Skip to content

QA run · approvals (FULL area) · a86db175 · 2026-08-11 · 5 PASS / 2 PARTIAL / 1 FAIL #7517

Description

@baozhoutao

Full approvals area run of the checklist-test skill — all 8 runnable items driven against a live showcase (2 opus subagents, isolated boot each). 3 items pre-blocked on fixtures (quorum-m-of-n, viewer-gating-submitter-side, sla-escalation). Labels: qa-run + bug.

Result: 5 PASS · 2 PARTIAL · 1 FAIL. Text-only per RUNNER.md.

Environment — framework a86db175 (PR #7304) · vendored console 09987b68 · showcase · isolated file DB + port per batch.

✅ Headline: the approval security boundary holds

decision-only-via-service proves the gate is fail-closed and node-scoped:

  • A raw POST …/runs/<id>/resume on an approval-paused run → 403 ("only its owning service may resume"), for both a bare body and branchLabel:'approve', with run / request / action-log unchanged.
  • The same route resumes a screen-paused run 200 → completed. So it is a targeted gate, not a dead route.
  • Every direct-write door is refused: PATCH/DELETE/POST/PUT on sys_approval_request, and POST on sys_approval_action / sys_approval_approver / sys_approval_token all answer 405 (allowed: get/list/aggregate/search/export). Only sys_approval_delegation is writable by design and is scoped to the caller's own delegator row.
  • Forged actorId is rejected, not ignored — even from an entitled approver: "cannot act as X — an approval action is recorded against the authenticated caller" (403).

resume-authority-gate pin: 22/22 green.

🔴 FAIL — 1 item (a showcase fixture defect)

decision-only-via-service clause 2 — approving an invoice sign-off strands its flow run

The decision is recorded correctly (status:'approved' + an approve action row), but the approver receives HTTP 500 RESUME_FAILED and the run re-reads status:'failed':

Node 'notify_cleared' failed: notify: at least one recipient is required, but every recipient template resolved to nothing: {record.account.owner}

Repro: take any flow:showcase_invoice_signoff request (seeded INV-1008, or raise one by PATCHing a draft invoice to status:'sent') and POST /api/v1/approvals/requests/<areq>/approve. Reproduced 2× on two invoices, identical error; the boot log carries the matching [approvals] resume failed — the run is stranded ERROR.

Root cause: examples/app-showcase/src/automation/flows/index.ts InvoiceDualSignoffFlownotify_cleared uses recipients: ['{record.account.owner}'] while the start node declares no config.expand, so the relation hop resolves to nothing. The engine's own error states the fix. Deterministic for every invoice approval.

🟡 PARTIAL — 2 items (both are checklist-text drift, not defects)

  • decision-action-matrix — all nine decision actions individually driven with before/after action-log diffs, each appending exactly one row carrying actor + kind + step; terminal states and run resumption proven on 3 datapoints including the decision-gateway exec branch; the maxRevisions terminal auto-rejects with no infinite loop; the record lock proven both ways (ADR-0089 D3a: flip .strict() on view/page schemas so mis-layered visibility keys are loud errors #2902). All negatives hold — no-revise-edge → 400 naming the missing out-edge, an immediate second remind → 429 THROTTLED with no second row, unentitled actor → 403 with state unchanged. Partial only because the item's verify text says resubmit moves the original request returned → pending; in fact a new request is minted per approval-node entry while the original stays returned (source-confirmed as by design). → revise the item text.
  • dynamic-approver-routing — the decision dialog renders a real typed record picker (live sys_user query, 4 real rows), not the 审批节点「处理人 Value」应改为记录 lookup(现查 metadata 端点→只能手填);附 approver value 语义核实 + queue 未实现 #3508 degraded free-text; multi-select proven on the captured POST body; the required gate refuses both a missing key and an empty array (400, request stays pending, no action row); stage-2 pending_approvers equals precisely the picked set (1 pick → 1, 2 picks → 2, same order). Partial only on the oracle: the run-detail read carries no variables key while paused (structural — the engine's suspend path calls recordLog without variables) and there is no side door, so "the outputs land in run variables" is proven functionally via the resolution rather than read directly. → reword the clause, or have run-detail carry the suspended-run variables snapshot.

✅ PASS — 5 items

  • ooo-delegation-reroute (5/5 + 3 negatives) — an active delegation reroutes the slot to the delegate only, writes a system ooo_substitute row naming both parties, notifies the delegate with a deep link and the delegator in parallel; the delegate decides with their own bearer token and the action row is attributed to them, never laundered onto the delegator; closing the window reroutes back instantly (no job, no restart). Both window edges tested, plus the position-routed negative (a position slot is unaffected while an individually-routed slot on the same instance reroutes).
  • per-group-signoff (6/6 + 2 negatives) — the decisive per_group oracle: after the manager-group approval the request stays pending with the manager group satisfied and dropped from the slate while finance remains open. The finance decision was made by a real second session (Ada Auditor driving the console herself), not the admin can_override path. Contrast case proves the difference: a 3-slot/1-user request finalizes on one approval.
  • inbox-metadata-actions (5/5) — the rendered action set matches the live server metadata's CEL visible predicates exactly across three viewer states (other-submitter pending → 5; own-submission pending → 7; finalized → 0).
  • notification-deep-link (4/4) — cold-loading the link in two separate fresh contexts opens the drawer on the right request (identity/status/pending slate field-matched against the API); an unknown id gives an explicit not-found and carries no other request's data.
  • record-page-decisions (5/5 + 3 negatives) — objectui#3126 refuted by a sampled dialog census: count held at exactly 1 for 6s before Confirm and 0 for 4.8s after, with exactly one POST. Lock contrast driven on the same flow both ways. Decision buttons are absent both for a record with no approval and — the stronger probe — for a real non-approver session viewing a record that does carry a live approval.

Cross-cutting defects found

  1. Hook refusals carrying an explicit statusCode are not mapped by /api/v1/data and leak as HTTP 500 INTERNAL_ERROR with no located guidance. Two independent cases, reproduced 2× each: (a) PATCH a record while a lockRecord:true approval is pending → 500, while the log holds RECORD_LOCKED; (b) POST sys_approval_delegation with delegator_id != caller → 500, while the log holds FORBIDDEN … statusCode 403. The console never hits (a) because the affordance is correctly disabled, but a direct API caller gets an opaque 500.
  2. The console header bell panel is dead. It renders "No notifications" under both its Unread and All filters while GET /api/v1/notifications returns 10 rows including an approval.reminder — and instrumenting the page shows the panel issues zero /api/v1/* requests when opened. The home page's "Needs your attention" card does surface them. Deserves its own checklist item. (Corroborated independently from the platform-core run, which found the console home header polls sys_inbox_message instead of /api/v1/notifications.)
  3. assignedToMe=true is not a supported list filter on /api/v1/approvals/requests — silently ignored, returns everything. The filter the console actually uses is approverId=<userId>,<email>,role:user.

Fixture corrections for the next sweep

  • ooo-delegation-reroute's prescribed persona shape is not constructiblesys_approval_delegation is self-service-only and the named delegator has no auth account. The mirror image was run instead (a second real runtime account as delegate), which is stronger.
  • Provisioning a seeded demo persona as a real login needs more than a password hash: better-auth 1.7.0-rc.2 requires the credential account's issuer to equal local:credential; without it sign-in fails INVALID_EMAIL_OR_PASSWORD behind a misleading "User not found" warn. Worth writing into the approvals items' knownGaps so future runs drive real second personas instead of recording blocked(fixture).
  • notification-deep-link's fixtures.requires is false on this build — opening a request notifies nobody (no approval.opened topic exists); name the remind/reassign path instead. Its clause-1 evidence line ("two screenshots with the URL visible") also cannot be met literally, because the console consumes the query param.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions