diff --git a/.changeset/setup-packaged-automation-nav-entry.md b/.changeset/setup-packaged-automation-nav-entry.md new file mode 100644 index 0000000000..0f94800058 --- /dev/null +++ b/.changeset/setup-packaged-automation-nav-entry.md @@ -0,0 +1,28 @@ +--- +"@objectstack/platform-objects": patch +--- + +fix(platform-objects): add the Setup nav entry for the packaged-automation page (#12457, ADR-0126 §7.4) + +The packaged-automation page (on/off per packaged flow/action, clone for +flows) shipped complete in the console (objectui app-shell, registered under +the component ref `automation:packaged`), but no framework +`NavigationContribution` ever named that ref — so on every stock boot the +page was reachable only by a hand-typed URL +(`/apps/setup/component/automation/packaged`), and Setup's sidebar carried no +entry. Epic #12150's L5/L6-UI cards closed with the objectui half pinned and +the framework half missing; `content/docs/build-without-code.mdx` promises +the page publicly. + +`SETUP_NAV_CONTRIBUTIONS` now contributes `nav_packaged_automation` +(`type: 'component'`, `componentRef: 'automation:packaged'`) in `group_apps` +beside Packages — package administration is Operate (ADR-0084), and ADR-0126 +§7.4 rules "Studio keeps the editing; Setup gets the operational state". The +entry deliberately carries no `requiresService: 'automation'` (the action +switches ride the `sys_metadata_activation` ledger this package registers and +work on compositions with no automation service, #12419) and no +`requiredPermissions` (matches `nav_packages`: the app's `setup.access` gates +entry, the activation write doors enforce `manage_metadata` / the §5 operator +gate server-side). Labels land in all four locales with recorded source +hashes; `setup-packaged-automation-nav.test.ts` pins the framework half of +the cross-repo contract the objectui nav test pins from its side. diff --git a/docs/qa/platform-checklist/FOLLOW-UPS.md b/docs/qa/platform-checklist/FOLLOW-UPS.md index cdd78bc168..0f792c54a5 100644 --- a/docs/qa/platform-checklist/FOLLOW-UPS.md +++ b/docs/qa/platform-checklist/FOLLOW-UPS.md @@ -278,3 +278,101 @@ recorded as accepted scope. Until then, only a sweep like this one can catch it. - 2FA challenge gate + lockout: covered (`identity-auth.auth-method-matrix` + `two-factor-lockout.dogfood.test.ts`); endpoint existence pinned by `auth-route-ledger.conformance.test.ts`. + +## 8. Scoped sweep 2026-08-26 — ADR-0126 验收卡 #12438 (packaged flow/action disable + clone) + +Scoped sweep triggered by acceptance card #12438 (Epic #12150, nine PRs merged at +af56546). Three read-only hunters (routes/runtime · Setup/Studio UI · docs claims) +diffed the shipped ADR-0126 surface against the ledger; **the whole surface was +uncovered** (zero hits for `ADR-0126|sys_metadata_activation|ACTION_DISABLED` across +the checklist before this sweep). 14 items were authored + 1 revision in the same +change (automation ×4; api-backend ×2; access-security ×3; platform-core ×3; +studio-authoring ×2; `automation.flow-toggle-kill-switch` re-sourced rev 2 — its old +source cited the `flowEnabled` map §7.2 retired). Ledger 207 → 221 items. What follows +is what is NOT a checklist item. + +### 8a. Product defects found while grounding (decide handling) + +Each is captured inside a checklist item as an expected-fail probe or knownGap, so a +run records actual behavior instead of ticking green. + +| # | defect | evidence | captured in | sensitivity | +|---|---|---|---|---| +| D16 | **Setup packaged-automation page is unreachable — no nav entry.** The page + registry ref `automation:packaged` shipped in objectui (`app-shell/src/views/setup/PackagedAutomationPage.tsx`, `services/builtinComponents.tsx:64-69`), but `packages/platform-objects/src/apps/setup-nav.contributions.ts` names no such item (only `developer:packages` at :51); reachable only by typed URL `/apps/setup/component/automation/packaged`. Epic L5 (#6301) and L6-UI (#6412) are CLOSED, so this is a dropped half, not pending work. Card **A1 is expected to fail**; `build-without-code.mdx:37` ships the promise publicly (docs ahead of surface — the sequencing ADR §8.5 asked to avoid). | objectstack `setup-nav.contributions.ts:32-171` vs objectui `PackagedAutomationPage.navContribution.test.tsx` (pins only the objectui half) | automation.setup-packaged-automation-board (expected-fail nav clause — a typed-URL pass must not tick it) | UX/release — **FILED as #12457** (2026-08-26); testers warned on #12438 | +| D17 | **`PUT`/`DELETE /api/v1/automation/:name` bypass the packaged lock** — `manage_metadata` alone re-registers/unregisters a packaged flow's live definition; `registerFlow` has zero `_lock`/provenance check, while `/meta/flow` refuses the same write. ADR-0126 §2 "refused loudly at the write door" is unimplemented at this door. | `packages/runtime/src/domains/automation.ts:1826-1865`; `packages/services/service-automation/src/engine.ts:2625-2730` | access-security.packaged-flow-write-door-parity (expected-fail parity clauses) | integrity — admin-gated, not an escalation; safe to file | +| D18 | **The flow clone is engine-registry-only** — no `sys_metadata` write on the clone path, `_packageId`/`_provenance` stripped, no post-clone navigation; Studio's Automations rail lists package-scoped metadata, so a package-less engine-only clone matches no package. "The clone is an ordinary flow, yours to edit in Studio" (ADR §1.3/§7.1, `build-without-code.mdx:37`) is unproven; restart survival unknown. | `domains/automation.ts:1414-1436` (registerFlow only); `flow-clone.ts:225`; objectui `StudioDesignSurface.tsx:3286`; `PackagedAutomationPage.tsx:302-313` | automation.packaged-flow-clone-contract (honest restart+Studio clause, expected-fail) | correctness — safe to file | +| D19 | **The subflow refusal's own remedy is dead** — "Disable the calling flow(s) first" is what the 409 prescribes, but `packagedSubflowCallers` scans the registered flow map with **no activation check**, so an already-DISABLED caller still guards its callee; the prescribed sequence can never complete. No test covers the sequence. | `engine.ts:2932-2947` (no activation consult) vs `:2992-2996` (the prescription); ADR §7.3 | automation.packaged-flow-subflow-disable-refusal (expected-fail remedy-sequence clause) | correctness — safe to file | +| D20 | **Extension-field collision silently OVERRIDES the shipped base field** — `mergeObjectDefinitions` spreads `extension.fields` over `base.fields`, and the authoring schema documents "Fields to add/override" with priority "wins on conflict". No generic collision gate exists (`managed-extension-fields` covers better-auth sys objects only; ADR §3 adopts it as *prior art*, not a live gate). May be by-design — but then integrations.mdx's "never by reshaping what shipped" overstates. | `packages/objectql/src/registry.ts:146`; `packages/spec/src/data/object.zod.ts:2979,2996-2997` | platform-core.packaged-object-extend-only (knownGap, do-not-file-as-FAIL rule) | integrity/design — needs a ruling | +| D21 | **Non-durable toggle disclosure is a server log line only** — with no activation ledger attached, `toggleFlow` warns "IN PROCESS ONLY … will NOT survive a restart", but the response body (`{name, enabled}`) and every UI surface carry nothing; card 已知边界 3's asymmetry has no user-facing channel and no docs sentence anywhere. | `engine.ts:3018-3028`; `domains/automation.ts:1321-1322` | — (not an item; needs a maintainer call on the channel: response field vs UI copy vs docs) | UX-integrity — maintainer call | +| D22 | **`POST /automation/:name/clone` is unledgered** — live route absent from `route-ledger.ts` and from the JS client; `api-backend.route-ledger-live-parity` runs ledger→live only, so an unledgered mount is invisible to it. Suggest a ledger row now; consider a reverse-parity (live→ledger) item as a standing gate. | `domains/automation.ts:1340` vs `route-ledger.ts:316-336` | — (not an item) | low — internal discipline | + +Two objectui-side polish rows captured inside `automation.setup-packaged-automation-board` +rather than as defect rows: `actionErrorDetail` drops `details[]`, so field-level +prescriptions on validation refusals never reach the operator (`packages/core/src/actions/actionErrorDetail.ts:27-35` +— a narrowing, not a rewrite); and the page renders live switches for a plain member +with the refusal discovered only after the click (`ComponentNavView` has no gate; ledger +reads are deliberately open per `sys-metadata-activation.object.ts:153-157` — record the +posture, then decide which shape is wanted). + +### 8b. Docs drift (PD#10 class — file as docs fixes, not checklist items) + +- **`content/docs/kernel/contracts/metadata-service.mdx:211-324`** teaches the superseded + three-layer overlay protocol as the customization architecture — which ADR-0126 §6.4 + forbids citing — and its worked example overlays an **`object`** (tier B, + `allowOrgOverride:false`): the exact `NOT_OVERRIDABLE` phantom write the §6.1 wall refuses. +- **`content/docs/protocol/objectui/concept.mdx:415-450`** (+ `index.mdx:446`) promises + per-tenant field-level object customization ("Make phone required", "Add custom field + vip_status") — contradicts Regime E and the sentence now shipped at + `capabilities/integrations.mdx:17` ("not by editing what shipped"). +- **`build-without-code.mdx`** routes no-code admins to a code-only mechanism without + saying so — extension packages ship in code with the package + (`data-modeling/object-extensions.mdx:22,28`); one clause ("via an extension package + your developer ships") closes it. +- **The shipped Regime-C doors are undocumented**: no docs page for + `/automation/:name/clone`, `/actions/_activation/:object/:action`, the subflow 409, or + the §5 operator gate; `ACTION_DISABLED` appears only in the generated ledgers; + `references/api/automation-api.mdx:22`'s toggle row predates the durable/gated semantics. +- **ADR-0126's header still reads `Status: Proposed`** while all nine epic PRs are merged + at af56546 — flip it (the acceptance act evidently happened). + +### 8c. Card-accuracy notes for the tester (#12438) + +- **A1 will fail** (D16); the typed URL works and the rest of A/B/C is testable through it. +- **已知边界 3 is inaccurate as worded**: with the ledger attached, `toggleFlow` writes a + row for ANY flow it holds (`packageId: ''` for non-packaged — `actions.ts:139-145` + states the design); the non-persistent case is the ledger-less boot (D21), not + "non-packaged flows" per se. +- **D1's refusal on a stock (single-posture) boot is the `manage_metadata` tier** — the §5 + posture gate is deliberately inert under `single`; and the two doors speak different + sentences (flow: `FLOW_ENABLEMENT_DENY_MESSAGE`; action: the shared activation-gate + wording). **D2 needs a `group`/`isolated` boot** (enterprise `@objectstack/organizations`) + — no stock fixture; unit-pinned only. +- **B1: clone requires a new machine name AND a new label**, both mandatory server-side. +- **C1's code is `ACTION_DISABLED`; the packaged-flow disable reuses `FLOW_DISABLED`** — + distinguish the ledger disable from a Studio `status` disable by the message's + `sys_metadata_activation` phrase, never by code alone. + +### 8d. Fixtures worth adding (would un-block clauses recorded as knownGaps) + +- two stock objects sharing an action machine name → unblocks the 409 `RESOURCE_CONFLICT` + ambiguity arm (`api-backend.action-activation-door-contract`). +- a `group`/`isolated` posture boot recipe → unblocks the §5 operator-gate legs + (`access-security.activation-write-operator-gate`) and card row D2. +- a documented no-automation lean-composition boot for manual runners → the dogfood + harness (`bootStack(showcaseStack)` minus automation) is currently the only path for + `platform-core.activation-ledger-registration-home`'s 503-turnaround leg. + +### 8e. Checked and CLEAN (so the next sweep does not re-derive) + +- **E1's three-tier language landed verbatim** at `capabilities/integrations.mdx:17` and + `build-without-code.mdx:37`; repo-wide, no unconditional "install then customize in + Studio" claim remains in `content/docs`. +- **The dashboard overlay door exists** (`dashboard allowOrgOverride: true`, + `packages/spec/src/kernel/metadata-plugin.zod.ts:787`) — the display-class item asserts + the tier-1 promise on both view and dashboard. +- **`TenancyPostureSchema` is enumSource-pinnable** (direct inline `z.enum`, 3 members) — + pinned on the operator-gate item. **`sys_metadata_activation.metadata_type` is NOT + pinnable** (untyped `Field.text`, string-literal writers `'flow'`/`'action'`) — + hand-enumerated on the row-contract item and flagged un-pinned. +- **No security-sensitive finding to withhold**: D16–D22 are admin-gated behaviors or + disclosure-shape issues; nothing here discloses an unfixed privilege escalation. diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index da0105a773..4fde3b4606 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -2072,6 +2072,303 @@ }, { "revision": 2, "date": "2026-08-18", "change": "added the two markers this item was missing. blocked {by: fixture, ref: #9334} — three independent rounds (#9334, #9417, and the wave sweep) each re-derived the same dead end: the stock deployment is tenancy: single, so ADR-0095 Layer 0 is inert (tenant-layer.ts postureEnforcesWall returns null) and booting OS_TENANCY_POSTURE=isolated is correctly refused because @objectstack/organizations is not declared; without the marker the selector keeps listing it runnable and a fourth round pays for it again. automated.ref — no-active-organization-write-refusal.test.ts pins every clause at unit level and was already green, which RUNNER rule 6's converse says should have been recorded here", "ref": "#9386" } ] + }, + { + "id": "access-security.activation-write-operator-gate", + "title": "Activation-ledger write authority (ADR-0126 §5): the manage_metadata tier refuses first on every posture, and the shared operator gate — INERT on stock `single` — demands the platform_admin POSITION in walled postures, at both the flow and action doors", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "admin (first sign-in dev admin — holds manage_metadata; the entitled contrast)", + "plain member (everyone baseline only, no manage_metadata — the D1 tier-refusal persona)", + "a fresh member granted a SCRATCH permission set carrying system_permissions ['manage_metadata'] and NO platform_admin position — the inert-gate proof persona (holding the tier capability without the operator identity)", + "platform operator (platform_admin POSITION) — needed only for the BLOCKED walled-posture legs" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "stock showcase boots the `single` tenancy posture (no tenancy service / ADR-0093 D4-D5 fallback) — CONFIRM the posture before scoring, because it decides which gate can answer at all", + "a packaged flow: showcase_urgent_task_alert (com.example.showcase, examples/app-showcase/src/automation/flows/index.ts:1679) for POST /api/v1/automation/showcase_urgent_task_alert/toggle", + "a packaged action for POST /api/v1/actions/_activation/showcase_task/showcase_mark_done (examples/app-showcase/src/ui/actions/index.ts)", + "a runtime-authored scratch permission set with system_permissions ['manage_metadata'] granted to a fresh member via a sys_user_permission_set row (same runtime-authoring pattern access-security.fls-mask-and-strip's read half uses) — no stock showcase set grants manage_metadata" + ], + "knownGaps": [ + "The group/isolated legs (clauses 5) have NO stock fixture: enabling a walled posture requires the enterprise @objectstack/organizations runtime (packages/spec/src/security/tenancy-posture.ts:23-34 'Open code, entitled activation', ADR-0105 D12), no example app sets a posture, and booting OS_TENANCY_POSTURE=isolated without it is correctly REFUSED (ADR-0093 D5) — the same dead end three rounds re-derived on access-security.no-active-org-session-semantics (#9334). Those clauses are blocked(fixture); the unit pins in automated.ref are their standing oracle. Never fake a posture to tick them.", + "⚠️ On the STOCK `single` posture the §5 operator gate is INERT by design (activation-gate.ts:141-147 — postureEnforcesWall('single') is false, so the gate returns before the position is ever read). The refusal an unentitled caller gets on stock showcase is the manage_metadata AUTHORING TIER, not the operator gate — score clause 1/2 against the tier and never against §5, or the run records the wrong gate as proven." + ] + }, + "steps": [ + "boot showcase isolated; admin session; PROVE the posture is `single` (no tenancy service resolves / the deployment declares none) and record the evidence — the posture selects which gate can answer", + "as the plain member: POST /api/v1/automation/showcase_urgent_task_alert/toggle {\"enabled\": false} — capture status, code and the EXACT message string", + "as the plain member: POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {\"enabled\": false} — capture status, code and the exact message", + "as the plain member again: repeat the flow toggle with a deliberately INVALID body (e.g. {\"enabled\": \"false\"}) — the refusal must still be the 403, not a 400 body-validation answer (the gate runs before body validation, automation.ts:1017-1045; activation-gate.ts:128-131)", + "GET /api/v1/data/sys_metadata_activation (reads are open — sys-metadata-activation.object.ts:153-158) and confirm no row for either artifact was written by the refusals", + "author the scratch manage_metadata permission set, grant it to a fresh member, and prove server-side that the member's positions do NOT include platform_admin (session row / GET /auth/get-session — never the grant gesture)", + "as that member: POST /api/v1/automation/showcase_urgent_task_alert/toggle {\"enabled\": false} — expect 200; read the ledger row back (metadata_type 'flow', active false, organization_id NULL); then re-enable ({\"enabled\": true}) to restore the fixture", + "as the same member: POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {\"enabled\": false} — expect 200 + the metadata_type 'action' row; re-enable to restore", + "walled-posture legs: BLOCKED on stock fixtures (see knownGaps) — the group/isolated matrix (org admin with manage_metadata refused naming the posture and the per-artifact remedy; platform_admin POSITION admitted; no row on refusal) is pinned at unit level by automated.ref" + ], + "acceptance": [ + { + "clause": "flow door, stock `single` posture: the refusal an unentitled caller gets is the manage_metadata AUTHORING TIER — 403 PERMISSION_DENIED with the exact enablement sentence — and NOT the §5 operator gate, which is inert on `single`; the run must record which gate it scored", + "oracle": "api", + "verify": "POST /api/v1/automation/showcase_urgent_task_alert/toggle as the plain member answers 403, code PERMISSION_DENIED, message exactly 'Enabling or disabling an automation flow requires the `manage_metadata` capability.' (FLOW_ENABLEMENT_DENY_MESSAGE, packages/runtime/src/domains/automation.ts:333-334 — the toggle-specific sentence, not the generic 'Authoring automation flows requires…' one). A message naming the tenancy posture here would mean the §5 gate answered — on `single` that is a FAIL of this clause, not a pass", + "evidence": "the refusal trace with the full message string quoted" + }, + { + "clause": "action door, stock `single` posture: same tier, the actions domain's own call into the shared gate — 403 PERMISSION_DENIED naming manage_metadata and the #10243 'functionally equivalent to deleting it' rationale, with subject 'a packaged action'", + "oracle": "api", + "verify": "POST /api/v1/actions/_activation/showcase_task/showcase_mark_done as the plain member answers 403 PERMISSION_DENIED with the refuseUngrantedActivationAuthoring sentence (packages/runtime/src/domains/activation-gate.ts:184-203, called at domains/actions.ts:154; the posture arm at actions.ts:156 stays silent on `single`)", + "evidence": "the refusal trace with the message quoted" + }, + { + "clause": "a refused caller writes nothing and learns nothing: no sys_metadata_activation row exists for either artifact after the refusals, and an unentitled caller with an INVALID body still gets the 403 — never a 400 that maps out the toggle contract (both gates run before body validation)", + "oracle": "api", + "verify": "GET /api/v1/data/sys_metadata_activation filtered on the two names returns no row attributable to the refused attempts; the invalid-body probe as the plain member answers 403 PERMISSION_DENIED, not 400 (gate placement: automation.ts:1017-1045 ahead of the service probe and body checks; activation-gate.ts:128-131)", + "evidence": "the ledger reads + the invalid-body trace" + }, + { + "clause": "the §5 gate is INERT on `single`, not satisfied: a caller holding manage_metadata but NOT the platform_admin POSITION succeeds at BOTH doors, and each write lands one install-level ledger row (organization_id NULL) — install-level and org-level are the same scope on one logical tenant (activation-gate.ts:22-27,141-147)", + "oracle": "api", + "verify": "the scratch-set member's positions are proven server-side to exclude platform_admin (BUILTIN_IDENTITY_PLATFORM_ADMIN = 'platform_admin', packages/spec/src/identity/eval-user.zod.ts:32), then both toggles answer 200 and GET /api/v1/data/sys_metadata_activation shows the metadata_type 'flow' and 'action' rows with active false, organization_id NULL; re-enable restores active true", + "evidence": "the position proof + both 200 traces + the ledger rows" + }, + { + "clause": "walled postures (`group`/`isolated`) — BLOCKED on stock fixtures (knownGaps, #9334 precedent): an org admin holding manage_metadata is refused 403 PERMISSION_DENIED with a message naming the POSTURE and the per-artifact remedy — flows recommend the clone path, actions deliberately do NOT (operator + sibling authoring instead) — no row is written on refusal, and a caller carrying the platform_admin POSITION (not a capability) is admitted", + "oracle": "test", + "verify": "stands on the unit pins until an enterprise-posture fixture exists: packages/runtime/src/domains/automation-activation-posture-gate.test.ts (#12157) and action-activation-posture-gate.test.ts (#12160) drive the posture x identity matrix against both doors and assert toggleFlow/the write was never entered on refusal; the refusal sentence and remedies are activation-gate.ts:100-118,155-163. Do not tick this clause from an HTTP probe on a faked posture", + "evidence": "the two test runs' output, cited by test name" + }, + { + "clause": "engine self-invocation bypasses both tiers (isSystem, never settable from the wire) — the boot pull and ledger hydration must not be refused by the gate that guards the wire", + "oracle": "test", + "verify": "the isSystem arms of the same unit pins (activation-gate.ts:139,190 — both return undefined before any check); corroborate live by the fact that a restart re-hydrates flow activations without error while the gate is armed", + "evidence": "test output + the boot log line" + } + ], + "negative": [ + "the §5 posture-gate sentence ('…this deployment runs the \\'single\\' tenancy posture…') appearing on a stock boot is a FAIL — the gate must not fire where postureEnforcesWall is false", + "run the refused calls as ADMIN too and confirm 200 (wrong-persona both sides): the denial must come from the missing capability, not a broken route", + "the two doors' tier sentences must not swap: the flow door owns its own FLOW_ENABLEMENT_DENY_MESSAGE while the action door speaks the shared activation-gate sentence — a run quoting the wrong door's string has probed the wrong door", + "absent tenancy service fails OPEN by design (activation-gate.ts:50-56,141-147): do not file a missing-refusal finding against a deployment with no tenancy service — that is ADR-0093 D4/D5, not a hole" + ], + "variants": [ + "single (stock — gate inert, tier decides)", + "group (blocked: enterprise posture fixture)", + "isolated (blocked: enterprise posture fixture)" + ], + "enumSource": { + "file": "packages/spec/src/security/tenancy-posture.ts", + "export": "TenancyPostureSchema", + "expect": 3 + }, + "automated": { + "kind": "unit", + "ref": "packages/runtime/src/domains/automation-activation-posture-gate.test.ts (#12157, flow door) + packages/runtime/src/domains/action-activation-posture-gate.test.ts (#12160, action door) — the full posture x identity matrix for both doors, including the group/isolated legs this item marks blocked(fixture), the no-write-on-refusal assertion, and the isSystem bypass" + }, + "source": [ + "packages/runtime/src/domains/activation-gate.ts (the shared §5 gate: posture read 141-147, platform_admin POSITION test 149-150, manage_metadata tier 184-203, per-artifact remedies 100-118, fail-open rationale 50-56)", + "packages/runtime/src/domains/automation.ts:333-334 (FLOW_ENABLEMENT_DENY_MESSAGE), :471-474 (isFlowActivationWrite), :1017-1045 (gate ordering ahead of service probe and body checks)", + "packages/runtime/src/domains/actions.ts:154-157 (both tiers at the action door)", + "packages/spec/src/security/tenancy-posture.ts (TenancyPostureSchema — the variants source; postureEnforcesWall; the ADR-0105 D12 entitlement note)", + "docs/adr/0126-packaged-metadata-customization-model.md §5 (D3: install-level rows, operator-gated in multi-org postures)", + "#12438 (the sweep), #12159 (ADR-0126 flow legs), #10243 (the measured incident the gate makes durable), #12157 / #12160 (the two door cards)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-26", + "change": "new — ADR-0126 §5 write authority had no checklist coverage. Authored with the stock-posture truth stated up front (the §5 gate is INERT on `single`; the D1 refusal is the manage_metadata tier) so a runner never scores the wrong gate, and with the group/isolated legs blocked(fixture) on the enterprise-posture gap (#9334 precedent) instead of faked. Variants pinned to TenancyPostureSchema (verified extractable by the validator's enumSource extractor)", + "ref": "#12438" + } + ] + }, + { + "id": "access-security.packaged-flow-write-door-parity", + "title": "Write-door parity on a packaged flow: PUT/DELETE /automation/:name must refuse the same packaged artifact that PUT /meta/flow/:name refuses (ADR-0126 §2 locked base) — EXPECTED FAIL today, the /automation door sails through on manage_metadata alone", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "admin (holds manage_metadata — deliberately, so every refusal observed is the package LOCK and never the capability tier)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a packaged flow: showcase_urgent_task_alert (com.example.showcase, examples/app-showcase/src/automation/flows/index.ts:1679)", + "capture the flow's full current definition BEFORE any probe (GET /api/v1/automation/showcase_urgent_task_alert) — it is the restore payload" + ], + "knownGaps": [ + "the acceptance is the ADR-0126 §2 PARITY PROMISE ('the packaged base is locked — in-place edit refused loudly at the write door'), not today's behavior: as of #12438 the /automation door has NO lock — PUT/DELETE /automation/:name reach registerFlow/unregisterFlow with only the manage_metadata authoring gate in front (packages/runtime/src/domains/automation.ts:1826-1865), and the engine has zero lock/provenance check on that path (packages/services/service-automation/src/engine.ts:2625-2730). Clauses 2-3 are EXPECTED FAILS: a 200 there, where /meta refuses the same artifact, is the product finding, tracked centrally in FOLLOW-UPS (#12438). Keep the parity promise as the acceptance so the item flips green when the door is locked, without a rewrite" + ] + }, + "steps": [ + "boot showcase isolated; admin session; GET /api/v1/automation/showcase_urgent_task_alert and store the full definition (the restore payload); also GET /api/v1/meta/flow/showcase_urgent_task_alert?layers=true to prove the artifact is package-backed (populated code layer, _packageId com.example.showcase)", + "control leg, the /meta door: PUT /api/v1/meta/flow/showcase_urgent_task_alert with a trivially modified copy of the definition (e.g. label suffix) — capture status, code and WHICH layer answered; repeat with ?package=com.example.showcase and capture that code too", + "probe leg 1: PUT /api/v1/automation/showcase_urgent_task_alert with the same trivially modified definition — capture status and, if 2xx, GET the flow back to prove the live registration mutated", + "probe leg 2: DELETE /api/v1/automation/showcase_urgent_task_alert — capture status and, if 2xx, confirm GET /api/v1/automation/showcase_urgent_task_alert now 404s (the shipped flow is gone from the live engine)", + "RESTORE, unconditionally: PUT /api/v1/automation/showcase_urgent_task_alert with the stored original definition (re-registering is the cheap path; a cold restart's boot flow pull is the fallback), then GET it back and diff against the stored capture — byte-identical", + "verify the flow still fires: trigger its record-change mutation once and confirm a run appears (the restore must revive the trigger binding, not just the definition read)" + ], + "acceptance": [ + { + "clause": "control: the /meta door refuses the packaged flow write with a ledgered metadata-protocol code, and the definition is unchanged — this is the same locked-base class access-security.readonly-package-locks-studio pins on the /meta/object door", + "oracle": "api", + "verify": "PUT /api/v1/meta/flow/showcase_urgent_task_alert answers >=400 with a ledgered code — record WHICH (expect the NOT_OVERRIDABLE family without ?package=, ITEM_LOCKED when ?package= names the read-only base; do not over-pin the exact spelling, the owd-save-gate rev-3 lesson — the layer that answers is part of the evidence, not an assumption); GET before/after identical", + "evidence": "both PUT traces (with and without ?package=) + the before/after /meta reads" + }, + { + "clause": "parity, update door: PUT /api/v1/automation/showcase_urgent_task_alert against the SAME packaged artifact is refused — ⚠️ EXPECTED FAIL today: the door runs only the manage_metadata authoring gate (automation.ts:1826-1857) and registerFlow re-registers with no lock or provenance check (engine.ts:2625-2715), so a 200 here while /meta refused the identical artifact IS the finding. Record the fail with both traces side by side; the defect is tracked centrally in FOLLOW-UPS (#12438) — do not re-file it per run", + "oracle": "api", + "verify": "same admin session, same artifact, same-shape body at both doors; the verdicts must MATCH. A 2xx on /automation with a mutated GET read-back, paired with the /meta refusal from clause 1, is a fail of this clause and the expected present-day outcome", + "evidence": "the /automation PUT trace + the mutated (or unchanged) GET read-back, paired with clause 1's refusal" + }, + { + "clause": "parity, delete door: DELETE /api/v1/automation/showcase_urgent_task_alert is refused for the same reason — ⚠️ EXPECTED FAIL today (unregisterFlow, engine.ts:2717-2730, removes the shipped flow from the live engine with no check; 'delete first, refuse second' is the exact shape the #10145 measurement recorded at this door before the capability gate existed, and the lock half is still missing)", + "oracle": "api", + "verify": "DELETE answers >=400 and the flow still serves; a 200 followed by a 404 on GET /api/v1/automation/showcase_urgent_task_alert is the fail (and the deletion this item's restore step exists to undo)", + "evidence": "the DELETE trace + the follow-up GET" + }, + { + "clause": "the probes leave no residue: after the restore step the live registration is byte-identical to the pre-probe capture AND the flow demonstrably fires again — a restore that re-registers the definition but leaves the trigger unbound is not a restore", + "oracle": "api", + "verify": "GET /api/v1/automation/showcase_urgent_task_alert diffs clean against the stored capture; one triggering mutation produces a run (GET the flow's runs, or the /_status enabled+bound read)", + "evidence": "the diff + the post-restore run evidence" + } + ], + "negative": [ + "a 403 on the /automation probes coming from a MISSING manage_metadata capability is the wrong tier and proves nothing about the lock — this item's persona holds the capability precisely so any refusal observed is the package lock; quote the refusal message to prove which gate answered", + "do NOT read clause 1's /meta refusal as covering the /automation door — two doors onto one artifact are the item; scoring one door twice is the mistake", + "the toggle door is NOT this item: POST /:name/toggle is the sanctioned activation switch (access-security.activation-write-operator-gate); this item is about DEFINITION writes and deletion of the locked base (ADR-0126 §2/§6 — sole definition ledger, never silent override)" + ], + "traps": [ + "destructive-in-place", + "dispatcher-vs-hono-route" + ], + "source": [ + "packages/runtime/src/domains/automation.ts:1826-1865 (PUT/DELETE /automation/:name → registerFlow/unregisterFlow, manage_metadata gate only)", + "packages/services/service-automation/src/engine.ts:2625-2730 (registerFlow/unregisterFlow — zero lock/provenance check on this path)", + "docs/adr/0126-packaged-metadata-customization-model.md §2 (Regime C: 'the packaged base is locked — in-place edit refused loudly at the write door') + §6 wall 2 (sole definition ledger)", + "packages/plugins/plugin-security/src/packaged-permission-set-lock.ts (the same lock implemented at the permission-set data door — the regime's first instance, proving the refusal shape the /automation door lacks)", + "cross-ref access-security.readonly-package-locks-studio (the /meta door's locked-base class on objects) + access-security.owd-save-gate rev 3 (why the /meta control leg records the answering layer instead of assuming it)", + "#12438 (the sweep that measured the gap; FOLLOW-UPS carries the product finding centrally), #12159 (ADR-0126 flow implementation legs)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-26", + "change": "new — #12438 measured that PUT/DELETE /automation/:name re-register/unregister a PACKAGED flow on manage_metadata alone while PUT /meta/flow/:name refuses the same artifact: the ADR-0126 §2 lock is unimplemented at the /automation door. Authored as a door-parity item with the parity promise as the acceptance and the present-day 200 recorded as an expected fail (tracked centrally in FOLLOW-UPS), plus a mandatory restore step because the probe mutates a live engine registration", + "ref": "#12438" + } + ] + }, + { + "id": "access-security.packaged-permission-set-lifecycle", + "title": "Packaged permission-set lifecycle (#11513) survives ADR-0126 unchanged: locked base at the data door, clone-to-customize with every facet carried, `active` row-state flip — and NONE of it writes a sys_metadata_activation row", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": [ + "admin (drives the lock probes, the lifecycle actions and the Setup surface)", + "member M holding the packaged set under test (granted showcase_contributor via a sys_user_permission_set row) — the persona whose live access proves the active flip enforces" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a package-declared permission set: showcase_contributor (declared by com.example.showcase in examples/app-showcase/src/security/permission-sets.ts, projected to a sys_permission_set row at boot) — the SchemaRegistry, not the row's managed_by column, is what the lock reads (packaged-permission-set-lock.ts)", + "the Setup permission-sets surface rendering the object's own actions (Activate / Deactivate / Clone on locations list_item + record_header — packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts:41-155)", + "member M granted showcase_contributor, with a showcase_contributor-only verb to probe (e.g. a project PATCH the everyone baseline does not grant — compute the baseline union per access-security.crud-permission-matrix's knownGaps before picking it)" + ], + "knownGaps": [ + "the ledger-wall clause rides the maintainer ruling of 2026-08-26 in #12159 (ADR-0126 §8 item 3): permission sets KEEP the landed #11513 machinery — the kind's own sys_permission_set.active column and the data-door lock — and do NOT write sys_metadata_activation until a separate convergence card decides projection-vs-migration. If that card lands, this clause flips meaning: re-ground it there before scoring, rather than filing the new rows as a scope-wall breach" + ] + }, + "steps": [ + "boot showcase with the console; admin session; provision member M (grant showcase_contributor); baseline: as M exercise the contributor-granted probe verb — 2xx; count sys_metadata_activation rows (GET /api/v1/data/sys_metadata_activation — reads are open)", + "lock, API: as admin PATCH /api/v1/data/sys_permission_set/ with a DEFINITION change (e.g. an object_permissions tweak) — capture the 403 and its message verbatim", + "lock, UI: attempt the same edit through the Setup/Studio permission-set surface and capture what renders (the server refusal text, not a local fallback)", + "row-state carve-out: run the Deactivate action from the Setup list (or PATCH /api/v1/data/sys_permission_set/ {\"active\": false} — exactly what the action sends, bodyExtra sys-permission-set.object.ts:66) — expect 2xx, NOT the lock", + "as M: repeat the probe verb — now refused; re-read the row (active false) and confirm M's sys_user_permission_set assignment row still exists (deactivation keeps assignments, per the action's own confirmText)", + "clone: run the Clone action on showcase_contributor (POST /api/v1/data/sys_permission_set — new label + snake_case name both required); read the clone back and field-compare every definition facet against the base; attempt the clone again with the SAME name — 409", + "clone is org-owned: PATCH the clone's definition (same shape step 2 sent at the base) — 2xx; grant the clone to a fresh member and prove one of its verbs live", + "reactivate the base (Activate action / {\"active\": true}) — as M the probe verb works again", + "ledger wall: re-count and re-list sys_metadata_activation — identical to step 1's baseline: no metadata_type 'permission' row, no row naming showcase_contributor or the clone, from ANY of the above" + ], + "acceptance": [ + { + "clause": "locked base: an in-place DEFINITION write against a package-declared set is refused 403 NOT_OVERRIDABLE with the #11513 message naming the sanctioned path (clone it), and the stored definition is unchanged — fail-closed: undeterminable provenance refuses too, with its own distinct message", + "oracle": "api", + "verify": "the PATCH answers 403, code NOT_OVERRIDABLE, message from PackagedPermissionSetLockedError (packages/plugins/plugin-security/src/packaged-permission-set-lock.ts:265-283 — names the package and the Clone action; the provenance-unknown refusal at :286-299 is a different sentence, distinguish by message); a re-read shows the definition facets unchanged", + "evidence": "the PATCH trace with message quoted + the before/after reads" + }, + { + "clause": "the active flip is ROW STATE, not a definition write: a bare {active} PATCH passes the lock (the #4669 carve-out runs BEFORE the lock — permission-set-projection.ts:1128-1134) and lands on the driver, and enforcement follows it — M's granted verb is refused while active=false and restored on re-activation, with M's assignment row intact throughout", + "oracle": "api", + "verify": "deactivate 2xx (a 403 here is the lock swallowing the carve-out — a FAIL that breaks the shipped Deactivate action); M's probe verb >=400 while inactive, 2xx after reactivation; sys_user_permission_set row for M present at every step", + "evidence": "the lifecycle traces + M's before/during/after probes + the assignment-row reads" + }, + { + "clause": "clone contract (#11703 regression guard): new label AND machine name mandatory; ALL definition facets carried — description, object_permissions, field_permissions, AND the three #11703 dropped ones (system_permissions, row_level_security, tab_permissions); admin_scope deliberately ABSENT (ruled 2026-08-24, stated in the action's own description); the clone starts active; a taken name answers 409; no linkage column (no cloned_from anywhere — amendment ruling 2)", + "oracle": "api", + "verify": "field-compare the clone row against the base per the clone_permission_set params list (sys-permission-set.object.ts:137-154 — the params list IS the payload); admin_scope empty/default on the clone; duplicate-name POST → 409 (projection duplicate check); no cloned_from/replaced_by key on the row", + "evidence": "the clone POST + the field-by-field diff + the 409 trace" + }, + { + "clause": "the clone is an ordinary org-owned set: its definition is freely editable (the same PATCH shape the base refused answers 2xx) and its grants enforce live for a member holding it", + "oracle": "api", + "verify": "PATCH the clone's definition 2xx + persisted re-read; a member granted the clone exercises one of its verbs 2xx", + "evidence": "the clone-edit trace + the member probe" + }, + { + "clause": "⭐ the no-ledger-row wall (ruling 2026-08-26 in #12159; ADR-0126 §8 item 3): NEITHER the active flip NOR the clone writes any sys_metadata_activation row — permission sets ride the kind's own active column until the convergence card; a metadata_type 'permission' row appearing from these operations is a FAIL of the model (the same scope wall platform-core.activation-ledger-row-contract owns for the after-every-switch sweep)", + "oracle": "api", + "verify": "GET /api/v1/data/sys_metadata_activation before and after the full lifecycle: row count identical, no metadata_type 'permission' row, no row naming showcase_contributor or the clone (grounded: packages/plugins/plugin-security has zero references to sys_metadata_activation — the ledger writers are the flow toggle and action _activation doors only)", + "evidence": "the before/after ledger listings" + }, + { + "clause": "the Setup surface serves the lifecycle honestly: Activate/Deactivate/Clone actions render on the permission-set list/record surfaces, the Clone dialog demands both new-name fields and explains the admin_scope omission, and an attempted in-place edit surfaces the SERVER's refusal text — not a local fallback string", + "oracle": "dom", + "verify": "after a screenshot confirms the surface rendered, read the action affordances and the refusal rendering; the displayed refusal must contain the server message's clone-path sentence verbatim", + "evidence": "screenshots + the DOM read of the refusal text" + } + ], + "negative": [ + "a 403 on the BARE {active} PATCH is a FAIL even though it looks like the lock working — the row-state carve-out is load-bearing for the shipped Deactivate/Activate actions (permission-set-projection.ts:1140-1144 says so in as many words)", + "a clone missing system_permissions, row_level_security or tab_permissions is the exact #11703 silent-grant-loss regression this item guards — diff every facet, not just the two that were never dropped", + "the two 403 sentences (packaged-locked vs provenance-unknown) must not be conflated: both are NOT_OVERRIDABLE, and only the message says whether the lock decided or failed closed", + "cross-refs, to keep one defect one count: the /meta-door package lock on objects is access-security.readonly-package-locks-studio; the WRITABLE-set edit loop through /meta/permission is access-security.permission-matrix-edit-loop (its fixture note explicitly excludes locked package sets — this item is that excluded case, at the DATA door); grant enforcement itself is access-security.crud-permission-matrix" + ], + "traps": [ + "wrong-persona", + "stale-console-bundle", + "hydration-race" + ], + "automated": { + "kind": "unit", + "ref": "packages/plugins/plugin-security/src/packaged-permission-set-lock.test.ts (provenance classifier, fail-closed verdicts, the clone action's payload pinned facet-by-facet against the params list — pin 6) + packaged-permission-set-restore-leg.test.ts; the live enforcement flip and the no-ledger-row wall stay manual" + }, + "source": [ + "packages/plugins/plugin-security/src/packaged-permission-set-lock.ts (classifier + both refusals; the SchemaRegistry-not-managed_by decision)", + "packages/plugins/plugin-security/src/permission-set-projection.ts:1076-1169 (insert-path lock + duplicate 409; the #4669 row-state carve-out at :1128-1134 ordered BEFORE the update-path lock at :1135-1167)", + "packages/plugins/plugin-security/src/objects/sys-permission-set.object.ts:41-155 (activate/deactivate bodyExtra {active}, clone_permission_set params — the payload contract)", + "docs/adr/0126-packaged-metadata-customization-model.md §1.1 (the #11513 machinery, named as the regime's first instance), §8 item 3 (permission sets converge later; the landed shape stays valid meanwhile)", + "#11513 (lock the base, clone to customize — the 2026-08-24 ruling), #11703 (the three dropped clone facets), #12159 (ruling 2026-08-26: no ledger rows for permission sets pending the convergence card), #12438 (the sweep)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-26", + "change": "new — the landed #11513 surface (data-door lock, clone action, active row-state lifecycle) had no checklist item: readonly-package-locks-studio covers the /meta door on objects and permission-matrix-edit-loop explicitly scopes itself to WRITABLE sets, so the packaged-set data-door lifecycle was uncovered end to end. Authored as the ADR-0126 regression guard with the no-ledger-row wall as its ⭐ clause, per the 2026-08-26 ruling in #12159 (card 已知边界 4): flipping active or cloning must keep writing sys_permission_set state only, never a sys_metadata_activation row", + "ref": "#12438" + } + ] } ] } \ No newline at end of file diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index 14d86f9507..5c9d6526a2 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -1319,6 +1319,243 @@ "ref": "#9299" } ] + }, + { + "id": "api-backend.packaged-action-disabled-dispatch", + "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": 1, + "priority": "P0", + "surface": "api", + "personas": [ + "admin (dev admin — holds manage_metadata for the activation flips via admin_full_access, and is the invoker on the 409 legs; the probe actions declare no requiredPermissions, so any authed caller clears the ADR-0066 D4 gate)", + "any authenticated caller for the 403-oracle leg — showcase_zoo_perm_missing requires showcase.restricted_ops, a capability the app declares and grants to NOBODY (predicate-matrix.action.ts), so even the admin answers 403 there" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_mark_done on showcase_task (ui/actions/index.ts — script action, registered body, NO declared params, no requiredPermissions): the clean 409 probe", + "showcase_action_param_gallery on showcase_field_zoo (ui/actions/index.ts — script action with a required param p_text): the param-ordering discriminator — the ONLY stock action whose param contract can produce a contrasting 400, because enforceActionParams (action-execution.ts) returns null outright for an action with no declared params", + "showcase_zoo_perm_missing on showcase_field_zoo (predicate-matrix.action.ts — requiredPermissions: ['showcase.restricted_ops'], granted to nobody): the 403-oracle probe", + "showcase_portfolio_snapshot (ui/actions/index.ts — object-less/global, and the ONLY stock action with ai.exposed: true): the live MCP-door probe; its activation flip spells the object segment `global` (/actions/_activation/global/showcase_portfolio_snapshot)" + ], + "knownGaps": [ + "The MCP door cannot be driven live with showcase_mark_done: the bridge's AI-exposure gate (actionAiExposureError, fail-closed on ai.exposed !== true) sits AHEAD of the activation consult, so a non-exposed action answers the exposure refusal regardless of its switch. showcase_portfolio_snapshot is the only stock ai.exposed action; if the MCP transport itself is not drivable in the run environment, score the MCP clause from the unit pin (action-activation-dispatch.test.ts 'door 2') and record which oracle the verdict rests on.", + "The record-load half of the ordering proof has NO contrasting refusal: the REST door's record load is best-effort (try/catch in domains/actions.ts — a missing record passes an empty record for new-record actions), so a nonexistent recordId never 404s on its own, enabled or disabled. The record leg is proven by code order plus the absence of any read/side effect, never by expecting a 404 from the enabled control." + ] + }, + "steps": [ + "boot showcase isolated on the real HTTP stack (os dev); sign in as admin", + "disable the probe: POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {\"enabled\": false} → 200 (the door's own arms are api-backend.action-activation-door-contract's item — do not re-prove them here); confirm the row via GET /api/v1/data/sys_metadata_activation (metadata_type 'action', name showcase_mark_done, organization_id NULL, active false)", + "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", + "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" + ], + "acceptance": [ + { + "clause": "the REST door refuses a disabled packaged action 409 ACTION_DISABLED and dispatches NOTHING: status 409, code ACTION_DISABLED (registered in the ADR-0112 ledger, error-code-ledger.zod.ts), message = the engine's own describeDisabledAction sentence naming the ledger — and no side effect lands (the mark_done body's done/progress write never ran)", + "oracle": "api", + "verify": "step-3 trace: 409 + code ACTION_DISABLED + the disabled-action sentence; follow-up reads show no task mutated", + "evidence": "the trace + the control re-reads" + }, + { + "clause": "the activation consult PRECEDES the ADR-0104 param contract: the disabled param-gallery invocation with a garbage bag answers 409 ACTION_DISABLED (a disabled action discloses no param shape), and the identical request against the re-enabled action answers 400 'Invalid action params' — the contrast that proves the ordering, since enforceActionParams demonstrably fires for this action", + "oracle": "api", + "verify": "step-4 pair: disabled → 409 ACTION_DISABLED (NOT 400); enabled → 400 naming the param issues. ⛔ Run the discriminator on showcase_action_param_gallery, never on showcase_mark_done — the latter declares no params, so its enabled control answers 200/404-ish shapes and proves nothing about ordering", + "evidence": "both traces side by side" + }, + { + "clause": "the consult also precedes the record load, proven by absence not by a contrasting refusal: the 409 arrives with a nonexistent recordId in play and no record read occurs — the enabled control CANNOT 404 on the bad id because the load is best-effort by design (domains/actions.ts catches and passes an empty record), so do not score this leg by expecting one", + "oracle": "api", + "verify": "the step-3/4 disabled traces are 409 with the bogus recordId present; the run record notes the enabled-control caveat rather than a fabricated 404 expectation", + "evidence": "traces + run-record note" + }, + { + "clause": "the 403 is never an oracle for the switch: an unentitled caller (showcase_zoo_perm_missing — the ADR-0066 D4 gate fires) answers 403 whether the action is enabled OR disabled, and the two 403 bodies are identical — the D4 gate runs AHEAD of the activation consult, so a caller without the capability learns nothing about what this installation switched off", + "oracle": "api", + "verify": "step-5 byte-compare: both refusals are 403 with the same capability-naming message; a 409 to the unentitled caller is the leak and an immediate FAIL", + "evidence": "the two 403 bodies, diffed" + }, + { + "clause": "the MCP run_action bridge (door 2 of 2) refuses the same switch with the same envelope: the disabled showcase_portfolio_snapshot answers a tool error carrying ACTION_DISABLED / 409 (invokeBusinessAction throws with code+status so resolveThrownHttpError keeps the ADR-0112 envelope), and re-enabling re-arms the tool", + "oracle": "api", + "verify": "step-6 tool-error envelope carries the code and the SAME engine sentence as the REST door (one describeDisabledAction producer, not two agreeing texts); the re-enabled call runs", + "evidence": "tool traces before/after the flip" + }, + { + "clause": "the switch is DURABLE: after a full server restart the action is still refused 409 (hydrateActionActivations re-applies the ledger at boot), and re-enabling via the door both re-arms dispatch and UPDATES the row in place — the row records the administrator's choice, it is never deleted (ADR-0126 §6 wall 3)", + "oracle": "api", + "verify": "step-7: post-restart invocation 409; post-re-enable invocation dispatches; sys_metadata_activation still carries the row with active true", + "evidence": "post-restart traces + the row read" + }, + { + "clause": "a disabled `type: flow` action is refused by its OWN switch, ahead of the type branch — the refusal is ACTION_DISABLED for the action's name, independent of anything the target flow's own ledger row says", + "oracle": "api", + "verify": "step-8: 409 ACTION_DISABLED naming showcase_bulk_reassign; the automation service is never consulted for the run", + "evidence": "the trace" + } + ], + "negative": [ + "a 400 (param contract) or a handler/record answer for a DISABLED action means the consult sits too deep — the disabled action leaked its param shape or read its record, which is exactly what the ordering exists to prevent", + "a 409 ACTION_DISABLED answered to a caller the D4 gate should refuse is the information leak (the 403 must win); conversely ACTION_DISABLED must NEVER surface on a stock boot with no ledger row — absence means ACTIVE (a stock boot behaves exactly as it always has)", + "an anonymous caller answers the ADR-0056 401 baseline before any of this — a 409/403 to an anonymous probe means the anonymous floor moved" + ], + "variants": [ + "door:rest (POST /actions/:object/:action)", + "door:mcp (run_action bridge)", + "type:script (showcase_mark_done, showcase_action_param_gallery)", + "type:flow (showcase_bulk_reassign — refused by the ACTION's switch)" + ], + "automated": { + "kind": "dogfood", + "ref": "packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts (live boots, both compositions: the flip lands 200, ONE install-level row with organization_id NULL, REST-door dispatch 409, re-enable UPDATES the row and re-arms, flows+actions share one table without touching each other's rows) + packages/runtime/src/domains/action-activation-dispatch.test.ts (unit: both doors incl. the MCP envelope, the 403-not-an-oracle leg, the flow-type-refused-by-its-own-switch leg, absence-means-active). ⚠️ Neither pin covers the RESTART leg, the param-ordering contrast, or a LIVE MCP transport call — those stay manual" + }, + "traps": [ + "dispatcher-vs-hono-route", + "wrong-persona", + "auth-state-leak", + "first-boot-cold-start" + ], + "source": [ + "packages/runtime/src/domains/actions.ts (the REST door's ordered gates: ADR-0066 D4 capability gate → activation consult → type branch → ADR-0104 param contract → best-effort record load)", + "packages/runtime/src/action-execution.ts (disabledActionRefusal — ACTION_DISABLED_CODE/STATUS 409, the shared consult both doors call; invokeBusinessAction — the MCP door, whose AI-exposure + capability gates run ahead of the consult)", + "packages/objectql/src/engine.ts (isActionEnabled / describeDisabledAction / hydrateActionActivations / setActionActive — projection over the durable ledger, row-first then projection)", + "packages/spec/src/api/error-code-ledger.zod.ts (ACTION_DISABLED registered — the *_DISABLED family)", + "examples/app-showcase/src/ui/actions/index.ts + predicate-matrix.action.ts (the four probe actions)", + "docs/adr/0126-packaged-metadata-customization-model.md (§8 item 2, §4 absence-means-active, §6 wall 3)", + "#12438", + "Epic #12150" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-26", + "change": "new — ADR-0126 §8 item 2 landed the packaged-action kill switch with a two-door consult and nothing in the ledger asserted the dispatch-side contract: the 409, its position in the gate order (after the D4 403, before params/record), the MCP twin, or restart durability. Register corrections folded in during source verification: the param-ordering discriminator must be showcase_action_param_gallery (showcase_mark_done declares no params, so enforceActionParams is a no-op for it and its enabled control proves nothing); the 403 leg must be an action with requiredPermissions (showcase_zoo_perm_missing — mark_done has none, every authed caller passes its gate); the live MCP leg must be showcase_portfolio_snapshot (the only ai.exposed stock action — the exposure gate refuses everything else ahead of the consult); and the record-load leg has no contrasting 404 because the load is best-effort by design", + "ref": "#12438" + } + ] + }, + { + "id": "api-backend.action-activation-door-contract", + "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": 1, + "priority": "P2", + "surface": "api", + "personas": [ + "admin (dev admin — manage_metadata via admin_full_access; the happy-path and body-arm caller)", + "a non-admin member for the authority-first leg (Mei Phone phone.demo@example.com or Ada Auditor auditor.demo@example.com, DEMO_PERSONA_PASSWORD 'showcase123' — no showcase permission set grants manage_metadata, only admin_full_access carries it)", + "anonymous (the 401 baseline control)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "showcase_mark_done on showcase_task — the declared action the happy path flips (restore enabled afterwards; api-backend.packaged-action-disabled-dispatch owns what dispatch does with the row)", + "an engine carrying the activation projection (any stock v17 boot — ObjectQLPlugin attaches the sys_metadata_activation store at start)" + ], + "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." + ] + }, + "steps": [ + "boot showcase isolated (os dev — the LIVE mount matters here, see the trap note); sign in as admin and as the non-admin member", + "authority-first probe, as the NON-ADMIN: POST /api/v1/actions/_activation/showcase_task/no_such_action with body {\"enable\": false} — a request that is simultaneously mis-bodied AND undeclared; capture. Repeat the same request anonymous; capture", + "shape arms, as admin: POST /api/v1/actions/_activation/showcase_mark_done {} (2-segment); POST /api/v1/actions/_activation/showcase_task/showcase_mark_done/extra {} (4-segment); capture both", + "body arms, as admin, against the declared action: {\"enable\": false} (one letter off); {\"enabled\": \"false\"} (string, not boolean); [] (non-object body); {} (empty); capture each with full envelope", + "declaration arm: POST /actions/_activation/showcase_task/no_such_action {\"enabled\": false} as admin; capture; then GET /api/v1/data/sys_metadata_activation and confirm NO row was written by any refused probe so far", + "happy path: POST /actions/_activation/showcase_task/showcase_mark_done {\"enabled\": false} → capture the 200 body; read the ledger row (metadata_type 'action', name, organization_id NULL, active false); POST again {\"enabled\": true} → re-read: the SAME row updated in place, active true — never deleted, never duplicated", + "record the ambiguity / 501 / 503 arms against their unit pins (knownGaps above) — run the pins and cite their output rather than skipping the clauses silently" + ], + "acceptance": [ + { + "clause": "AUTHORITY RUNS FIRST and a refused caller learns nothing: the non-admin's mis-bodied undeclared-action probe answers 403 PERMISSION_DENIED with the manage_metadata sentence ('switching a shipped artifact off is functionally equivalent to deleting it', #10243) — NOT the 400 the body deserves and NOT the 404 the lookup would give, so neither the body contract nor the action namespace is disclosed. The refusal recommends NO clone (action-clone is deliberately unchartered — ACTION_ACTIVATION_SUBJECT's remedy names the operator and authoring a sibling action)", + "oracle": "api", + "verify": "step-2 non-admin trace: 403 + code PERMISSION_DENIED + the capability-naming message with no clone recommendation; the anonymous twin answers the 401 UNAUTHENTICATED baseline (the ADR-0056 floor sits ahead of even this)", + "evidence": "both traces" + }, + { + "clause": "the shape arm names the shape: the 2-segment path answers this door's own 400 'Path must be /actions/_activation/:object/:action (use `global` for an object-less action)' — while the 4-segment path 404s at the ROUTER, because no /actions mount pattern matches four segments (the door never sees it; route-ledger.ts records the reconstruction)", + "oracle": "api", + "verify": "step-3: 2-segment → 400 with the shape sentence; 4-segment → the routing-404 shape (compare against a known unmounted path, per api-backend.route-ledger-live-parity's control discipline)", + "evidence": "both traces + the routing-404 control" + }, + { + "clause": "the body contract refuses rather than inverts (#3899 — the flow toggle's one-letter-off lesson): {\"enable\": false} → 400 VALIDATION_FAILED with fields[0] {field:'enable', code:'unknown_field'} and a message suggesting `enabled`; {\"enabled\":\"false\"} → 400 with code 'invalid_type' (a JSON boolean, not a string); a non-object body → 400 invalid_type on '(body)'; and the envelope is the shared validationFailure shape every other door emits (fields[], per api-backend.error-envelope-ledger), never a hand-rolled lookalike", + "oracle": "api", + "verify": "step-4 traces: each 400 carries the named field-level code; crucially the {\"enable\": false} probe must NOT answer 200 — a 200 there ENABLED the artifact the caller meant to switch off", + "evidence": "the four envelopes" + }, + { + "clause": "an EMPTY body ENABLES — {} answers 200 with enabled true (the documented legacy shape: `enabled` defaults true, same as the flow toggle). A runner must not read the empty-body 200 as a body-contract failure", + "oracle": "api", + "verify": "the {} trace: 200, response enabled: true", + "evidence": "the trace" + }, + { + "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)", + "evidence": "the 404 trace + the ledger read" + }, + { + "clause": "the happy path writes exactly ONE install-level row and re-enable UPDATES it: 200 {name, objectName, enabled}; the row carries metadata_type 'action', the declarative name, organization_id NULL (the per-org dimension is reserved and unwritten); flipping back answers 200 and the same row flips active — never a second row, never a delete (§6 wall 3: the ledger records the choice)", + "oracle": "api", + "verify": "step-6: the 200 body + before/after ledger reads (one row throughout, active false then true)", + "evidence": "the traces + both row reads" + }, + { + "clause": "the ambiguity arm refuses instead of switching off artifacts the caller did not name: a name declared on 2+ objects answers 409 RESOURCE_CONFLICT (standard catalog — no new code minted) naming every owner object, and writes nothing", + "oracle": "test", + "verify": "no stock fixture (knownGaps) — run packages/runtime/src/domains/action-activation-posture-gate.test.ts and cite the ambiguity case (409 RESOURCE_CONFLICT, both owners named, setActionActive never called); with an authored colliding scratch package, the live probe supersedes the pin", + "evidence": "the pin's output (or the live trace when the scratch fixture exists), with which oracle the verdict rests on recorded" + }, + { + "clause": "the door is HONEST about what it cannot make durable: an engine without the projection answers 501 naming ADR-0126 §8, and a write the attached store refuses answers the store's own 503 SERVICE_UNAVAILABLE — never a 200 reporting a switch that does not exist (§6 wall 3)", + "oracle": "test", + "verify": "unreachable on a stock boot (knownGaps) — cite the two unit cases in action-activation-posture-gate.test.ts (501 + 503), and packaged-activation-ledger-reach.dogfood.test.ts for the composition (#12359) where the 503 once fired live and must stay closed", + "evidence": "the pins' output" + } + ], + "negative": [ + "{\"enable\": false} answering 200 is the catastrophic inversion — the caller switching something OFF silently switched it ON (the exact #3899 shape this body contract exists to close)", + "a non-manage_metadata caller receiving the body 400 or the declaration 404 is an information-ordering FAIL — the authority gates must answer first, and their message says nothing about the caller's own positions or sets (#7450)", + "any refused arm leaving a sys_metadata_activation row behind is a write-first-refuse-second FAIL (#10243 with an audit trail)", + "`_activation` read as an invocation — it is structurally impossible (machine names are ^[a-z][a-z0-9_]*$ and cannot start with '_'), so an invocation-shaped answer from this path means the reserved segment collapsed into the name space" + ], + "automated": { + "kind": "unit", + "ref": "packages/runtime/src/domains/action-activation-posture-gate.test.ts (the door's own contract: both authority tiers across all three postures, unknown-key/non-boolean 400s, empty-body-enables, undeclared 404 + no row, ambiguity 409 + no write, 501 no-projection, 503 non-durable) + packages/qa/dogfood/test/action-params-contract.dogfood.test.ts ('[#12160] the reserved `_activation` segment survives the real mount' — the 2-segment 400 and undeclared 404 measured through the live dispatcher reconstruction) + packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts (the live happy path: 200 flip, one org-NULL row, re-enable updates). The live-server body/authority arm sweep as a whole stays manual" + }, + "traps": [ + "dispatcher-vs-hono-route", + "wrong-persona", + "auth-state-leak" + ], + "source": [ + "packages/runtime/src/domains/actions.ts (ACTION_ACTIVATION_SEGMENT, handleActionActivationWrite — the five ordered steps: authority → shape → body → declaration/ambiguity → durable write; refuseAmbiguousActionActivation)", + "packages/runtime/src/domains/activation-gate.ts (refuseUngrantedActivationAuthoring — manage_metadata tier, #10243 sentence; refuseUngrantedActivationWrite — the §5 posture tier; ACTION_ACTIVATION_SUBJECT's no-clone remedy)", + "packages/runtime/src/route-ledger.ts (the #12160 server-only row: servedBy reconstruction through /actions/:object/:action/:recordId, the 4-segment router-404, and why the segment is reserved)", + "packages/objectql/src/engine.ts (setActionActive — durable row first, projection second; the 503-on-no-ledger contract)", + "sibling item api-backend.packaged-action-disabled-dispatch (what DISPATCH does with the row this door writes) · access-security.activation-write-operator-gate owns the gate's posture depths", + "docs/adr/0126-packaged-metadata-customization-model.md (§4, §5, §6 walls, §8 item 2)", + "#12438", + "Epic #12150" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-26", + "change": "new — the ADR-0126 §8 activation door is the only non-invocation shape /actions serves, is absent from every REST-ledger sweep (server-only, servedBy reconstruction), and none of its arms were in the ledger. Register corrections folded in during source verification: the 400-shape message carries a '(use `global` for an object-less action)' tail; a 4-segment path 404s at the ROUTER rather than reaching the door's 400 (no mount pattern matches); the authority gates run ahead of even the path-shape check, not just body/lookup; an empty body ENABLES by contract (a trap for a runner reading it as a refusal); and the ambiguity arm's no-stock-fixture status was verified across both showcase action files rather than assumed", + "ref": "#12438" + } + ] } ] } \ No newline at end of file diff --git a/docs/qa/platform-checklist/areas/automation.json b/docs/qa/platform-checklist/areas/automation.json index 54f6721313..b8508495f4 100644 --- a/docs/qa/platform-checklist/areas/automation.json +++ b/docs/qa/platform-checklist/areas/automation.json @@ -1081,7 +1081,7 @@ "title": "Toggling a record-change flow OFF is a runtime kill switch — the mutation that fired it produces no new run; ON restores firing", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P2", "surface": "api", "personas": [ @@ -1141,8 +1141,10 @@ ], "source": [ "packages/runtime/src/route-ledger.ts (POST /automation/:name/toggle → automation.toggle; GET /automation/_status → automation.getRuntimeStatus)", - "packages/services/service-automation/src/engine.ts (toggleFlow → flowEnabled + activateFlowTrigger/deactivateFlowTrigger; getFlowRuntimeStates enabled/bound)", - "examples/app-showcase/src/automation/flows/index.ts (UrgentTaskAlertFlow — the record_change flow, #3427)" + "packages/services/service-automation/src/engine.ts:2978-3043 (toggleFlow — writes the sys_metadata_activation row FIRST, then updates the flowLedgerDisabled projection and activateFlowTrigger/deactivateFlowTrigger; the process-local flowEnabled map this item originally cited is RETIRED by ADR-0126 §7.2) + :1344-1382 (flowLedgerDisabled is a projection of the ledger, not the old map under a new name) + getFlowRuntimeStates enabled/bound", + "docs/adr/0126-packaged-metadata-customization-model.md §7.2 (the durable ledger row replaces the process-local flowEnabled map as the sanctioned off-switch)", + "examples/app-showcase/src/automation/flows/index.ts (UrgentTaskAlertFlow — the record_change flow, #3427)", + "automation.packaged-flow-disable-durable owns the halves this item deliberately does not: the durable row's shape, restart survival, and the ledger-message oracle; automation.packaged-flow-subflow-disable-refusal owns the §7.3 disable guard (showcase_urgent_task_alert has no packaged subflow callers, so this item's toggles are never refused by it)" ], "history": [ { @@ -1150,6 +1152,12 @@ "date": "2026-08-08", "change": "initial — pins the /toggle runtime kill switch on a record_change flow: OFF unbinds the trigger so the firing mutation produces no run, ON restores it; runs list + runtime state as oracles", "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-26", + "change": "re-sourced, semantics untouched: the source cited 'toggleFlow → flowEnabled', and that process-local map is retired by ADR-0126 §7.2 (the #10243 cross-tenant leak's mechanism) — toggleFlow now writes the sys_metadata_activation row first and the engine projects the ledger via flowLedgerDisabled (engine.ts:1344-1382, :2978-3043). Every clause here still holds and still means the same thing (single-boot kill switch: OFF unbinds, /_status mirrors, ON restores); the new durable/ledger-row/restart halves are NOT folded in — they are automation.packaged-flow-disable-durable, and the §7.3 subflow-disable guard is automation.packaged-flow-subflow-disable-refusal, both cross-referenced from source", + "ref": "#12438" } ] }, @@ -1274,6 +1282,412 @@ "ref": "#10236" } ] + }, + { + "id": "automation.packaged-flow-disable-durable", + "title": "Disabling a packaged flow writes a durable sys_metadata_activation row that survives a cold restart with the trigger left unbound; re-enable UPDATES the row and restores firing", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P0", + "surface": "api", + "personas": [ + "seeded admin (admin@objectos.ai / admin123 — holds manage_metadata, which is the whole authority the toggle door demands on the stock `single` posture: the ADR-0126 §5 activation gate is INERT there, see access-security's activation-gate item)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a FILE-backed database — restart survival is structurally unreachable on the in-memory store; record the db path in the run env (the same requirement automation.durable-suspend-restart carries, and its cold-restart recipe — stop the process entirely, boot a second server over the SAME database file — is the one to reuse here)", + "showcase_urgent_task_alert (examples/app-showcase/src/automation/flows/index.ts UrgentTaskAlertFlow) — a PACKAGED record_change flow (package com.example.showcase, examples/app-showcase/objectstack.config.ts:80) with NO packaged subflow callers, so its disable is never refused by the §7.3 guard this item does not test", + "⚠️ export OS_PORT= as well as passing -p (RUNNER.md's connector self-URL standing fact) — the showcase's self-pinging connectors otherwise fail every unrelated flow run in the boot" + ] + }, + "steps": [ + "boot showcase isolated against a file DB (dogfood §0); sign in as the dev admin", + "baseline: POST /api/v1/data/showcase_task with priority='urgent' — one new run for showcase_urgent_task_alert (the single-boot toggle semantics are automation.flow-toggle-kill-switch's; this item takes only enough baseline to anchor the restart contrast)", + "toggle OFF: POST /api/v1/automation/showcase_urgent_task_alert/toggle {\"enabled\": false}", + "read the ledger row over the data API: GET /api/v1/data/sys_metadata_activation (reads are open — apiMethods ['get','list'], sys-metadata-activation.object.ts:157) and locate the row where metadata_type='flow' AND name='showcase_urgent_task_alert' — record active, package_id, organization_id, and the row id", + "probe the refusal MESSAGE: POST /api/v1/automation/showcase_urgent_task_alert/trigger — record the full error body (status, code, message)", + "cold restart: stop the server process entirely; boot a second server over the SAME database file; read the boot log for the automation plugin's activation-ledger line", + "post-restart: GET /api/v1/automation/_status — record enabled/bound for showcase_urgent_task_alert", + "post-restart kill-switch: POST another priority='urgent' showcase_task; GET /api/v1/automation/showcase_urgent_task_alert/runs — assert NO new run row", + "re-enable: POST /api/v1/automation/showcase_urgent_task_alert/toggle {\"enabled\": true}; re-read the sys_metadata_activation row by the id recorded in step 4", + "POST a final priority='urgent' showcase_task; GET the runs list — a new run row appears again" + ], + "acceptance": [ + { + "clause": "the toggle writes the durable row, not (only) process state: after {enabled:false} a sys_metadata_activation row exists with metadata_type='flow', name='showcase_urgent_task_alert', package_id='com.example.showcase', active=false, and organization_id NULL (install-level — §5 reserves the column, nothing on this line writes it)", + "oracle": "api", + "verify": "the step-4 data-API read returns exactly one such row (unique per (metadata_type, name) via the NULL-collapsed 'organization' index). toggleFlow writes this row FIRST and only then updates the in-process projection (engine.ts:3010-3032, core/src/utils/metadata-activation-store.ts:221-250) — so a 2xx toggle with no row is a FAIL even if the same-boot kill switch works", + "evidence": "the row read (all five columns) + the toggle response" + }, + { + "clause": "the FLOW_DISABLED refusal message names the LEDGER: triggering the disabled flow answers 409 FLOW_DISABLED whose message carries the sys_metadata_activation / activation-ledger phrasing and the two remedies (re-enable, or clone under a new name)", + "oracle": "api", + "verify": "the step-5 error message contains 'packaged-metadata activation ledger (sys_metadata_activation' (engine.ts describeDisabledFlow, :2888-2904). ⛔ The CODE alone is NOT the oracle: ADR-0126 §7.2 deliberately REUSES FLOW_DISABLED for the ledger dimension (no new ADR-0112 entry), and a status-disabled flow answers the same 409 FLOW_DISABLED with the old bare \"Flow '' is disabled\" — the ledger-vs-status distinction rides the MESSAGE, so a verdict keyed on code alone cannot tell this item's dimension from the authoring one", + "evidence": "the full 409 body with the message quoted" + }, + { + "clause": "the disable survives a literal cold restart with the trigger left UNBOUND: on the new process /_status reports enabled=false AND bound=false for the flow, and the boot log carries the activation-ledger line naming it", + "oracle": "api", + "verify": "post-restart GET /api/v1/automation/_status shows enabled:false, bound:false — the boot flow pull re-registers and re-arms every flow, and registerFlow deliberately does NOT re-arm a ledger-disabled one (engine.ts:2706-2714); hydrateFlowActivations then runs AFTER the pull (service-automation/src/plugin.ts:986-1005) and disarms from the ledger (engine.ts:2851-2871). Corroborate with the log line '[Automation] Activation ledger: 1 packaged flow(s) are switched off…' naming showcase_urgent_task_alert", + "evidence": "the post-restart /_status read + the boot-log excerpt" + }, + { + "clause": "the kill switch holds on the new process: a priority='urgent' create after the restart produces NO new run row", + "oracle": "api", + "verify": "runs-list count identical before and after the post-restart urgent create — assert the ABSENCE of a new run, exactly as the single-boot item does, but on a process that never saw the toggle request", + "evidence": "before/after runs reads bracketing the post-restart create" + }, + { + "clause": "re-enable UPDATES the row and never deletes it: after {enabled:true} the SAME row (same id) reads active=true and is still present, and the next urgent create fires the flow again", + "oracle": "api", + "verify": "the step-9 re-read returns the step-4 row id with active=true — setActive is read-then-update and the store's engine slice deliberately has no delete (core/src/utils/metadata-activation-store.ts:221-250, :126-133): the ledger records the administrator's CHOICE (§6 wall 3), it does not erase it. Then one new run for the final urgent create", + "evidence": "the re-read row (id + active) + the runs list after the re-enable" + } + ], + "negative": [ + "a toggle answering 2xx with NO sys_metadata_activation row written is a FAIL on this boot — the engine has a legitimate ledger-less degraded mode, but it WARNS 'IN PROCESS ONLY … will NOT survive a restart' (engine.ts:3019-3029) and the showcase composition attaches the ledger, so silence plus no row means the durable write was skipped where it was promised", + "a restart that re-arms the flow (enabled or bound true, or a run row from the post-restart create) is a FAIL against the whole point of ADR-0126 §7.2 — it is the retired flowEnabled map's behaviour reappearing", + "a re-enable that DELETES the row is a FAIL of the row contract even though firing resumes — absence-means-active makes deletion look equivalent right up until something needs the recorded choice", + "a run that scored the ledger clause from the FLOW_DISABLED code alone (without the message) has verified nothing this item asserts — the code is shared with the status dimension by design" + ], + "traps": [ + "first-boot-cold-start", + "seed-data-thin" + ], + "source": [ + "docs/adr/0126-packaged-metadata-customization-model.md §4 (the generic ledger + row shape), §7.2 (the durable row replaces the process-local flowEnabled map; FLOW_DISABLED code reused, distinction rides the message)", + "packages/services/service-automation/src/engine.ts:2978-3043 (toggleFlow — durable row FIRST), :2888-2904 (describeDisabledFlow — the ledger message), :2851-2871 (hydrateFlowActivations), :2706-2714 (registerFlow does not re-arm a ledger-disabled flow — restart survival's other half), :1344-1382 (flowLedgerDisabled projection)", + "packages/services/service-automation/src/plugin.ts:986-1005 (hydrate AFTER the boot flow pull — the ordering that makes the unbind stick)", + "packages/core/src/utils/metadata-activation-store.ts:185-207 (org-carrying rows skipped on read), :221-250 (setActive read-then-update; organization_id never written), :126-133 (no delete on the engine slice)", + "packages/platform-objects/src/system/sys-metadata-activation.object.ts:152-158 (apiMethods ['get','list'] — reads open, generic-data-API writes refused)", + "packages/services/service-automation/src/flow-activation-ledger.test.ts (engine-level pins: hydration unbinds what a PREVIOUS process disabled, ledger-disabled survives re-registration — the HTTP+real-restart path here is what those pins cannot cover)", + "examples/app-showcase/src/automation/flows/index.ts (UrgentTaskAlertFlow) + examples/app-showcase/objectstack.config.ts:80 (com.example.showcase)", + "#12438 (sweep) · Epic #12150", + "cross-refs: automation.flow-toggle-kill-switch (the single-boot kill-switch half — not repeated here), automation.durable-suspend-restart (the cold-restart recipe + file-DB discipline)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-26", + "change": "new — ADR-0126 §7.2 turned the flow toggle from a process-local map into a durable sys_metadata_activation row, and no item asserted the durable half: the row's shape and install-level scope, the restart that must leave the trigger unbound, the ledger-naming FLOW_DISABLED message (code deliberately reused, so the message is the only observable distinction), and the update-never-delete re-enable. P0 because a regression here re-arms flows administrators switched off, silently, at the next deploy", + "ref": "#12438" + } + ] + }, + { + "id": "automation.packaged-flow-subflow-disable-refusal", + "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; enable is never guarded", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "seeded admin (admin@objectos.ai / admin123 — holds manage_metadata)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the three stock packaged caller→child pairs (examples/app-showcase/src/automation/flows/index.ts): showcase_task_done_notify_owner → showcase_notify_owner via a subflow node (:678, :707) · showcase_project_closure → showcase_closure_signoff via subflow (:795, :816) · showcase_release_signoff → showcase_one_task_signoff via a MAP node (:1322, :1339) — the map pair is load-bearing, because a scan that read only `subflow` nodes would pass the first two and miss it" + ], + "knownGaps": [ + "two engine-pinned arms have no stock HTTP specimen and are scored from the unit pins, not driven: a NON-packaged caller must not guard (a tenant's own flow cannot hold a packaged one hostage) and a flow calling ITSELF must not guard its own disable — both pinned engine-level in packages/services/service-automation/src/flow-activation-ledger.test.ts (:372, :395); driving them over HTTP needs a scratch-package tenant caller nothing stock provides", + "there is NO HTTP-level pin of this refusal anywhere (the pins above are engine unit tests against a fake store) — which is exactly why the wire shape (does the thrown {code:'DELETE_RESTRICTED', status:409} survive the dispatcher's error mapping intact?) is this item's job and not a formality" + ] + }, + "steps": [ + "boot showcase isolated (dogfood §0, OS_PORT exported); sign in as the dev admin", + "subflow pair: POST /api/v1/automation/showcase_notify_owner/toggle {\"enabled\": false} — record the full HTTP answer (status, code, message)", + "map pair: POST /api/v1/automation/showcase_one_task_signoff/toggle {\"enabled\": false} — record the answer", + "after each refusal: GET /api/v1/data/sys_metadata_activation and assert NO row exists for the refused flow; then prove it is still ARMED — trigger a run through its caller (PATCH a showcase_task to status='done' for the notify pair) and confirm the child's subflow step executed", + "remedy sequence (the refusal's own instruction): POST /api/v1/automation/showcase_task_done_notify_owner/toggle {\"enabled\": false} (the CALLER — it has no packaged callers of its own, so this lands), then RETRY POST /api/v1/automation/showcase_notify_owner/toggle {\"enabled\": false} and record what it answers", + "enable arm: toggle showcase_task_done_notify_owner back ON, then — with the caller armed — POST /api/v1/automation/showcase_notify_owner/toggle {\"enabled\": true} (a no-op enable of an already-armed flow) and confirm it is never refused", + "teardown: re-enable anything left disabled (or discard the isolated DB)" + ], + "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')", + "oracle": "api", + "verify": "step 2's answer: HTTP 409, error.code DELETE_RESTRICTED (packages/spec/src/api/errors.zod.ts:96; the engine throws {code:'DELETE_RESTRICTED', status:409, subflowCallers:[…]} — engine.ts:2986-3007), 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" + }, + { + "clause": "MAP callers count as subflow callers: disabling showcase_one_task_signoff is refused naming showcase_release_signoff, whose invocation is a map node's per-item target, not a subflow node", + "oracle": "api", + "verify": "step 3's answer names 'showcase_release_signoff'. The scan reads both node types through config.flowName (engine.ts:2932-2947) because a map's own descriptor calls its target 'the per-item subflow' — a pass on the subflow pairs with a silent 2xx here means the scan regressed to `subflow` only", + "evidence": "the 409 body for the map pair" + }, + { + "clause": "a refused attempt is a full no-op: NO sys_metadata_activation row is written for the refused flow, and the flow is still armed (a caller-driven run executes its subflow step)", + "oracle": "api", + "verify": "the guard throws BEFORE the durable write (engine.ts:2984-3008 precedes the setActive at :3013) — so the data-API read shows no row for the refused name, and the step-4 caller run's step log shows the child executed. A row with active=false after a 409 means the refusal happened after the write, which is a half-disable nothing reports", + "evidence": "the ledger read (absence) + the caller run's step-log excerpt" + }, + { + "clause": "ENABLE is never guarded — arming a flow cannot break a caller, so the enable arm answers 2xx regardless of callers", + "oracle": "api", + "verify": "step 6's enable answers 2xx with no DELETE_RESTRICTED (guard is on the !enabled branch only, engine.ts:2984-2986; pinned engine-level at flow-activation-ledger.test.ts:384)", + "evidence": "the enable response" + }, + { + "clause": "the refusal's own remedy is followable: after disabling the calling flow first, the child's disable lands (2xx + ledger row active=false)", + "oracle": "api", + "verify": "step 5's retry. ⚠️ EXPECTED FAIL at af56546, kept as the assertion on purpose: packagedSubflowCallers scans the REGISTERED flow map with no activation check (engine.ts:2932-2947 — it skips self and non-packaged callers, nothing else), so a disabled caller still guards and the retry still answers 409 — while both the engine's own refusal message ('Disable the calling flow(s) first…') and ADR-0126 §7.3's rationale ('The refusal is honest, actionable (disable the callers first, or don't)') name exactly this sequence as the way out. A red here is a product finding about the remedy sentence — the door tells the administrator to do something it then refuses — already tracked by the #12438 sweep; record it, do NOT soften this clause to match the scan", + "evidence": "the caller's successful disable + the retry's full answer, side by side with the first refusal's remedy sentence" + } + ], + "negative": [ + "a 2xx on step 2 or 3 (the disable landing despite packaged callers) is a FAIL of §7.3 itself — the vendor caller would then break mid-run at its subflow node with the late 'subflow … failed: Flow … is disabled' this guard exists to prevent", + "a refusal naming only the FIRST caller when several exist is a FAIL — the message must name every packaged caller (pinned engine-level, flow-activation-ledger.test.ts:335); with stock fixtures each child has one caller, so this arm rides the pin unless a scratch second caller is authored", + "the UI half — the caller names rendered verbatim in a role=alert on the Setup packaged-automation page — is deliberately NOT scored here; it is automation.setup-packaged-automation-board's refusal clause" + ], + "traps": [ + "dispatcher-vs-hono-route", + "seed-data-thin" + ], + "source": [ + "docs/adr/0126-packaged-metadata-customization-model.md §7.3 (the subflow cascade: refused, callers named, attached to disable only)", + "packages/services/service-automation/src/engine.ts:2906-2947 (packagedSubflowCallers — definition scan at disable time, subflow AND map via config.flowName, non-packaged and self skipped), :2984-3008 (the guard, before the durable write; the thrown {code:'DELETE_RESTRICTED', status:409, subflowCallers})", + "packages/spec/src/api/errors.zod.ts:96 (DELETE_RESTRICTED — standard 409 catalog member, no new code minted; its DELETE_ prefix fits per the #10243 ruling quoted at the throw site)", + "packages/services/service-automation/src/flow-activation-ledger.test.ts:310-399 (the engine-level pins: names every caller, map counts, non-packaged and self do not guard, enable never guarded, no-callers lands)", + "examples/app-showcase/src/automation/flows/index.ts:678,707,795,816,1322,1339 (the three stock caller→child pairs)", + "#12438 (sweep) · Epic #12150", + "cross-ref: automation.packaged-flow-disable-durable (what a disable that LANDS must do); automation.setup-packaged-automation-board (the refusal rendered in the UI)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-26", + "change": "new — the ADR-0126 §7.3 disable guard had only engine-level unit pins and no HTTP-level assertion anywhere: the wire 409 DELETE_RESTRICTED with callers named, the map-caller arm, the no-row-on-refusal invariant, and the never-guarded enable. The remedy-sequence clause (disable the caller first) is authored as an expected fail: the shipped scan consults registration only (engine.ts:2932-2947), so the refusal's own named remedy does not currently unblock the child — the clause keeps the promise the door itself makes and flags the red as a tracked product finding", + "ref": "#12438" + } + ] + }, + { + "id": "automation.packaged-flow-clone-contract", + "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": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "seeded admin (admin@objectos.ai / admin123 — holds manage_metadata; the clone door is in the #10145 authoring write set, domains/automation.ts:428-445)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a FILE-backed database (the restart clause is unreachable without one — same discipline as automation.durable-suspend-restart)", + "showcase_urgent_task_alert (packaged record_change flow — the double-fire specimen: its trigger is an urgent showcase_task create, cheap to fire twice) and showcase_task_done_notify_owner (packaged flow carrying a subflow node → showcase_notify_owner — the references-not-re-pointed specimen)" + ] + }, + "steps": [ + "boot showcase isolated against a file DB (dogfood §0, OS_PORT exported); sign in as the dev admin", + "refusal arms first (no state left behind): POST /api/v1/automation/showcase_urgent_task_alert/clone with {} · with {\"name\":\"x\"} (label missing) · with {\"label\":\"x\"} (name missing) · with {\"name\":\"showcase_urgent_task_alert\",\"label\":\"Same\"} (same-name) · with {\"name\":\"Bad-Name!\",\"label\":\"Illegal\"} (violates ^[a-z_][a-z0-9_]*$) · and POST /api/v1/automation/does_not_exist/clone {\"name\":\"qa_x\",\"label\":\"X\"} (unknown source) — record every status + code + message", + "the copy: POST /api/v1/automation/showcase_urgent_task_alert/clone {\"name\":\"qa_urgent_alert_clone\",\"label\":\"QA urgent alert clone\"} — record the FULL response body (flow + notice)", + "read both definitions back (GET /api/v1/automation/qa_urgent_alert_clone and …/showcase_urgent_task_alert) and diff them key by key", + "double-fire: POST /api/v1/data/showcase_task with priority='urgent'; GET both flows' runs lists", + "references: POST /api/v1/automation/showcase_task_done_notify_owner/clone {\"name\":\"qa_task_done_clone\",\"label\":\"QA task-done clone\"}; read the clone's subflow node config", + "ordinariness: PUT /api/v1/automation/qa_urgent_alert_clone with the clone's own definition (label tweaked) — it is a tenant flow, the edit lands", + "restart probe: stop the server; cold-boot over the SAME database file; GET /api/v1/automation/qa_urgent_alert_clone and GET /api/v1/meta/flow (does any package/tenant metadata hold the clone?)", + "teardown: DELETE /api/v1/automation/qa_urgent_alert_clone and …/qa_task_done_clone (or discard the isolated DB)" + ], + "acceptance": [ + { + "clause": "name AND label are both mandatory: an empty body, a missing name, and a missing label each answer 400 with a per-field detail (code 'required'), and nothing reaches the service", + "oracle": "api", + "verify": "the three refusals from step 2: 400, details naming the absent field (domains/automation.ts:1360-1388 — mandatory per ADR-0126 §7.1, the #11513 shape; an unknown body key answers 400 unknown_field). Route-level pins exist in packages/runtime/src/domains/automation-flow-clone.test.ts — this run proves the same arms on the live dispatcher", + "evidence": "the three 400 bodies with their details arrays" + }, + { + "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", + "oracle": "api", + "verify": "the step-4 diff: differences are exactly {name, label, status:'draft'} (FLOW_CLONE_MUTATED_FIELDS, flow-clone.ts:115) plus the ABSENCE of the dropped keys (flow-clone.ts:132-135 — 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", + "evidence": "the key-by-key diff + the dropped-keys absence check" + }, + { + "clause": "status 'draft' is NOT an off-switch and the response says so verbatim: one urgent create fires BOTH base and clone (double-fire), and the clone response's notice equals FLOW_CLONE_NOTICE", + "oracle": "api", + "verify": "step 5: one new run in EACH flow's runs list for the same create — the engine disables on status obsolete/invalid only, draft binds its trigger (flow-clone.ts:90-104). The response notice matches flow-clone.ts:151-157 verbatim, including the toggle instruction — the sentence is the only thing standing between the admin and the two-flows-one-trigger surprise, so a paraphrase is a FAIL", + "evidence": "both runs lists bracketing the create + the notice text from the clone response" + }, + { + "clause": "the refusal arms answer their own status+code: same/taken name → 409 RESOURCE_CONFLICT with the shadow-rationale message suggesting a free name; unknown source → 404 RESOURCE_NOT_FOUND; an illegal machine name → 400 (the definition goes through registerFlow's own validation, one policy not two)", + "oracle": "api", + "verify": "step 2's remaining arms: the 409 message explains WHY same-name clones are refused (engine keys flows by bare name — silent order-dependent shadowing, flowCloneNameTakenMessage flow-clone.ts:180-188) and suggests '_copy'; 404 for the unknown source; 400 for 'Bad-Name!' (FlowSchema name regex ^[a-z_][a-z0-9_]*$, flow.zod.ts:616). Codes pinned at automation-flow-clone.test.ts:368-401,439", + "evidence": "the three bodies (status + code + message)" + }, + { + "clause": "references are NOT re-pointed: the clone of showcase_task_done_notify_owner still calls showcase_notify_owner at its subflow node — the notice's first sentence, held", + "oracle": "api", + "verify": "the step-6 clone's subflow node carries config.flowName 'showcase_notify_owner', not a qa_-renamed anything (ADR-0126 §9: automatic re-pointing explicitly not chartered, no reference index exists). A clone whose references were silently rewritten is a FAIL in the OTHER direction — it would contradict the notice the same response carries", + "evidence": "the clone's subflow node config read" + }, + { + "clause": "the clone is an ordinary tenant flow at the engine: PUT /api/v1/automation/qa_urgent_alert_clone lands (2xx, edit visible on re-read)", + "oracle": "api", + "verify": "step 7. ⚠️ Scope note: this proves the clone is editable, NOT that the packaged base is protected at this door — PUT /automation/:name currently performs no lock/provenance check on ANY flow (domains/automation.ts:1826-1857), and that door-parity question is access-security.packaged-flow-write-door-parity's expected-fail probe, not this clause", + "evidence": "the PUT response + the re-read" + }, + { + "clause": "the honest clause — the clone OUTLIVES the process and REACHES Studio: after a cold restart over the same database file the clone still exists and dispatches, and it is listed on a Studio Automations rail (it is 'an ordinary org/install-owned flow', ADR-0126 §7.1, under the shipped 'customize in Studio' promise §1.3 — the Setup page's own copy says 'Editing happens in Studio')", + "oracle": "api", + "verify": "step 8: GET /api/v1/automation/qa_urgent_alert_clone on the new process, and the flow present in metadata a Studio rail can list. ⚠️ EXPECTED FAIL at af56546, kept as the assertion on purpose: the clone route registers through automationService.registerFlow ONLY — no sys_metadata write anywhere in the arm (domains/automation.ts:1414-1436) — so the boot flow pull has nothing to re-register and the clone vanishes on restart; and the Studio Automations rail lists PACKAGE-scoped metadata (objectui StudioDesignSurface.tsx:3286 loadPackageSurfaces(client,'flow',packageId)) while the clone's _packageId was deliberately stripped (flow-clone.ts:225), so it is reachable in no rail. A red here is a product finding already tracked by the #12438 sweep (clone registered engine-only); record which half failed (durability, reachability, or both) — do NOT soften the clause to a same-process read", + "evidence": "the post-restart GET (present or 404) + the meta/flow listing showing where, if anywhere, the clone is held" + } + ], + "negative": [ + "a clone missing any source facet the diff can name (a node, an edge, a variable, the retry block) is the #11703 enumerated-param-list regression — fail the copy clause even if the clone runs", + "a clonedFrom / ancestry field appearing ANYWHERE — response, stored definition, or a later listing — is a FAIL of amendment ruling 2: a response field is the cheapest place for lineage to reappear and a UI that reads one starts displaying what the platform ruled it does not track", + "a same-name clone answering 2xx is the silent order-dependent shadow #11665 §2.2 measured — the worst outcome this door has, because storage legitimately holds both rows and only the engine's bare-name map breaks", + "an anonymous or non-manage_metadata caller reaching the clone door (anything but 401/403 PERMISSION_DENIED) is a FAIL — the door is in the #10145 authoring write set precisely because a clone REGISTERS flow metadata at environment scope" + ], + "traps": [ + "dispatcher-vs-hono-route", + "destructive-in-place" + ], + "source": [ + "docs/adr/0126-packaged-metadata-customization-model.md §7.1 (mandatory new name, whole-definition copy, no linkage), §9 (no re-pointing, no ancestry), §1.3 (the shipped 'customize in Studio' promise the honest clause holds the clone to)", + "packages/runtime/src/flow-clone.ts:105 (FLOW_CLONE_STATUS draft — and why it is deliberately not an off-switch), :115 (FLOW_CLONE_MUTATED_FIELDS), :132-135 (FLOW_CLONE_DROPPED_KEYS derived from spec), :151-157 (FLOW_CLONE_NOTICE), :160-188 (409 + name-taken message), :220-230 (deep copy via structuredClone; _packageId et al. stripped)", + "packages/runtime/src/domains/automation.ts:1340-1437 (the clone arm: body-first validation, mandatory name+label, 404/409 probes, registerFlow-only registration, no ancestry on the response), :428-445 (clone in the manage_metadata write set)", + "packages/runtime/src/domains/automation-flow-clone.test.ts (route-level pins for the validation and refusal arms — a mocked-service pin; it cannot see the live dispatcher, the double-fire, or the restart — which is exactly the remainder this item drives)", + "packages/spec/src/automation/flow.zod.ts:616 (machine-name regex)", + "objectui packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx:3286 (the Automations rail is package-scoped — the reachability half of the honest clause)", + "examples/app-showcase/src/automation/flows/index.ts (UrgentTaskAlertFlow; TaskDoneNotifyOwnerFlow → showcase_notify_owner)", + "#12438 (sweep) · Epic #12150", + "cross-ref: access-security.packaged-flow-write-door-parity (owns the packaged-base write-door question this item's ordinariness clause must not be read as answering); automation.setup-packaged-automation-board (the clone dialog UI half)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-26", + "change": "new — the ADR-0126 §7.1 clone door landed with route-level mocked pins only; nothing asserted the live contract (double-fire with the notice verbatim, references not re-pointed, the refusal arms on the real dispatcher) and nothing anywhere asked the two questions the honest clause pins: the clone registers engine-only (no sys_metadata write) and its _packageId is stripped, so restart survival and Studio reachability — both halves of the ADR's own 'ordinary flow' promise — are authored as an expected-fail probe rather than left unasked", + "ref": "#12438" + } + ] + }, + { + "id": "automation.setup-packaged-automation-board", + "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": 1, + "priority": "P1", + "surface": "browser", + "personas": [ + "seeded admin (admin@objectos.ai / admin123 — holds manage_metadata)", + "a plain member (fresh runtime sign-up — member_default, holds NO manage_metadata; the refusal-rendering and posture clauses)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "a console bundle matching the .objectui-sha pin (the page is objectui code — a stale bundle tests last week's page)", + "the packaged showcase flows (com.example.showcase) and at least one packaged action for the actions section", + "the stock subflow pair showcase_task_done_notify_owner → showcase_notify_owner (drives the 409 refusal-rendering clause)" + ], + "knownGaps": [ + "REPO OWNERSHIP (ADR-0054): the PAGE — markup, testids, dialogs, refusal rendering — is objectui's (packages/app-shell/src/views/setup/PackagedAutomationPage.tsx + PackagedActionsSection.tsx, registered as ref 'automation:packaged' in services/builtinComponents.tsx:64-69); the Setup NAV METADATA that should reach it is objectstack's (packages/platform-objects/src/apps/setup-nav.contributions.ts). A locator or copy fix goes to objectui; the missing nav entry goes HERE. Conflating the two repos is how the nav gap survived both repos' tests", + "AUTOMATION IS PINNED IN THE objectui REPO — `automated.ref` names objectui component tests exclusively, so from this checkout the item is neither runnable nor pin-evidenced; and those tests are the MOCKED half only (stubbed fetch): they prove the page against faked responses and structurally cannot see the missing framework nav entry, a live server's refusal bodies, or persistence. Full protocol: RUNNER.md, the objectui-pinned-automation standing fact" + ] + }, + "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 (typed URL /apps/setup/component/automation/packaged if the sidebar has no entry — recording that the typed URL was needed) and screenshot it settled", + "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", + "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", + "refusal rendering + posture (plain member): sign in as the member IN A FRESH BROWSER CONTEXT (own session — both localStorage token and cookie, or the cell is still the admin's), open the page, record what renders BEFORE any click, then flip any switch and screenshot the row", + "lineage sweep: with the clone from step 6 present, search the whole page (both sections) for any diff/badge/'customized'/lineage affordance" + ], + "acceptance": [ + { + "clause": "the page is REACHABLE FROM SETUP NAVIGATION: the served setup app metadata contains a type:'component' nav item with componentRef 'automation:packaged', and clicking the sidebar entry lands on the page", + "oracle": "network", + "verify": "the GET /api/v1/meta/app?id=setup body carries the item AND the sidebar click navigates to it. ⚠️ EXPECTED FAIL at af56546, kept as the assertion on purpose: the objectui page and registry ref exist, but objectstack's packages/platform-objects/src/apps/setup-nav.contributions.ts contributes NO such nav item (the only component ref it ships is developer:packages, :51) — the page is reachable only by typed URL, and Epic layers L5 (#6301) / L6-UI (#6412) are closed, so this is a defect, not pending work; a red here is that product finding, already tracked by the #12438 sweep. ⛔ A typed-URL landing must NOT tick this clause — objectui's own PackagedAutomationPage.navContribution.test.tsx proves only ref→URL resolution (the mocked half), which is exactly the half that cannot see the missing nav entry", + "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", + "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:59-106). 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" + }, + { + "clause": "the toggle flips state AND persists: after flipping OFF and a full reload the switch still reads Off and the sys_metadata_activation row reads active=false", + "oracle": "api", + "verify": "the reload re-derives the switch from the server (runtime states), not from component state; corroborate with the data-API row read — the DOM alone cannot distinguish a durable flip from an optimistic one", + "evidence": "post-reload screenshot + the row read" + }, + { + "clause": "clone is offered on FLOW rows ONLY — no clone affordance anywhere in the packaged-actions section", + "oracle": "dom", + "verify": "every flow row carries a Clone button; the actions section has none (its own header states why — ADR-0126 §8 keeps the action clone pre-chartered; PackagedActionsSection.tsx header ⛔ 'No clone'). A clone control appearing on actions is a FAIL against the charter, not a bonus", + "evidence": "screenshots of both sections" + }, + { + "clause": "NO lineage surface: no diff-vs-base, no 'customized' badge, no clonedFrom link, no drift indicator anywhere on the page — including after a clone exists", + "oracle": "dom", + "verify": "the step-9 sweep finds nothing lineage-shaped (ADR-0126 §9 / the ⛔ no-drift-surface rule quoted in the page header). Displayed ancestry would mean a data source the platform ruled it does not track — its appearance is a FAIL even if it looks helpful", + "evidence": "full-page screenshot with the clone present" + }, + { + "clause": "server refusals render VERBATIM in a role=alert beside the control that caused them: the plain member's toggle shows the exact manage_metadata sentence; the admin's subflow toggle shows the 409 naming showcase_task_done_notify_owner; the local fallback strings appear nowhere", + "oracle": "dom", + "verify": "the member's alert text equals 'Enabling or disabling an automation flow requires the `manage_metadata` capability.' (domains/automation.ts:333-335) and the admin's names the caller — actionErrorDetail reads error → error.message → message and falls back only when the body is unreadable (objectui core/src/actions/actionErrorDetail.ts:27-35; the alert at PackagedAutomationPage.tsx:427). The i18n fallbacks (packagedAutomation.toggleFailed / toggleFailedHttp) rendering instead of the server sentence means the body was dropped — the server's remedy ('disable the callers first' / the capability name) is the actionable half", + "evidence": "both alert screenshots with the text transcribed" + }, + { + "clause": "the clone dialog requires BOTH machine name and label before submit is possible, and a server-refused name surfaces legibly: the illegal-name 400 renders in the dialog's alert — record exactly what text arrives, because the per-field details[] are dropped client-side", + "oracle": "dom", + "verify": "the submit control is disabled until both fields are non-empty (cloneNameValid, PackagedAutomationPage.tsx:327,529 — so the 'required' 400s of the API item are unreachable from this dialog); submitting 'Bad-Name!' round-trips the server 400 and the dialog alert (role=alert, :516) shows the message actionErrorDetail extracted — details[] never reach the DOM (:27-35), so if the rendered sentence does not state the ^[a-z_][a-z0-9_]*$ constraint legibly, record what DID render as the finding rather than inventing a pass. A legal clone then shows the server's post-clone notice verbatim", + "evidence": "dialog screenshots: disabled submit, the 400 alert text, the post-clone notice" + }, + { + "clause": "posture record (not a pass/fail gate): a plain member REACHES the page and sees the switches — the component route has no capability gate and the ledger/meta reads are open — with the refusal arriving only after a click; record the posture as observed", + "oracle": "dom", + "verify": "ComponentNavView resolves the ref with no authz check (objectui ComponentNavView.tsx:26-60) and sys_metadata_activation reads are open by ruling (sys-metadata-activation.object.ts:152-158), so the member sees real install-wide state with dead controls. Whether that read-exposure is intended is a maintainer question tracked in the #12438 sweep's follow-ups — this clause RECORDS what a member sees (which lists load, what the first refusal moment is); it does not adjudicate", + "evidence": "the member's pre-click page screenshot + the list of requests the page made and their statuses" + } + ], + "negative": [ + "ticking the nav clause off a typed-URL landing (or off the objectui navContribution unit test) is the exact false positive this item exists to block — the page working and the page being findable are different facts, and only the second is red today", + "scoring the member's refusal cell while the admin's token is still live in the browser context is an auth-state-leak — the alert would show a 409 (subflow) instead of the 403 (capability), silently proving the wrong clause", + "a toggle whose switch flips back On after reload while the API row says active=false (or vice versa) is a FAIL wherever the mismatch lies — the page must derive from the server, and the server must have written the row" + ], + "traps": [ + "wrong-persona", + "auth-state-leak", + "hydration-race", + "stale-console-bundle" + ], + "automated": { + "kind": "test", + "ref": "objectui packages/app-shell/src/views/setup/PackagedAutomationPage.test.tsx + PackagedActionsSection.test.tsx — the MOCKED component half only (stubbed fetch): rendering, filter, dialog validation, alert wiring; they cannot evidence the nav clause, live refusal bodies, or persistence" + }, + "source": [ + "docs/adr/0126-packaged-metadata-customization-model.md §7.4 (the Setup surface: on/off + clone, authoring stays in Studio; ⛔ no drift/ancestry surface)", + "objectui packages/app-shell/src/views/setup/PackagedAutomationPage.tsx (:327,529 clone-dialog validation; :427,516 role=alert refusal rendering) + PackagedActionsSection.tsx (header: ⛔ no clone on actions) + packagedFlows.ts:59-106 (isPackagedFlowItem + runtime-spine join)", + "objectui packages/app-shell/src/services/builtinComponents.tsx:64-69 (registerAppComponent ref 'automation:packaged') + views/ComponentNavView.tsx:26-60 (ref resolution, no gate) + views/setup/PackagedAutomationPage.navContribution.test.tsx (ref→URL resolution — the mocked half of the nav clause)", + "objectui packages/core/src/actions/actionErrorDetail.ts:27-35 (error → error.message → message; details[] dropped)", + "packages/platform-objects/src/apps/setup-nav.contributions.ts (NO automation:packaged nav item at af56546 — the expected-fail's ground)", + "packages/runtime/src/domains/automation.ts:333-335 (the exact enablement 403 sentence)", + "packages/platform-objects/src/system/sys-metadata-activation.object.ts:152-158 (reads open)", + "docs/adr/0054 (locator stability / UI-testability contract — the repo-ownership split recorded in fixtures)", + "#12438 (sweep; nav gap found from two angles) · Epic #12150 (L5 #6301 / L6-UI #6412 both closed → the nav gap is a defect, not pending work)", + "cross-ref: automation.packaged-flow-disable-durable and automation.packaged-flow-subflow-disable-refusal (the API contracts whose surfaces this page renders); automation.packaged-flow-clone-contract (the clone door behind the dialog)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-26", + "change": "new — the ADR-0126 §7.4 Setup surface shipped in objectui with mocked component tests only; nothing asserted the cross-repo seams where the real risks live: the Setup nav entry (missing at af56546 — authored as an expected-fail reachability clause a typed URL must not satisfy), verbatim server refusals surviving to role=alert against a LIVE server, toggle persistence corroborated by the ledger row, clone-on-flows-only, the no-lineage rule, and the plain-member read posture (recorded, with the maintainer question flagged rather than adjudicated)", + "ref": "#12438" + } + ] } ] } \ No newline at end of file diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 25f0e09e03..fd99fbb488 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -1371,6 +1371,268 @@ "ref": "#11421" } ] + }, + { + "id": "platform-core.activation-ledger-registration-home", + "title": "The activation ledger's registration home: sys_metadata_activation is registered by PlatformObjectsPlugin under its OWN manifest, so packaged disable works with or without the automation service — one owner, datasource binding carried across the move", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["seeded admin (admin@objectos.ai / admin123)"], + "fixtures": { + "app": "showcase", + "requires": [ + "the with-automation composition is any stock boot: `objectstack dev`/`os serve` composes AutomationServicePlugin whenever the app requires the 'automation' capability token (packages/cli/src/commands/serve.ts CAPABILITY_PROVIDERS, :1056-1063), and the showcase does — so the live-boot legs below need nothing beyond an isolated stock boot", + "the NO-automation composition — the one #12359 measured the 503 on — is `bootStack(showcaseStack)` from @objectstack/verify with the `automation` option omitted (packages/verify/src/harness.ts:322; the option at :286 is only honored at :549-553), which is exactly how the pinned dogfood suite constructs it" + ], + "knownGaps": [ + "NO stock CLI path boots the showcase WITHOUT the automation service: serve/dev compose it from the app's own `requires`, so a manual runner cannot stage the no-automation composition with `os dev` flags alone. The no-automation legs therefore ride the pinned dogfood suite (automated.ref — its first describe carries an anti-vacuity control asserting the automation service is genuinely absent) or an authored scratch stack config that drops the requirement; the run records WHICH of the two its verdict rests on. Scoring those legs off a stock boot measures the wrong composition" + ] + }, + "steps": [ + "run the pinned suite: pnpm --filter @objectstack/dogfood exec vitest run test/packaged-activation-ledger-reach.dogfood.test.ts — BOTH describes: '#12359 — actions and NO automation service' (the 503-turnaround leg, bootStack with no automation) and '#12159 Part 1 — a composition WITH automation' (the move's second end); capture the full output", + "live with-automation boot (stock `objectstack dev`, isolated port/DB), as admin: POST /api/v1/actions/_activation/showcase_task/showcase_mark_done {\"enabled\":false} → 200; GET /api/v1/data/sys_metadata_activation and capture the row (metadata_type 'action', name showcase_mark_done, organization_id NULL, active false/0)", + "same boot, the flow half: POST /api/v1/automation/showcase_task_completed/toggle {\"enabled\":false} → 200; re-read the ledger — a metadata_type 'flow' row for showcase_task_completed appears BESIDE the action row, and the flow-name and action-name lists never cross (the discriminator is load-bearing, not decorative)", + "restore both switches ({\"enabled\":true} / toggle on) — both rows persist with active true (updated, not deleted); leave the boot as found", + "ownership is proven by the boot itself plus the registry read: the stock boot composes BOTH PlatformObjectsPlugin (serve auto-injects it) and the automation service, and it comes up green — a second registrant of sys_metadata_activation would be a boot FAILURE (`registerObject` throws 'already owned by package …', ADR-0029 D3), never a benign duplicate; the dogfood suite additionally reads registry.getObjectOwner('sys_metadata_activation').packageId === 'com.objectstack.platform-objects.activation-ledger'", + "spec receipt: read packages/spec/src/system/constants/platform-object-names.ts — the 'platform-objects' array (:47) carries 'sys_metadata_activation' (:63), 41 names total; the automation service's entry does NOT list it" + ], + "acceptance": [ + { + "clause": "the NO-automation composition serves the ledger: the flip that answered 503 SERVICE_UNAVAILABLE on the #12359 boot (actions composed, no automation service) answers 200, writes ONE install-level row (metadata_type 'action', organization_id NULL), dispatch consults it (409 ACTION_DISABLED ahead of the param contract and the record load), and re-enabling UPDATES the row rather than deleting it", + "oracle": "test", + "verify": "the dogfood suite's first describe is green INCLUDING its anti-vacuity control ('the automation service really is absent from this composition') — a harness that quietly composed automation would re-measure the case that already worked; the 503 is asserted by name (not.toBe(503)) so a regression reads as 'the ledger went away again', never as 'some error'", + "evidence": "the vitest output naming all five tests of the first describe" + }, + { + "clause": "the WITH-automation composition — the move's second end — still works whole: the action flip lands (200 → 409 on dispatch) and the flow toggle writes a durable row, both into the SAME sys_metadata_activation table, without either consumer touching the other's rows", + "oracle": "api", + "verify": "the live-boot legs (steps 2-4): both switches answer 200, the ledger read shows a 'flow' row and an 'action' row side by side, flow names never appear in the action list and vice versa; mirrored by the second dogfood describe ('both consumers share ONE table without touching each other's rows')", + "evidence": "the captured switch responses + the ledger row listing showing both metadata_type values" + }, + { + "clause": "the ledger object has exactly ONE owner and it is the declaring package's own manifest: com.objectstack.platform-objects.activation-ledger — a MOVE, not an add. The automation service's manifest no longer names the object, and re-adding it there is a boot failure for every served composition, not a duplicate", + "oracle": "test", + "verify": "the dogfood 'exactly ONE owner' test (registry.getObjectOwner) is green, plus the negative pin in packages/services/service-automation/src/activation-ledger-registration.test.ts (asserts behaviourally, over a real init() against a recording manifest service, that this plugin registers no sys_metadata_activation) and the positive pin in packages/platform-objects/src/plugin.test.ts (exactly one registering manifest)", + "evidence": "the three test outputs" + }, + { + "clause": "the datasource binding is carried by the registrar, unchanged by the move: the owning ACTIVATION_LEDGER_MANIFEST carries the automation manifest's scope/namespace/defaultDatasource triple verbatim (scope 'system', defaultDatasource 'cloud', namespace 'sys'), so on a composition with no 'cloud' driver the table rides the global default driver exactly as before — a binding change would leave existing rows in one database and read another, silently re-arming every artifact an administrator disabled (ADR-0126 §6 wall 3 through a side door)", + "oracle": "test", + "verify": "the dogfood binding test: resolveEffectiveDatasource('sys_metadata_activation') === undefined on the no-cloud composition, with sys_automation_run as the control proving the resolver is not answering undefined for everything; the manifest triple itself is pinned in platform-objects/src/plugin.test.ts where it is visible without a cloud driver existing", + "evidence": "the vitest output naming both assertions" + }, + { + "clause": "the spec-side receipt matches the runtime: PLATFORM_OBJECTS_BY_PACKAGE['platform-objects'] includes sys_metadata_activation (41 names), so the one-way lint rule and every consumer of the constant attribute the ledger to platform-objects, not to the automation service", + "oracle": "build", + "verify": "grep packages/spec/src/system/constants/platform-object-names.ts: 'sys_metadata_activation' appears in the 'platform-objects' array (:47/:63) and in no other package's array; count the array (41)", + "evidence": "the grep excerpt + the count" + } + ], + "negative": [ + "a 503 SERVICE_UNAVAILABLE from the activation door on ANY composition that carries PlatformObjectsPlugin is THE regression this item exists for (#12359 measured it) — score it as 'the ledger went away again', never as a generic server error", + "the no-automation legs scored on a boot where automation was quietly composed are vacuous — the dogfood anti-vacuity control failing is a harness finding, not a skip", + "a second package registering sys_metadata_activation surfaces as a BOOT FAILURE ('already owned by package …'), so a green stock boot is half the single-owner proof — a run that sees that throw must not read it as a flaky boot", + "a resolveEffectiveDatasource answer that changed from the pre-move behavior (rows in one database, reads in another) silently re-arms disabled artifacts and is a FAIL even though every HTTP response looks healthy" + ], + "traps": ["dispatcher-vs-hono-route", "stale-dist"], + "automated": { + "kind": "api", + "ref": "packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts (both describes); packages/services/service-automation/src/activation-ledger-registration.test.ts (the negative half of the MOVE); packages/platform-objects/src/plugin.test.ts (the owning manifest + routing triple)" + }, + "source": [ + "packages/platform-objects/src/plugin.ts:51-80 (the registration-home rationale — the measured 503 at :60-62; MOVE-not-add :67-76), :109-152 (why the ledger rides its OWN manifest — the datasource-routing measurement), :153-164 (ACTIVATION_LEDGER_MANIFEST), :181-192 (the two register calls), :193-199 (lean-kernel degradation: no manifest service → the door refuses loudly with 503 rather than keeping a bit that reverts)", + "packages/services/service-automation/src/plugin.ts:738-761 (the flow leg attaches by probe() of the real table — runObjectRegistered no longer vouches for it; not attached on a failed probe, :762-777)", + "packages/spec/src/system/constants/platform-object-names.ts:47,:63 (PLATFORM_OBJECTS_BY_PACKAGE receipt)", + "packages/verify/src/harness.ts:286,:322,:549-553 (bootStack's automation option — how the no-automation composition is constructed)", + "docs/adr/0126-packaged-metadata-customization-model.md §4 (one generic activation ledger)", + "#12438 (the scoped sweep this item lands from), #12419 (the registration-home PR), #12359 (the 503 measurement + the 2026-08-26 「同意」 ruling: registration follows the declaration)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-26", + "change": "new — authored in the #12438 scoped sweep (ADR-0126 disable+clone). Grounding fixed the item's shape twice: (1) the no-automation composition has NO stock CLI path (serve/dev compose automation from the app's own requires), so that leg rides the pinned dogfood suite's bootStack(showcaseStack) with the automation option omitted, recorded as a knownGap rather than pretended manual; (2) the single-owner clause is proven by the boot succeeding plus a registry read, because a double registration is a boot FAILURE (registerObject throws), never an observable duplicate", + "ref": "#12438" + } + ] + }, + { + "id": "platform-core.activation-ledger-row-contract", + "title": "sys_metadata_activation row contract: reads open / generic-data-API writes 405, one NULL-collapsed row per artifact, org-carrying rows skipped on read, re-enable updates never deletes, and rows exist ONLY for metadata_type flow|action (the ADR-0126 scope wall)", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "api", + "personas": ["seeded admin (admin@objectos.ai / admin123)"], + "fixtures": { + "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" + ], + "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:51) and 'action' (packages/objectql/src/action-activation.ts:113). 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", + "the reserved organization_id column has NO writer by design (ADR-0126 §5 — the per-org dimension is additive-later), so the org-row-skip leg can only be staged by hand-seeding the sqlite file between boots; the hand-seeded row is the run's own artifact and must be cleaned up (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", + "read the ledger through the open read path: GET /api/v1/data/sys_metadata_activation — capture every row; each carries the five declared columns (metadata_type, name, package_id, organization_id, active) and organization_id is NULL on every row a shipped switch wrote", + "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)", + "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)", + "uniqueness probe (direct DB): stop the server; in the sqlite file INSERT a second NULL-organization row for an existing (metadata_type, name) pair — the COALESCE'd unique index (ADR-0120 D3 NULL-collapse) refuses with a constraint error; capture it. A hand-written composite naming organization_id verbatim would be NULL-distinct and enforce nothing (#5030, measured) — the refusal is the proof the declared 'organization' arm is live", + "org-row-skip probe (direct DB): INSERT one org-CARRYING row (organization_id = any non-null id, metadata_type 'action', name showcase_mark_done, active 0); restart against the SAME DB; POST /api/v1/actions/showcase_task/showcase_mark_done → NOT refused by the switch (the artifact stays ARMED — the install-level read skips org rows, it never merges them); CONTROL, same boot: flip the real install-level switch off through the door → dispatch now 409 ACTION_DISABLED (proves the probe can see the switch, so the armed reading is not vacuous); re-enable and delete the hand-seeded row (or discard the DB)", + "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:3159) has zero route consumers" + ], + "acceptance": [ + { + "clause": "reads open, generic writes refused: GET /api/v1/data/sys_metadata_activation answers 200 for an admin (operability surfaces can ask 'what is disabled here'), while create/update/delete through the generic data API each answer 405 naming the refused operation — the rows are written by the ADR-0126 enable/disable doors under a system context and by nothing else (ADR-0103 engine-owned posture)", + "oracle": "api", + "verify": "the 200 list read + the three 405s from step 4 (apiMethods ['get','list'] at sys-metadata-activation.object.ts:153-158; the 405-with-allowedOperations shape from checkApiExposure, packages/runtime/src/api-exposure.ts:138-144); the post-probe re-read diffs empty", + "evidence": "the list read + the three refusal bodies + the empty diff" + }, + { + "clause": "row identity holds and is NULL-collapsed: one row per (metadata_type, name) at install level — organization_id NULL on every row the shipped switches write (§5 reserved, no writer sets it), and a second NULL-org row for the same pair is REFUSED by the declared unique:'organization' index, so one artifact can never carry two contradictory active rows", + "oracle": "log", + "verify": "the row dump from step 2 (organization_id NULL throughout) + the sqlite constraint refusal from step 6 (the driver's COALESCE(organization_id,'__global__') NULL-collapse, ADR-0120 D3 — the spelling sys-metadata-activation.object.ts:129-150 documents against the #5030 NULL-distinct hole)", + "evidence": "the row dump + the refused INSERT with its constraint error" + }, + { + "clause": "org-carrying rows are SKIPPED on read, never merged: a hand-seeded row with organization_id set and active=0 leaves the artifact ARMED across a restart — reading it as install-level would apply one organization's choice to the whole installation (the #10243 direction arrived at from the read side); the control leg proves the same probe detects the real install-level switch", + "oracle": "api", + "verify": "step 7 both arms: post-restart dispatch NOT 409 with only the org row present (skip at packages/core/src/utils/metadata-activation-store.ts:194), then 409 once the install-level row is written through the door", + "evidence": "the seeded row + the two dispatch statuses bracketing the control flip" + }, + { + "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", + "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:221-250; the delete-less MetadataActivationStoreEngine, :126-133)", + "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)", + "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" + }, + { + "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", + "oracle": "api", + "verify": "the pre/post-restart row diff from step 7 is empty apart from the run's own writes (no-lifecycle ruling at sys-metadata-activation.object.ts:49-54); an empty ledger on a fresh boot changes nothing anywhere", + "evidence": "the pre/post-restart row diff" + } + ], + "negative": [ + "a 2xx on ANY generic-data-API write to sys_metadata_activation is a FAIL of the engine-owned posture — these rows have exactly one writer class (the ADR-0126 doors)", + "the artifact reading as DISARMED after step 7's restart (the org-carrying row applied install-wide) is the #10243 failure 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 index (the exact #5030 hole the unique:'organization' spelling exists to close)", + "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" + ], + "variants": [ + "metadata_type 'flow' — written by ObjectStoreFlowActivationStore (packages/services/service-automation/src/flow-activation-store.ts:51)", + "metadata_type 'action' — written by ObjectStoreActionActivationStore (packages/objectql/src/action-activation.ts:113)" + ], + "traps": ["destructive-in-place", "absence-inference"], + "automated": { + "kind": "unit", + "ref": "packages/objectql/src/action-activation.test.ts + packages/services/service-automation/src/flow-activation-ledger.test.ts (the store-level pins: org-row skip, 0-reads-as-false, update-not-delete, organization_id never in the write payload); packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts (row shape + update-not-delete on a live boot)" + }, + "source": [ + "packages/platform-objects/src/system/sys-metadata-activation.object.ts — apiMethods ['get','list'] :153-158; unique:'organization' NULL-collapsed index + the #5030 rationale :129-150; organization_id reserved-NULL :107-119; no-lifecycle ruling :49-54; what the object is NOT (§4 posture) :15-42", + "packages/core/src/utils/metadata-activation-store.ts — org-row skip on read :185-207 (the wall at :194); read-then-write setActive, organization_id never in the payload :221-250; delete-less engine slice :126-133; 0-reads-as-false :199-203", + "packages/runtime/src/api-exposure.ts:113-144 (checkApiExposure — the 405 shape for a whitelisted-methods object)", + "packages/services/service-automation/src/flow-activation-store.ts:51 + packages/objectql/src/action-activation.ts:113 (the two metadata_type string literals — the whole live vocabulary)", + "packages/objectql/src/engine.ts:3159 (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), §5 (org column reserved), §6 wall 3 (record the choice, never erase it)", + "#12438 (the scoped sweep), #12419 (registration home — the sibling item), #12159 (the permission-rides-its-own-field ruling, 2026-08-26)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-26", + "change": "new — authored in the #12438 scoped sweep. Grounding decided two load-bearing shapes: (1) metadata_type has NO pinnable enum — an untyped Field.text with two string-literal writers — so the variants are hand-enumerated and explicitly un-pinned, with the re-derivation duty written into the scope-wall clause instead of a false enumSource; (2) the org-row-skip and duplicate-row probes cannot be staged over REST at all (apiMethods get/list), so they ride direct sqlite access to the run's own file DB, the same fixture class the migration-journal item proved", + "ref": "#12438" + } + ] + }, + { + "id": "platform-core.packaged-object-extend-only", + "title": "Packaged objects are extend-only (Regime E): an extension package's fields merge additively and round-trip on showcase_account, while an in-place field add on the packaged object is refused server-side at both write doors — and the collision-refusal half of the docs claim does not exist yet", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "api", + "personas": ["seeded admin (admin@objectos.ai / admin123)"], + "fixtures": { + "app": "showcase", + "requires": [ + "the shipped extension fixture: examples/app-showcase/src/data/extensions/account.extension.ts (AccountExtension — loyalty_tier select bronze/silver/gold/platinum, linkedin_url url, csat_score number 0-100 onto showcase_account, priority 210), registered through the showcase manifest's objectExtensions — present on any stock boot" + ], + "knownGaps": [ + "the COLLISION-REFUSAL half of the E1 claim DOES NOT EXIST in source, so no clause asserts it: a colliding extension field name is NOT refused at build or boot — mergeObjectDefinitions spreads extension.fields over base.fields (packages/objectql/src/registry.ts:146) and the authoring schema documents exactly that semantics ('Fields to add/override', priority 'higher = applied later (wins on conflict)' — packages/spec/src/data/object.zod.ts:2979,:2996-2997). The only collision gate in the tree is managed-extension-fields.test.ts (plugin-auth), which covers better-auth-managed sys objects only, and ADR-0126 §3 adopts it as Regime E PRIOR ART, not as a live generic gate. A colliding extension field therefore silently OVERRIDES the shipped field today: record the posture, do not stage a collision probe expecting a refusal, and do not file the override as a run FAIL — the missing generic gate is a standing product gap tracked outside this item" + ] + }, + "steps": [ + "additive half — CROSS-REF, do not duplicate: platform-core.metadata-registry-serving owns the overlay steps (its steps 7-8 / clauses 5-6): the three AccountExtension fields present in the MERGED GET /api/v1/meta/object/showcase_account, rendering on the form, and a loyalty_tier + csat_score write round-tripping through /api/v1/data/showcase_account. Cite that item's verdicts when the same run scored them; otherwise drive exactly those steps here", + "capture the BEFORE image for the refusal half: GET /api/v1/meta/object/showcase_account and keep the full served field inventory", + "in-place refusal, door 1 (org-overlay): PUT /api/v1/meta/object/showcase_account with the served body plus ONE added field qa_inplace_probe_ and NO ?package= → expect 403 NOT_OVERRIDABLE (type 'object' is not in the ADR-0005 overlay opt-ins; the refusal names allowRuntimeCreate/allowOrgOverride and the OS_METADATA_WRITABLE escape hatch — packages/metadata-protocol/src/protocol.ts:11370-11386)", + "in-place refusal, door 2 (package write): repeat the same PUT with ?package=com.example.showcase (the showcase's read-only code package) → expect the read-only-base refusal (403 ITEM_LOCKED class — the same door access-security.owd-save-gate's control leg measured on stock, #9477); record WHICH door answered and the exact status + code, reading declaredCode where the wire code is a mapped generic", + "after both refusals: re-GET /api/v1/meta/object/showcase_account and diff against step 2 — byte-identical; qa_inplace_probe_ absent, the three extension fields still present", + "record the Regime E provenance posture: the extension arrived AS A PACKAGE — it registers as an 'extend' contributor (packages/objectql/src/engine.ts:4618-4636) folded over the base at resolution (mergeObjectDefinitions), the base package's authored definition is never edited, and package identity supplies provenance and upgrade isolation (ADR-0126 §3: 'the customization IS a package', install-grain not per-org)" + ], + "acceptance": [ + { + "clause": "the additive half holds: the extension package's fields merge into the served showcase_account object without re-declaring it, and they are real writable columns, not display-only badges — a write round-trips through the generic data API", + "oracle": "api", + "verify": "platform-core.metadata-registry-serving clauses 5-6 green in this run (or re-driven per step 1): the three overlay fields in the merged /meta/object read with their declared types/options, plus the PATCH + GET pair through /data/showcase_account", + "evidence": "the cross-referenced verdicts, or the merged field inventory + the PATCH/GET pair" + }, + { + "clause": "the in-place half holds at BOTH server doors: a field add on the packaged object is refused whether spelled as an org overlay (no ?package= → 403 NOT_OVERRIDABLE) or as a direct package write (?package= naming the read-only code base → the ITEM_LOCKED-class refusal) — 'a packaged object grows through your own fields via an extension package, never by reshaping what shipped' is a server contract, not a Studio courtesy", + "oracle": "api", + "verify": "the two PUT refusals from steps 3-4, each with status + code recorded (read declaredCode where the dispatcher maps the wire code to a generic — the owd-save-gate lesson); neither door answers 2xx", + "evidence": "both refusal bodies side by side, with the door each came from named" + }, + { + "clause": "a refusal changes NOTHING: the post-refusal /meta/object read is identical to the before image — the probe field never landed, the extension fields never moved, and no half-written overlay row exists", + "oracle": "api", + "verify": "the step 5 diff is empty; a refusal that leaves a draft or a partial overlay behind is the half-installed-remains failure class even though the status code was correct", + "evidence": "the empty before/after diff" + } + ], + "negative": [ + "a 2xx on either in-place PUT is THE fail the E1 tier-3 claim guards against — the packaged base was reshaped at runtime", + "an extension field missing from the merged read, or present in /meta but not writable through /data, is a FAIL of the additive half — cross-file it against platform-core.metadata-registry-serving rather than double-counting", + "a colliding extension field name silently overriding the shipped field is NOT a run FAIL today — it is the documented merge semantics (see knownGaps) and filing it as a platform defect wastes a triage cycle. The direction that IS a finding: a collision whose override corrupts the object (e.g. a type change under existing rows) — capture and extract separately", + "a refusal rendered only by the UI (Studio hiding the affordance) while the API admits the write is a FAIL — both doors are server contracts" + ], + "traps": ["dispatcher-vs-hono-route", "cache-staleness"], + "source": [ + "examples/app-showcase/src/data/extensions/account.extension.ts (AccountExtension — the shipped fixture, priority 210)", + "packages/objectql/src/registry.ts:137-171 (mergeObjectDefinitions — additive fields fold, the spread at :146) + :4566-4648 vicinity via engine: packages/objectql/src/engine.ts:4618-4636 (the objectExtensions loop registering 'extend' contributors)", + "packages/spec/src/data/object.zod.ts:2938-3011 (ObjectExtensionSchema — what an extension may carry, the #4001 silently-dropped history, 'Fields to add/override', priority wins-on-conflict; defineObjectExtension :3009)", + "packages/metadata-protocol/src/protocol.ts:11370-11386 (the NOT_OVERRIDABLE refusal text and escape hatch)", + "packages/plugins/plugin-auth/src/managed-extension-fields.ts + managed-extension-fields.test.ts (the build-time collision gate — better-auth-managed objects ONLY; adopted by ADR-0126 §3 as Regime E prior art, NOT a generic gate)", + "docs/adr/0126-packaged-metadata-customization-model.md §2-§3 (Regime E: additive contributions at package grain, base never edited, install-grain not per-org)", + "content/docs/capabilities/integrations.mdx:17 + content/docs/build-without-code.mdx:37 (the E1 docs claim this item grounds)", + "cross-refs: platform-core.metadata-registry-serving (owns the overlay merge + round-trip steps), platform-core.metadata-authoring-roundtrip (the generic deny-side overlay gate), access-security.owd-save-gate (which layer answers which 4xx on /meta/object writes)", + "#12438 (the scoped sweep this item lands from)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-26", + "change": "new — authored in the #12438 scoped sweep to ground the E1 tier-3 docs claim. Grounding CORRECTED the claim's third half before authoring: no collision refusal exists for generic object extensions (mergeObjectDefinitions spreads extension fields over the base and the schema documents override-by-priority; the managed-extension-fields build gate covers better-auth objects only and is adopted by ADR-0126 §3 as prior art, not as a live gate) — so that half is a knownGap with an explicit do-not-file-as-FAIL rule, and the item asserts the two halves source supports: additive merge + round-trip (cross-referenced to metadata-registry-serving, not duplicated) and the server-side in-place refusal at both write doors", + "ref": "#12438" + } + ] } ] } diff --git a/docs/qa/platform-checklist/areas/studio-authoring.json b/docs/qa/platform-checklist/areas/studio-authoring.json index d95fe56e49..bed99cd015 100644 --- a/docs/qa/platform-checklist/areas/studio-authoring.json +++ b/docs/qa/platform-checklist/areas/studio-authoring.json @@ -1095,6 +1095,164 @@ "history": [ { "revision": 1, "date": "2026-08-08", "change": "new item: the html (parse-never-execute, in-browser sdui-parser compile, static) vs react (executed at render, capability-gated, live data + interactivity) source-page tiers — the objectui renderer-path split grounded in PageRenderer's kind dispatch + react-page.tsx's gate, distinguishing command-center-jsx/capability-map (html) from crm-workbench (react); records the honest build-step finding (none needed to view — in-browser compile/transpile) as a knownGap", "ref": "claude/platform-test-checklist-ocwugl" } ] + }, + { + "id": "studio-authoring.packaged-automation-studio-lock", + "title": "Studio Automations pillar on a read-only package: a packaged flow is NOT editable in place — New-flow and autosave honor the courtesy gate, the enable/disable switch produces no metadata write, and inspector edits are inert or announced, never silently discarded", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "browser", + "personas": ["admin (opens Studio on the read-only package)"], + "fixtures": { + "app": "showcase", + "requires": [ + "the stock read-only code package com.example.showcase with its packaged flows (showcase_urgent_task_alert, showcase_task_done_notify_owner, …) listed in the Automations rail — the rail merges published + draft flows package-scoped (objectui StudioDesignSurface.tsx:3286 loadPackageSurfaces)", + "a vendored console matching .objectui-sha — the pillar markup under test is objectui's, so a stale bundle tests yesterday's gate" + ], + "knownGaps": [ + "CURRENT SHAPE, RECORDED SO RUNS DON'T RE-DERIVE IT (objectui f7c52e2): the AutomationsPillar's readOnly prop is documented as 'hide/disable flow-authoring affordances' (StudioDesignSurface.tsx:3218-3219) and the New-flow button (:3478) and autosave (:3390) consult it — but the enable/disable switch does NOT (disabled={!isEditable || !!saving}, :3442) and the flow node inspector mounts with readOnly hardcoded false (:3583), where the Data pillar threads the real flag into its editors (:2999, :3099). So today the switch is clickable-but-server-refused and inspector edits take locally and silently vanish on reload. The clauses below keep the PROMISE as the assertion and name this shape as the expected finding", + "no automated pin exists in either repo for the Automations pillar's read-only posture (checked at objectui f7c52e2: no StudioDesignSurface read-only test covers the switch or the flow inspector) — this item is browser-driven end to end" + ] + }, + "steps": [ + "boot the showcase (objectstack dev --ui --seed-admin -p -d file:/tmp//data.db), sign in admin@objectos.ai/admin123; open Studio, switch to the read-only com.example.showcase package, open the Automations pillar", + "pin the precondition: GET /api/v1/packages must report com.example.showcase writable:false — the pillar's readOnly gate is pkgWritable===false (objectui StudioDesignSurface.tsx:502) and an UNKNOWN writability (fetch failed / still loading) deliberately leaves the pillar ungated, so a race here fakes a gate failure (hydration-race)", + "baseline the server truth: GET /api/v1/meta/flow/showcase_urgent_task_alert (full body) — every later clause diffs against this read", + "affordance sweep with the network panel recording: confirm the New-flow button is ABSENT from the rail heading; select the packaged flow and wait past the autosave debounce after touching the canvas — no flow save may leave the browser", + "click the enable/disable switch in the pillar header and capture everything: whether it is disabled/absent, and if clickable, the exact request it fires (today: a flow DRAFT save flipping status active↔obsolete — StudioDesignSurface.tsx:3400-3417), the server's response, and what the UI surfaces (error banner :3524-3528)", + "select a flow node and edit a property in the inspector; record whether the edit visibly takes on the canvas/draft, and whether any affordance announces a read-only or will-not-persist state", + "reload the pillar (re-open the package) and re-inspect: record whether the inspector edit survived or vanished, and whether its discard was ever announced", + "close out: GET /api/v1/meta/flow/showcase_urgent_task_alert again and diff against the baseline — byte-identical" + ], + "acceptance": [ + { + "clause": "the courtesy gate holds on the affordances that consult it: on a read-only package the New-flow button is absent from the Automations rail and the flow autosave never fires — no draft write leaves the browser from ordinary canvas edits", + "oracle": "network", + "verify": "the rail DOM carries no New button (gated on !readOnly, objectui StudioDesignSurface.tsx:3478); the network trace across the edit session and past the autosave debounce carries zero flow save/PUT from the autosave path (blocked on readOnly, :3388-3393)", + "evidence": "the rail DOM read + the flow-write-free network trace over the debounce window" + }, + { + "clause": "a packaged flow definition is not editable in place in Studio (ADR-0126 §2 Regime C, card F2): after driving every affordance the pillar offers, the flow's server-side metadata is byte-identical — and any write that slips past client courtesy is refused server-side with the ledgered 422 [writable_package_required] naming the writable-package remedy (ADR-0124 D1: the server is the authority, the client gate is courtesy)", + "oracle": "api", + "verify": "GET /api/v1/meta/flow/showcase_urgent_task_alert before/after the whole probe: identical; every flow write captured in the trace answers 4xx with the writable_package_required class — a 2xx on any of them is the hard FAIL", + "evidence": "the before/after meta reads, diffed + each refusal body" + }, + { + "clause": "the enable/disable switch honors the read-only package lock exactly as the New-flow button does: clicking it produces NO metadata write", + "oracle": "network", + "verify": "click the switch: the PASS shape is disabled/absent-or-inert (no request fires). CURRENT RECORDED FINDING (objectui f7c52e2): the switch is clickable — its disabled expression omits readOnly (StudioDesignSurface.tsx:3442) — and toggleEnabled fires a flow draft save flipping deployment status (:3400-3417) that the server refuses 422 writable_package_required, surfaced in the pillar error banner. That clickable-but-refused shape is this clause's expected FAIL with the server gate holding: record it as the standing finding, not a pass and not a new defect; only a 2xx on that write escalates to clause 2's hard FAIL", + "evidence": "the click's network capture (no request — or the refused request + the error-banner screenshot)" + }, + { + "clause": "inspector edits are inert OR their discard is announced: the flow node inspector on a read-only package either disables its inputs or tells the author the edit will not persist — an edit that visibly takes and silently vanishes on reload is the failure", + "oracle": "dom", + "verify": "edit a node property, confirm what rendered, reload the pillar and re-read the inspector + canvas: the edit must not silently vanish. CURRENT SOURCE-PREDICTED SHAPE (objectui f7c52e2): the inspector mounts with readOnly hardcoded false (StudioDesignSurface.tsx:3583 — contrast the Data pillar threading the real flag, :2999/:3099) while autosave stays blocked, so the edit takes locally and vanishes on reload unannounced — the predicted verdict is this clause's FAIL; capture it as the finding", + "evidence": "before-edit / after-edit / after-reload screenshots + the inspector input DOM state (disabled or not)" + } + ], + "negative": [ + "a 2xx on ANY flow write into the read-only package is the hard FAIL — the server gate is the authority (ADR-0124 D1), and its fall here is the same class access-security.packaged-flow-write-door-parity hunts at the /meta and /automation doors", + "an inspector edit that visibly takes and silently vanishes on reload is a FAIL of the inert-or-announced clause even though nothing persisted — 'no server write happened' is clause 2's business, not an announcement", + "SCOPE: do not re-prove or widen access-security.readonly-package-locks-studio — its steps own the object designer + permission matrix surfaces; this item owns the Automations pillar. And do not conflate this switch with the packaged-flow activation ledger: the Studio pillar switch flips the flow's deployment `status` field via a draft save (a metadata write, refused on a read-only package), while the LEDGERED enable/disable (sys_metadata_activation) lives on the Setup packaged-automation board — automation.setup-packaged-automation-board / automation.packaged-flow-disable-durable own that surface" + ], + "traps": ["stale-console-bundle", "hydration-race", "automation-input"], + "source": [ + "objectui packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx:502 (readOnly = pkgWritable===false; unknown writability deliberately leaves the pillar ungated), :3218-3219 (the prop's own contract: 'hide/disable flow-authoring affordances'), :3388-3393 (autosave blocked on readOnly), :3436-3450 (the enable/disable switch — disabled={!isEditable || !!saving}, readOnly absent), :3400-3417 (toggleEnabled = client.save flow draft flipping status active↔obsolete), :3478 (New-flow gated on !readOnly), :3583 (inspector readOnly hardcoded false; contrast Data pillar threading :2999/:3099), :3286 (the rail lists package-scoped flows) — objectui owns this pillar's markup, so locator and gate fixes land there (ADR-0054)", + "packages/metadata-protocol/src/sys-metadata-repository.ts:1563 + packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts (the 422 [writable_package_required] refusal a read-only code package answers to metadata writes — the server half of the lock; com.example.showcase is source-loaded read-only)", + "docs/adr/0126-packaged-metadata-customization-model.md §2/§3 (flow = Regime C: locked base, in-place edit refused loudly at the write door — card F2)", + "#12438 (the ADR-0126 packaged flow/action sweep this item lands from)", + "access-security.readonly-package-locks-studio (object designer + permission matrix — cross-referenced, not widened), access-security.packaged-flow-write-door-parity (the API-side door parity for the same packaged flow), automation.setup-packaged-automation-board (the ledgered enable/disable surface — a different mechanism from this pillar's status flip)" + ], + "history": [ + { "revision": 1, "date": "2026-08-26", "change": "new item (#12438 card F2, UI half): the Studio Automations pillar's read-only package lock — the promise (no in-place edit, switch produces no metadata write, inspector edits inert or announced) asserted with the current source shape recorded as the expected finding: the switch is clickable with only the server-side 422 behind it (disabled expression omits readOnly, StudioDesignSurface.tsx:3442) and the inspector hardcodes readOnly:false (:3583) so edits take locally and silently vanish on reload; New-flow (:3478) and autosave (:3390) honor the gate. Grounded in objectui f7c52e2 + the framework's writable_package_required door; owns the Automations pillar only, cross-referencing readonly-package-locks-studio for the surfaces it already owns", "ref": "#12438" } + ] + }, + { + "id": "studio-authoring.packaged-display-class-direct-edit", + "title": "Packaged views and dashboards are 'yours to edit directly' (ADR-0126 Regime O): the designer live-edits the PACKAGED artifact, publish reaches the end user, reset-overlay returns the shipped base — while a Regime-C flow in the same surface stays locked", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "browser", + "personas": ["admin (authors the overlay in the designer)", "end user (renders the published change)"], + "fixtures": { + "app": "showcase", + "requires": [ + "the stock packaged showcase views (com.example.showcase — e.g. the showcase_task view gallery, examples/app-showcase/src/ui/views/task.view.ts); discover the container name via GET /api/v1/meta/view?object=showcase_task rather than guessing it", + "the built-in System Overview dashboard (name system_overview), code-shipped by @objectstack/platform-objects — packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts", + "a packaged showcase flow as the same-surface Regime-C negative (e.g. showcase_urgent_task_alert)", + "a vendored console matching .objectui-sha" + ], + "knownGaps": [ + "DASHBOARD OVERLAY DOOR VERIFIED PRESENT at authoring time (this worktree): DEFAULT_METADATA_TYPE_REGISTRY declares dashboard allowOrgOverride:true (packages/spec/src/kernel/metadata-plugin.zod.ts:787), same as view (:785) — so the dashboard leg is ASSERTED, not waived. If a run finds the flag flipped, revise this item (ADR-0005 admission-pair change) before scoring", + "dashboards.system-overview-live-counts drives the SAME system_overview board expecting its stock shape — the dashboard leg here MUST end with the reset-overlay step; an overlay left behind corrupts that item's next run", + "the neighbouring failure class is objectui#4308 mis-tiering (an item in a WRITABLE package shown behind the installed-package lock): if the packaged view/dashboard opens LOCKED, read the ResourceEditPage tiering (isArtifactItem :1144-1148; artifact-backed canWrite = allowOrgOverride, :1592-1599) and the layered envelope's provenance, and forge the raw PUT, before filing a registry defect" + ] + }, + "steps": [ + "boot the showcase, sign in admin@objectos.ai/admin123; open the metadata-admin designer on a PACKAGED showcase view (/_console/…/metadata/view/, container discovered via GET /api/v1/meta/view?object=showcase_task) — it must open EDITABLE, with no 'provided by an installed package' banner", + "record the shipped base: GET /api/v1/meta/view?object=showcase_task and the layered read of the container, before any edit — the reset clause diffs against this", + "edit DIRECTLY on the packaged artifact — change the default list's label and add/remove a column — save draft and publish through the designer chrome", + "as an end user, open the showcase task list (client reload only, no server restart): the edited label/column set renders", + "server truth: GET the view again — the overlay body is served over the shipped base (overlay wins at read; the code layer remains underneath in the layered read)", + "dashboard leg: open the designer on the built-in system_overview dashboard — it opens EDITABLE the same way (dashboard allowOrgOverride:true); edit a widget title; publish; open the rendered System Overview board and confirm the edited title renders (its live-count semantics stay dashboards.system-overview-live-counts' business)", + "reset BOTH overlays via the designer's destructive control: it must carry the 'Reset overlay' verb (resettable:true on the layered envelope — a package baseline exists), confirm; the follow-up GET serves the SHIPPED body again and the end-user surfaces render the base", + "negative, same surface: open the designer route on the PACKAGED flow — it opens read-only behind the installed-package banner (artifact-backed + flow allowOrgOverride:false) with no operating save/publish path" + ], + "acceptance": [ + { + "clause": "a PACKAGED view opens directly editable in the designer — no installed-package lock — and the edit persists as an org overlay served over the shipped base (the tier-1 claim 'views … are yours to edit directly', on the packaged artifact itself)", + "oracle": "api", + "verify": "the editor renders editable (no artifactLockedBanner — artifact-backed canWrite is allowOrgOverride, which view declares true); after publish, GET /api/v1/meta/view?object=showcase_task serves the edited label/columns while the layered read still carries the code base underneath", + "evidence": "the editable-designer screenshot + the pre/post meta reads" + }, + { + "clause": "the end user sees the direct edit after publish with NO server restart — the claim is about the running app, not the store", + "oracle": "screenshot", + "verify": "the end-user task list renders the new label/column set after a client reload; the server log shows no restart across the sequence", + "evidence": "before/after end-user screenshots + server log excerpt" + }, + { + "clause": "the dashboard leg holds on a BUILT-IN packaged dashboard: system_overview (code-shipped by platform-objects) is directly editable the same way, and the rendered board shows the published edit ('… and dashboards are yours to edit directly')", + "oracle": "api", + "verify": "the dashboard editor opens editable; after publish GET /api/v1/meta/dashboard/system_overview serves the edited widget title and the rendered board displays it — the tiles' live-count truthfulness is dashboards.system-overview-live-counts, cite it rather than re-proving", + "evidence": "the dashboard meta read + the rendered-board screenshot" + }, + { + "clause": "reset-overlay returns the shipped base — the customization is an overlay, never a mutation of what shipped: the destructive control carries the Reset-overlay verb, answers reset:true, and the follow-up read AND render serve the artifact default again", + "oracle": "api", + "verify": "the reset (view and dashboard both) answers reset:true; GET then serves the body recorded in step 2, byte-identical; the end-user list and the board render the base — re-fetch fresh, never score a cached read (cache-staleness)", + "evidence": "the reset receipts + post-reset reads diffed against the step-2 baseline + the reverted renders" + }, + { + "clause": "a Regime-C artifact in the SAME surface stays locked: the packaged flow opens read-only behind the installed-package banner with no operating save/publish path — 'yours to edit directly' is the Regime-O display classes (view, dashboard), never flow (ADR-0126 §3)", + "oracle": "dom", + "verify": "after a screenshot confirms render, the flow editor shows the installed-package lock banner and its inputs are read-only (isArtifactItem + flow allowOrgOverride:false ⇒ canWrite false); no save/publish affordance operates", + "evidence": "the locked-editor screenshot + the disabled-state DOM read" + } + ], + "negative": [ + "a packaged view or dashboard opening behind the installed-package lock is the FAIL of the shipped tier-1 claim (content/docs/capabilities/integrations.mdx:17, content/docs/build-without-code.mdx:37) — but before filing it as a registry defect, forge the raw PUT: if the server ACCEPTS while the UI locks, it is the objectui#4308 client mis-tiering class, filed against objectui, not the registry", + "a reset that answers 200 while the read or render still serves the overlay is a FAIL — the restore must flip the served body", + "leaving the system_overview overlay behind after the run corrupts dashboards.system-overview-live-counts' stock fixture — the reset step is part of the item, not cleanup courtesy", + "SCOPE: the raw-PUT registry gate in both directions (403 not_overridable on locked kinds, accepted overlay + DELETE reset semantics at API grain) is studio-authoring.org-override-registry-gate — cite it, never duplicate it; studio-authoring.view-authoring-live deliberately avoids packaged views, and this item is its packaged complement" + ], + "traps": ["stale-console-bundle", "hydration-race", "automation-input", "cache-staleness"], + "source": [ + "packages/spec/src/kernel/metadata-plugin.zod.ts:785 (view allowOrgOverride:true), :787 (dashboard allowOrgOverride:true — the overlay door the dashboard leg rides, verified present), :828 (flow allowOrgOverride:false — the same-surface Regime-C negative)", + "docs/adr/0126-packaged-metadata-customization-model.md §3 (Regime O = view, dashboard, report, translation, email_template; flow = Regime C)", + "content/docs/capabilities/integrations.mdx:17 + content/docs/build-without-code.mdx:37 (the E1 tier-1 claim under test: 'views and dashboards are yours to edit directly' on installed/packaged apps)", + "packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts (the code-shipped built-in board the dashboard leg edits)", + "objectui packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx:1144-1148 (isArtifactItem tiering), :1592-1599 (artifact-backed canWrite = allowOrgOverride — why the view/dashboard open editable and the flow does not), :1190-1191 (resettable → the Reset-overlay verb), :2166-2190 (the installed-package lock banner) — objectui owns the designer and end-user markup, so locator fixes land there (ADR-0054); the framework owns the registry flags and the overlay/reset doors", + "#12438 (the ADR-0126 sweep this item lands from)", + "studio-authoring.org-override-registry-gate (the raw-PUT registry-gate half — cross-referenced, not duplicated), studio-authoring.view-authoring-live (the non-packaged authoring loop this item complements), dashboards.system-overview-live-counts (the same board's live-count semantics — and the reason the overlay must be reset)" + ], + "history": [ + { "revision": 1, "date": "2026-08-26", "change": "new item (#12438, E1 tier-1 claim / ADR-0126 §3 Regime O): 'views and dashboards are yours to edit directly' proven on PACKAGED artifacts through the designer — live edit, publish, end-user render, reset-overlay back to the shipped base — with a built-in dashboard leg (system_overview; the dashboard kind's allowOrgOverride:true overlay door verified in the registry at metadata-plugin.zod.ts:787 before asserting, per the register's instruction) and a same-surface Regime-C flow negative. Complements org-override-registry-gate (raw-PUT half) and view-authoring-live (which deliberately avoids packaged views)", "ref": "#12438" } + ] } ] } diff --git a/docs/qa/platform-checklist/coverage.json b/docs/qa/platform-checklist/coverage.json index d4893a759c..7f0bee8c44 100644 --- a/docs/qa/platform-checklist/coverage.json +++ b/docs/qa/platform-checklist/coverage.json @@ -5,7 +5,11 @@ "items": [ "records-forms.action-param-widgets", "ai.mcp-run-action-exposure-gate", - "records-forms.action-location-matrix" + "records-forms.action-location-matrix", + "api-backend.packaged-action-disabled-dispatch", + "api-backend.action-activation-door-contract", + "automation.setup-packaged-automation-board", + "platform-core.activation-ledger-registration-home" ] }, "agent": { @@ -36,7 +40,8 @@ "dashboards.chart-type-matrix", "dashboards.global-filters-rescope", "dashboards.chart-first-paint", - "dashboards.empty-null-bucket-boundaries" + "dashboards.empty-null-bucket-boundaries", + "studio-authoring.packaged-display-class-direct-edit" ] }, "dataset": { @@ -78,7 +83,14 @@ "automation.screen-flow-roundtrip", "automation.durable-suspend-restart", "automation.flow-runs-page-test-trigger", - "automation.flow-toggle-kill-switch" + "automation.flow-toggle-kill-switch", + "automation.packaged-flow-disable-durable", + "automation.packaged-flow-subflow-disable-refusal", + "automation.packaged-flow-clone-contract", + "automation.setup-packaged-automation-board", + "access-security.activation-write-operator-gate", + "access-security.packaged-flow-write-door-parity", + "studio-authoring.packaged-automation-studio-lock" ] }, "hook": { @@ -108,7 +120,8 @@ "items": [ "records-forms.crud-roundtrip", "platform-core.metadata-authoring-roundtrip", - "studio-authoring.object-designer-roundtrip" + "studio-authoring.object-designer-roundtrip", + "platform-core.packaged-object-extend-only" ] }, "page": { @@ -128,7 +141,8 @@ "access-security.permission-matrix-edit-loop", "access-security.sharing-rule-authoring-ui", "access-security.owd-save-gate", - "access-security.share-link-capability-tokens" + "access-security.share-link-capability-tokens", + "access-security.packaged-permission-set-lifecycle" ] }, "position": { @@ -193,7 +207,8 @@ "records-forms.list-view-capabilities", "studio-authoring.view-authoring-live", "records-forms.gantt-interactions", - "records-forms.saved-view-management" + "records-forms.saved-view-management", + "studio-authoring.packaged-display-class-direct-edit" ] }, "webhook": { diff --git a/packages/platform-objects/src/apps/setup-nav.contributions.ts b/packages/platform-objects/src/apps/setup-nav.contributions.ts index 110762315e..fa40ab2c56 100644 --- a/packages/platform-objects/src/apps/setup-nav.contributions.ts +++ b/packages/platform-objects/src/apps/setup-nav.contributions.ts @@ -49,6 +49,18 @@ export const SETUP_NAV_CONTRIBUTIONS: NavigationContribution[] = [ priority: BASE_PRIORITY, items: [ { id: 'nav_packages', type: 'component', label: 'Packages', componentRef: 'developer:packages', icon: 'package' }, + // Packaged automation is OPERATIONAL state — on/off per packaged + // flow/action, clone for flows (ADR-0126 §7.4: "Studio keeps the + // editing; Setup gets the operational state"). The console binds + // `automation:packaged` to the packaged-automation page (objectui + // app-shell `builtinComponents`). The entry lives HERE, not in + // service-automation: the page's data source is the + // `sys_metadata_activation` ledger this package registers (#12419), and + // the action switches work on compositions with no automation service — + // a nav entry riding that service would hide a page that still works + // there. #12457: the page merged in objectui with no framework entry + // naming its ref, leaving it reachable only by typed URL. + { id: 'nav_packaged_automation', type: 'component', label: 'Packaged Automation', componentRef: 'automation:packaged', icon: 'workflow' }, ], }, { diff --git a/packages/platform-objects/src/apps/setup-packaged-automation-nav.test.ts b/packages/platform-objects/src/apps/setup-packaged-automation-nav.test.ts new file mode 100644 index 0000000000..92846d24ec --- /dev/null +++ b/packages/platform-objects/src/apps/setup-packaged-automation-nav.test.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The Setup app's Packaged Automation entry points at the `automation:packaged` +// COMPONENT the console registers (#12457, ADR-0126 §7.4). +// +// Why this needs a pin rather than a code comment: the page shipped complete in +// objectui (app-shell `PackagedAutomationPage`, registered under +// `automation:packaged` in `builtinComponents`) with a nav test asserting the +// objectui half of the contract — ref registered, ref addresses +// `component/automation/packaged`, page mounts through that route +// (`PackagedAutomationPage.navContribution.test.tsx`). NOTHING asserted the +// framework half, so the epic closed with no `NavigationContribution` naming +// the ref and the page reachable only by a hand-typed URL — a defect invisible +// to `platform-core.builtin-apps-nav-render`, which walks only the destinations +// the merged nav declares. This file is the framework half of that cross-repo +// contract; the REF LITERAL below and objectui's `REF` constant must agree, and +// each side pins its own. +// +// Two deliberate absences worth stating so they are not "fixed": +// - no `requiresService: 'automation'` — the page's action half rides the +// `sys_metadata_activation` ledger this package registers (#12419) and works +// on compositions with NO automation service; gating the entry on that +// service would hide a page that still works there. +// - no `requiredPermissions` — matches `nav_packages` beside it: the Setup +// app's own `setup.access` gates the app, and the activation WRITE doors +// enforce `manage_metadata` / the ADR-0126 §5 operator gate server-side. +import { describe, it, expect } from 'vitest'; +import { NavigationContributionSchema } from '@objectstack/spec/ui'; + +import { SETUP_NAV_CONTRIBUTIONS } from './setup-nav.contributions.js'; + +type NavItem = { + id?: string; + type?: string; + label?: string; + componentRef?: string; + requiresService?: string; + requiredPermissions?: string[]; + children?: NavItem[]; +}; + +/** Every contributed Setup nav item, depth-first, with its owning group. */ +const allItems = (): Array<{ group?: string; item: NavItem }> => { + const out: Array<{ group?: string; item: NavItem }> = []; + for (const c of SETUP_NAV_CONTRIBUTIONS) { + const walk = (items: NavItem[] = []) => { + for (const item of items) { + if (!item) continue; + out.push({ group: c.group, item }); + if (Array.isArray(item.children)) walk(item.children); + } + }; + walk((c.items ?? []) as NavItem[]); + } + return out; +}; + +const entry = (): { group?: string; item: NavItem } => { + const found = allItems().find(({ item }) => item.id === 'nav_packaged_automation'); + expect(found, 'Setup lost its nav_packaged_automation entry (#12457)').toBeDefined(); + return found as { group?: string; item: NavItem }; +}; + +describe('the Setup Packaged Automation entry targets the console component (#12457)', () => { + it('routes to the `automation:packaged` registry key', () => { + expect(entry().item).toMatchObject({ + type: 'component', + componentRef: 'automation:packaged', + label: 'Packaged Automation', + }); + }); + + it('lives in group_apps beside Packages — package administration is Operate (ADR-0084)', () => { + expect(entry().group).toBe('group_apps'); + }); + + it('does not gate on the automation service (the action half works without it, #12419)', () => { + expect(entry().item.requiresService).toBeUndefined(); + }); + + it('parses as a NavigationContribution the runtime merge accepts', () => { + const contribution = SETUP_NAV_CONTRIBUTIONS.find((c) => + (c.items ?? []).some((i) => (i as NavItem).id === 'nav_packaged_automation'), + ); + expect(contribution).toBeDefined(); + expect(() => NavigationContributionSchema.parse(contribution)).not.toThrow(); + }); +}); diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index bbbf8256cf..6873d60cbb 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -64,6 +64,7 @@ export const en: TranslationData = { // each app resolves its own subtree, so the studio copy never answers // for this one. Its absence here was #5750's first cause. nav_packages: { label: 'Packages' }, + nav_packaged_automation: { label: 'Packaged Automation' }, nav_marketplace_browse: { label: 'Browse Marketplace' }, nav_marketplace_installed: { label: 'Installed Apps' }, nav_cloud_connection: { label: 'Cloud Connection' }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts index 4edea798f4..4f28b94321 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts @@ -52,6 +52,7 @@ export const esESSourceHashes: Readonly> = { 'apps.setup.navigation.group_overview.label': 'eebdbe1cc7b59cdf', 'apps.setup.navigation.group_apps.label': '699b367291abcbe6', 'apps.setup.navigation.nav_packages.label': '8cd8f29658025f73', + 'apps.setup.navigation.nav_packaged_automation.label': 'f18b2b616b63012a', 'apps.setup.navigation.nav_marketplace_browse.label': '2afbe021f5419fdd', 'apps.setup.navigation.nav_marketplace_installed.label': '93bd5d458e05db60', 'apps.setup.navigation.nav_cloud_connection.label': '3a7caad0cb98e71d', diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index 3eb8e4c783..a0f1b8c64e 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -35,6 +35,7 @@ export const esES: TranslationData = { // Setup's own `nav_packages` — distinct from the same id under // `apps.studio.navigation`, which does not answer for this app (#5750). nav_packages: { label: 'Paquetes' }, + nav_packaged_automation: { label: 'Automatización empaquetada' }, nav_marketplace_browse: { label: 'Explorar Marketplace' }, nav_marketplace_installed: { label: 'Aplicaciones instaladas' }, nav_cloud_connection: { label: 'Conexión a la nube' }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts index e1ad461c65..6444edd9f1 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts @@ -52,6 +52,7 @@ export const jaJPSourceHashes: Readonly> = { 'apps.setup.navigation.group_overview.label': 'eebdbe1cc7b59cdf', 'apps.setup.navigation.group_apps.label': '699b367291abcbe6', 'apps.setup.navigation.nav_packages.label': '8cd8f29658025f73', + 'apps.setup.navigation.nav_packaged_automation.label': 'f18b2b616b63012a', 'apps.setup.navigation.nav_marketplace_browse.label': '2afbe021f5419fdd', 'apps.setup.navigation.nav_marketplace_installed.label': '93bd5d458e05db60', 'apps.setup.navigation.nav_cloud_connection.label': '3a7caad0cb98e71d', diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index 3b808d504a..e2b1fdcac6 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -35,6 +35,7 @@ export const jaJP: TranslationData = { // Setup's own `nav_packages` — distinct from the same id under // `apps.studio.navigation`, which does not answer for this app (#5750). nav_packages: { label: 'パッケージ' }, + nav_packaged_automation: { label: 'パッケージ自動化' }, nav_marketplace_browse: { label: 'マーケットプレイスを閲覧' }, nav_marketplace_installed: { label: 'インストール済みアプリ' }, nav_cloud_connection: { label: 'クラウド接続' }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts index 4eaef30a8f..78e8a43338 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts @@ -52,6 +52,7 @@ export const zhCNSourceHashes: Readonly> = { 'apps.setup.navigation.group_overview.label': 'eebdbe1cc7b59cdf', 'apps.setup.navigation.group_apps.label': '699b367291abcbe6', 'apps.setup.navigation.nav_packages.label': '8cd8f29658025f73', + 'apps.setup.navigation.nav_packaged_automation.label': 'f18b2b616b63012a', 'apps.setup.navigation.nav_marketplace_browse.label': '2afbe021f5419fdd', 'apps.setup.navigation.nav_marketplace_installed.label': '93bd5d458e05db60', 'apps.setup.navigation.nav_cloud_connection.label': '3a7caad0cb98e71d', diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index 4fc8718cee..e841295b10 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -38,6 +38,7 @@ export const zhCN: TranslationData = { // literal in an otherwise Chinese menu (#5750). Both entries are // legitimate; keep them both. nav_packages: { label: '软件包' }, + nav_packaged_automation: { label: '打包自动化' }, nav_marketplace_browse: { label: '浏览应用市场' }, nav_marketplace_installed: { label: '已安装应用' }, nav_cloud_connection: { label: '云连接' },