feat(ai): tier-3 supervised/four-eyes approval split — backend core - #3175
Merged
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… scopes Adds TIER3_FOUR_EYES_ACTIONS/TOOLS and TIER3_SUPERVISED_ACTIONS/TOOLS plus resolveApprovalScope() per the 2026-08-05 tier3-supervised-four-eyes design (§3.1/§3.2): within tier 3, four_eyes requires a second approvals:decide holder; everything else is supervised (requester self-approves). checkGuardrails now surfaces approvalScope on both tier-3 return branches. Exports getAllRegisteredToolNames() from aiTools.ts (core registry + M365/Google tier maps) so the exhaustiveness contract test can walk every real tool. s1_threat_action gets a new per-action split in TIER3_ACTIONS (kill/quarantine supervised, rollback four_eyes) since its action enum is a real discriminator, unlike s1_isolate_device's boolean isolate flag which stays whole-tool supervised per spec §3.1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st columns Adds approval_scope/classification_version (immutable tier-3 classification from checkGuardrails), approval_expires_at/release_by (split pending-approval vs execution-lease deadlines), and effect_digest (four_eyes content-pinning hash) to action_intents, per the tier3-supervised-four-eyes-split design. Extends action_intents_immutable_trg to cover the three immutable columns; release_by/approval_expires_at stay mutable for lifecycle stamping. Registers the new columns in the export-policy allowlist and updates the schema contract test's column-list assertion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
resolveIntentApprovers counted disabled/invited users as eligible Tier-3 action-intent approvers on both the org-member and partner-axis candidate queries, inflating the four-eyes approver set and wrongly suppressing the sole-operator fallback. Join `users` and gate on status='active' on both queries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t deadlines Task 4 of the tier3-supervised-four-eyes backend plan. createActionIntent now reads checkGuardrails' approvalScope, stamps approvalScope/classificationVersion on the intent, and short-circuits supervised intents to a single requester-owned approval row (no approvals:decide required, no push). Four-eyes chat deadlines extend to 60 minutes; supervised keeps the existing 5-minute window. Legacy expiresAt is dual-written alongside the new approvalExpiresAt column for rolling-upgrade compat (removal tracked as Plan 3 cleanup). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extract insertSingleApproverRow(userId) helper shared by the supervised short-circuit and the four_eyes sole-operator branch — both previously had byte-identical 8-line blocks to insert one approval_requests row and derive approvalRequestIds/requesterApprovalRequestId/fanOutUserIds. Behavior unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pending_approval intents now expire on approval_expires_at while approved intents expire on release_by (falling back to expires_at for legacy rows), in both the expiry reaper and the release worker's claim CAS. The approve fan-in stamps release_by = now() + RELEASE_LEASE_MS (10m) on an approval win, so an intent approved just before approval_expires_at gets a fresh lease instead of being reaped for a deadline that no longer governs it (the "59:59 trap"). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tier-3 SUPERVISED intents let the requester self-decide with no WebAuthn assertion, gated on a live re-check of the underlying tool's RBAC permission (not approvals:decide) via the same buildAuthContextForIntent + checkToolPermission pair the release worker uses. A non-requester is refused (403 not_requester) even if they hold approvals:decide. The whole decide write — approval-row CAS, ai_tool_executions mirror, and the action-intents fan-in (intent CAS + release_by + sibling expiry + intent_approved outbox insert) — now commits as ONE system-scoped transaction, so a fault anywhere inside rolls everything back and the caller gets a retryable 500 instead of a half-applied decision (approval decided with no intent/outbox follow-through). report-suspicious's intent-rejection block gets the same fail-loud treatment instead of swallowing the error. Extends aiAgentSdk.ts's ApprovalMethod audit union with 'supervised_self' so the postToolUse audit event distinguishes a supervised self-decide from a four_eyes approval via the approvals surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, enforcing floor Fix round 1 on the supervised plain-decide + atomic-decide-transaction work: 1. report-suspicious: fold the approval-row 'reported' flip into the SAME transaction as the intent CAS + sibling expiry. Previously the flip committed separately BEFORE that transaction opened, so a throw inside it left the row 'reported' with the intent permanently stranded pending_approval (a retry's pre-fetch saw status!=='pending' and never re-entered the fan-in). A rollback now restores 'pending' too. 2. Lock-order inversion: both the decide-write transaction and report-suspicious's intent-reject transaction now take a `SELECT ... FOR UPDATE` lock on the intent row FIRST, before touching approval_requests — matching report-suspicious/intentExpiryReaper's existing order. The decide handler previously locked its own approval row first and the intent second, the opposite order, which could deadlock (Postgres 40P01) against a concurrent report-suspicious/reaper transaction. 3. The ai_tool_executions mirror (system-scoped, no RLS) now carries an explicit `exists(...)` tenant/linkage guard in its UPDATE's WHERE clause — verifying the execution's ai_sessions row belongs to the same user as the approval_requests row being decided — instead of relying solely on the (already-trusted, but app-layer-only) executionId FK value. 4. New `approvalsDecideSupervised.integration.test.ts`: drives the real approve/deny route against genuine role/permission state (not the mocked buildAuthContextForIntent/checkToolPermission the unit suite stands in for) — happy path (200, intent approved, outbox written) and a revoked-permission path (403, nothing written). 5. Adjudicated requirement: a supervised decide now checks whether the partner's authenticator policy is actively enforcing before skipping the assurance ladder. When enforcing, the requester goes through the same ladder a four_eyes sole-operator self-approve does (WebAuthn L3 satisfies it); non-enforcing partners keep the unchanged plain-click path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds effectDigest.ts (SHA-256 over resolved tool-specific target content: run_script body, quote send revision, invoice/contract state, org status). createIntent computes and stores it for four_eyes intents only, inside the creation transaction; the release worker recomputes it immediately before execution and CASes to failed:content_changed on a mismatch, closing the TOCTOU gap where an approver signs off on a reference whose target mutates during the (up to 60-minute) four_eyes approval window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t-neutral mount GET /pending now joins action_intents and drops rows whose intent is no longer pending_approval, or whose caller no longer qualifies live (four_eyes: current approvals:decide + org access; supervised: still the requester) — a demoted approver stops seeing (and count-ing) a row it can no longer decide. Adds keyset (createdAt, id) pagination (limit capped at 50, opaque cursor, additive nextCursor field) and a new GET /pending/count using the identical filter set. Both routes are now also mounted transport-neutrally at /api/v1/approvals, alongside the existing /api/v1/mobile/approvals alias, with the same mobileDeviceBlockedMiddleware so a blocked device can't dodge the check via the new prefix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…le four-eyes contract - resolveApprovalScope now takes `input` and resolves manage_organizations: update_org (four_eyes only when `status` is present, else supervised) and s1_isolate_device (four_eyes only on isolate:false containment release, else supervised) from argument content instead of a static tool/action table. Both are exempted from the contract test's static exactly-one-scope invariant via new TIER3_INPUT_AWARE_ACTIONS/TIER3_INPUT_AWARE_TOOLS sets and covered by dedicated both-branches tests. - aiAgentSdk's chat SSE approval_required event for the tier-3 durable-intent path now carries approvalScope alongside the existing selfApprovalRequestId, so the web approval card can distinguish a supervised self-approve from a four_eyes sole-operator self-approve (AiStreamEvent type updated in packages/shared). Push dispatch is unchanged: it was already fully owned and gated to four_eyes inside intentService.ts's createActionIntent; the only push call site in aiAgentSdk.ts is the unrelated Tier-2 legacy per_step bridge. - New durable-executable contract test (intentReleaseWorker.durable.contract.test.ts) asserts every four_eyes-classified tool (whole-tool or per-action) is releasable by the headless worker (isSessionRequiredForRelease, newly exported) rather than stuck needing a live chat session — it passes as-is, since Phase 2's Google/M365 headless dispatch already covers every four_eyes member. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fourEyesTools was built only from the static TIER3_FOUR_EYES_TOOLS/ TIER3_FOUR_EYES_ACTIONS tables, so s1_isolate_device (moved to TIER3_INPUT_AWARE_TOOLS) and manage_organizations (only covered by accident via create_org) were structurally invisible to the durable-executable contract even though they can resolve four_eyes at runtime via resolveApprovalScope's input-aware overrides. Union in TIER3_INPUT_AWARE_TOOLS and the tool-name projection of TIER3_INPUT_AWARE_ACTIONS — a conservative superset is correct since the assertion is about session-requirement, not resolved scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds intentSupervisedFourEyes.integration.test.ts, covering what the existing decide-path integration suites don't: a four_eyes intent fans out to both other eligible admins (never the requester), an approve at t+30min still succeeds and releases inside the new 60-minute four_eyes window (via direct DB timestamp manipulation, not a mocked clock), and a disabled second admin correctly engages the four_eyes sole-operator fallback instead of reclassifying to supervised. approvalsDecideSupervised.integration.test.ts already covers the supervised happy-path + revoked-permission; approvalsDecideAtomicity. integration.test.ts already covers the fan-in fault-injection rollback. Named in vitest.integration.config.ts's include list for discoverability (the file already lives under src/__tests__/integration/, which both that config's glob and vitest.config.ts's wholesale exclude already cover automatically). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…four-eyes split execute_command was reclassified `supervised` by an earlier task in this plan (Task 1, aiGuardrails.ts), which collapses its approval fan-out to exactly one requester-owned row. intentFanout.integration.test.ts and intentSelfApproveGuard.integration.test.ts still seeded intents with execute_command and asserted the pre-split multi-approver/four_eyes sole-operator behavior, so they were either failing outright (wrong row count/owner) or passing for the wrong reason (silently routed through the unrelated supervised decide branch instead of the four_eyes one they exist to cover). Switches every intent-creation call in both files to restore_snapshot (four_eyes-classified), the same fix approvalsDecideAtomicity.integration.test.ts already made for its own fixture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Final whole-branch review turned up three IMPORTANT gaps: 1. The inline chat-session release path (aiAgentSdk.ts) won the approved->executing CAS and revalidated the requester's authorization, but never recomputed the pinned effect digest the way jobs/intentReleaseWorker.ts does — a four_eyes reference-approval whose target drifted during the approval window could execute stale content if it happened to complete inline instead of via the durable worker. Mirror the worker's computeEffectDigest recheck and CAS to failed:content_changed on mismatch. 2. Supervised approves polluted the four_eyes sole-operator audit signal: a supervised intent's sole approval row is always requester-owned, so every ordinary supervised approve was audited as self_approved_sole_operator (an outcome meant to flag four_eyes L3 self-approval specifically). Gate soleOperatorApproval / details.soleOperator on approvalScope === 'four_eyes' in both routes/approvals.ts and services/actionIntents/intentService.ts; supervised approves now audit as approved with details.approvalMethod: 'supervised_self'. 3. The reaper's pending_approval predicate used bare approval_expires_at < now(), which is SQL NULL (never true) for legacy writer rows that never got the column backfilled — those rows could never be reaped even once their legacy expires_at deadline passed. Fall back to COALESCE(approval_expires_at, expires_at), mirroring the approved branch's existing construct. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploying breeze with
|
| Latest commit: |
0d02b3d
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://db0c61b0.breeze-9te.pages.dev |
| Branch Preview URL: | https://toddhebebrand-tier3-supervis.breeze-9te.pages.dev |
Multi-agent review of the tier-3 supervised/four-eyes split surfaced two CI
blockers, four fail-open defects, and a set of missing test contracts.
CI blockers:
- Reserve the new `approvals` route namespace in BOTH hand-maintained mirrors
(extension-sdk/manifest.ts, extension-api/legacy.ts). Without it an extension
could claim the namespace and shadow the approvals API.
- Fix TS2769 in the durable four-eyes contract test under
noUncheckedIndexedAccess.
Authorization / fail-open:
- Supervised approve no longer discards a presented WebAuthn proof. A forged or
replayed proof previously succeeded silently at L1 and audited as
`session_tap`. The sole-operator L3 gate is now conditioned so an optional
proof can only raise recorded assurance, never block a decide that a plain
click would have passed.
- report-suspicious returns 409 `already_decided` (with finalStatus) instead of
a 204 that was indistinguishable from "stopped it" while the flagged action
still executed. The approval_requests flip is now a CAS on status='pending',
so a losing race can no longer overwrite a committed approval.
- GET /approvals/:id and the assertion-challenge endpoint now apply the same
live-authorization filter as /pending. A demoted approver could still read
full action arguments (script bodies, target emails) from the detail route.
TOCTOU / effect digest:
- Pin effect digests regardless of approval scope. The run_script resolver —
the module's own motivating example — was unreachable, since pinning was
gated on four_eyes while run_script is supervised.
- computeEffectDigest returns a three-way EffectDigestOutcome; "resolver
missing an arg" and "target absent" are now audited instead of silently
storing NULL and disabling the check.
- run_script pins every field that changes what executes, not just content.
- Add manage_invoices:void resolver and a resolver-coverage contract test with
an explicit DELIBERATELY_UNPINNED allowlist.
- Share one hasPinnedDigest predicate across both release paths; they
previously diverged on undefined (worker failed closed, SDK failed open).
- Wrap the worker's recompute so a transient fault reports digest_check_failed
instead of stranding the intent for 20 minutes as execution_lost.
Contracts / correctness:
- Enumerate multiplexed tools' real action enums: an action in neither tier-3
table fell through to the whole-tool supervised catch-all rather than the
documented four_eyes fail-safe.
- Single approvalScope union in @breeze/shared, pinned against the migration's
CHECK literals.
- requireNotExpired is phase-typed ('approval' | 'release') and throws when the
phase contradicts the from-status.
- Stamp the release lease unconditionally; backfill release_by for pre-split
approved rows (2026-08-15 migration). Deliberately no NOT NULL / CHECK — both
would reject old-instance writes mid rolling upgrade; rationale recorded in
the migration.
- Extend the action_intents immutability contract test to every deny-listed
column (including the previously-missed origin_principal_* pair) and assert
release_by / approval_expires_at stay mutable.
- Re-point createIntentAtomicity at a four_eyes tool; it had silently lost
multi-approver fan-out coverage.
- Replace unit tests that asserted SQL text or mocked away the behavior in
their own titles with real-DB coverage, incl. a tight deadline boundary and
legacy release_by IS NULL release.
Full API unit suite green (20286), shared 1660, extension sdk/api green;
tsc --noEmit -p apps/api/tsconfig.json clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rewritten contract test covered the new approval_scope / classification_version / effect_digest columns, but its ~28 live-DB cases never executed anywhere in CI: they sat behind describe.runIf(DATABASE_URL), the blocking test-api job has no Postgres, and the file was not in the integration config's include list. The immutability of approval_scope — the property that stops an intent switching approval classification after approvers have acted — was asserted nowhere. Split the suite along the line CI actually draws: - Static DDL/parse assertions stay in src/db/migration-action-intents.test.ts and keep running in the fast, blocking test-api job. - The 28 behavioral cases move to src/__tests__/integration/actionIntentsImmutabilityTrigger.integration.test.ts with per-test fixtures so they are truncation-safe. That directory is matched wholesale by both configs (integration include, unit exclude), so it needs no hand-maintained entry in the list that has gone stale before. - Shared deny-list derivation extracted to testUtils so the two suites cannot drift into vacuity independently. Verified non-vacuous: removing the approval_scope clause from the trigger in a throwaway test DB fails the suite twice over — the behavioral UPDATE case and the live-function-vs-migration drift gate — and restoring returns it to 28/28. Runs in the Integration Tests job, which is blocking on PRs and main (the continue-on-error in ci.yml belongs to smoke-test, not integration-test). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ToddHebebrand
pushed a commit
that referenced
this pull request
Aug 7, 2026
…vers (#3174) (#3183) Fixes #3174. ## The defect `resolveElevationApprovers` (`apps/api/src/services/pamApprovers.ts`) resolves both candidate sets — direct `organization_users` members, and `partner_users` members whose `org_access` covers the org — without ever consulting `users.status`. Memberships are retained when an account is disabled or is still `invited`, so those accounts keep counting as eligible approvers. Both consequences named in the issue follow: the approver set is inflated with push targets that can never respond, and any logic keyed on the approver count is skewed by ghosts that look like real approvers. ## The fix Both candidate queries now `innerJoin(users)` and require `status = 'active'`, which is the same shape `resolveIntentApprovers` uses on the AI side. Nothing else changed — the wildcard-role handling, the partner `org_access` logic and the final `mobile_devices` narrowing are untouched. No new table and no new column, so there is no cascade or export-policy registration to add. ## Test evidence The two new cases assert the join target and the predicate that actually reach drizzle, **not** the ids the mock was primed to return. That distinction matters here: the mock resolves its rows regardless of the `WHERE`, so a test that only checked returned ids would pass just as happily with the gate deleted. **Confirmed against the previous behaviour.** Removing *only* the `eq(users.status, 'active')` predicate from both queries — keeping the join, so the failure is about the gate and not a broken mock chain: | | Result | |---|---| | Status predicate removed | **2 failed / 4 passed** — exactly the two new cases | | Fix in place | **6 passed** | Full runs on the final tree, Node 22.23.2: - `tsc --noEmit -p apps/api/tsconfig.json` → **exit 0**, no output - `apps/api` full unit suite (the `test-api` job) → **1271 files passed / 5 skipped, 20144 tests passed / 61 skipped** - Targeted: `pamApprovers`, `routes/agents/elevationRequests`, `services/actionIntents` → 13 files / 174 tests passed ## Scope note This is the PAM half only. `apps/api/src/services/actionIntents/intentApprovers.ts` is the AI-side twin and is being fixed on the `tier3-supervised-four-eyes` branch (#3175); this branch deliberately does not touch it, so the two do not conflict. https://claude.ai/code/session_01RUup17Z6KMH9jSkhBBhRJ1
Resolves the vitest.integration.config.ts include-list conflict: both sides appended entries, so both are kept. Main added the #3191 enrollment-key expired-filter test; this branch added the three tier-3 supervised/four-eyes integration suites.
This was referenced Aug 7, 2026
ToddHebebrand
added a commit
that referenced
this pull request
Aug 8, 2026
Docs staleness sweep for the v0.102.0..v0.104.0 range (tracked in `scripts/docs-review/last-reviewed.json`). ## Page updates - **features/ai.mdx** — rewrote the Tier 3 approval workflow for the supervised/four-eyes split (#3175): supervised actions are approved by the requesting technician in chat (gated on their own permissions); a fixed high-stakes list (financial, tenant shape, M365/Google identity, restores/rollbacks, computer control, containment release) requires a second approver, now with a 60-minute window and content-change pinning. Sole-operator and `approvals:decide` sections rescoped to four-eyes; disabled/invited users noted as ineligible approvers. - **features/devices.mdx** — WAN IP / LAN IP opt-in device-list columns (#2996). - **features/quotes.mdx** — new "Ordering What You Sold" section: the To-be-ordered procurement breakdown on won quotes, Mark ordered / Mark received tracking, CSV export, vendor cost snapshot, Pax8 badges (#3111). - **features/scripts.mdx** — partner-wide ("All my organizations") scripts, the Available-to picker, and the full-partner-access requirement for partner-wide writes (#3262/#3263); fixed the API section's org-only claim. - **features/edr-integrations.mdx** — chunked resumable package uploads with progress (#3113). - **features/snmp.mdx** — poll due-check runs off attempts; failing devices back off exponentially (scheduler description + troubleshooting entry) (#3223). - **agents/enrollment-keys.mdx** — installer capacity figure and key status derived from live bootstrap tokens (#2993/#3045/#3196). Verified as already self-documented in range (no action): Quick Support (#3153), third-party ring auto-approve (#3150), VSS writer health + partial status (#3005/#3030), EVENT_LOOP_MONITOR_*/DB_POOL_HEALTH_* env vars (#3024/#3224), strict env validation (#2979), custom alert-condition retirement (#2995), enrollment idempotency (#3063), extensions install scoping (#3032). ## Housekeeping - `scripts/docs-review/mapping.json` — added `quickSupport*`, `eventLoop*`, `dbPoolHealth*` patterns; `approvals.ts` now also maps to features/ai.mdx; `partnerWideAccess.ts` now also maps to features/scripts.mdx. - `packages/shared/src/utils/docsMapping.ts` — `/remote/quick-support` → Quick Support section anchor (+ test). `@breeze/shared` tests green (1661 passed). - `apps/api/src/data/docsIndex.json` regenerated (147 docs indexed). - `docs/release-notes/next-release-draft.md` cleared; last release set to v0.104.0 (2026-08-08). - `scripts/docs-review/last-reviewed.json` bumped to v0.104.0. Docs build verified: `astro build` — 150 pages, no errors. **Follow-up (not in this PR):** `docsMapping.ts` maps `/ai-risk` → `/features/user-risk/`, but the AI Risk Engine (approval history, tiers) is documented in `features/ai.mdx` — one of the two should be reconciled. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Todd Hebebrand <todd@lanternops.io> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Splits AI Tier-3 approvals into two scopes (spec:
docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md, in this PR):approvals:decidedecides — financial issuance/payment, tenant-shape changes, M365/Google identity mutations, restore/DR, containment release, computer control. Chat approval window raised 5→60 min for these; the durable release worker executes late approvals under a 10-min release lease.Motivation: for orgs with ≥2 admins and no mobile app (all self-hosters — the app isn't distributable yet), every Tier-3 action fanned out four-eyes with push-only notification and a 5-minute window → guaranteed expiry. Reported by a self-hosted partner 2026-08-05.
Also fixes (found during design review + implementation)
content_changedon drift.approval_expires_at(pending) +release_bylease (approved) — an approval at 59:59 no longer gets reaped before the worker claims it; legacy-row COALESCE fallbacks for rolling upgrades.GET /pending/count; transport-neutral/api/v1/approvalsmount (mobile alias kept, response shape backward-compatible with additivenextCursor).Reviewer callouts
action_intent.self_approved_sole_operatornow fires only for genuine four-eyes sole-operator self-approvals; supervised decides auditapprovalMethod: 'supervised_self'.approvedflag semantics per [API] Decide: should read-only execute_command types auto-execute under the default per_step approval mode? #3130 unchanged.2026-08-14-intent-approval-scope-and-deadlines.sql(forward-dated to sort after existing 2026-08-13; idempotent; extends the immutability trigger). All five new columns registered in the export policy.MCP_APPROVAL_REQUIRED).approvalScope; the in-chat supervised card is actionable today via the existing sole-operator card path).request_elevationare four_eyes pending explicit demotion decisions.Testing
Full unit suite (20k+ tests), RLS suite, integration suite (incl. new
intentSupervisedFourEyes,approvalsDecideSupervised, inverted atomicity test, reaper legacy-row cases) green locally;db:check-driftclean. Contract tests: classification exhaustiveness (every effective-tier-3 surface explicitly scoped), durable-executable (four-eyes ⊆ headless-executable, input-aware tools included), export policy.🤖 Generated with Claude Code