Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1b68974
docs: spec for Tier-3 supervised/four-eyes split + web approvals inbox
Aug 6, 2026
053ab69
docs: backend implementation plan for tier-3 supervised/four-eyes split
Aug 6, 2026
7bdf059
feat(ai): classify tier-3 tools into supervised vs four-eyes approval…
Aug 6, 2026
c4fceae
feat(ai): action_intents approval scope, split deadlines, effect dige…
Aug 6, 2026
504171b
fix(ai): exclude non-active users from intent approver fan-out
Aug 6, 2026
3e3b50a
feat(ai): scope-aware intent creation — supervised self fan-out, spli…
Aug 6, 2026
aaa5f09
refactor(ai): dedupe single-approver insert in intentService fan-out
Aug 6, 2026
093f8cc
fix(ai): split intent expiry into approval deadline + release lease
Aug 6, 2026
0386795
feat(ai): supervised plain-decide branch + atomic decide transaction
Aug 6, 2026
7b6bfd0
fix(ai): address review round 1 — lock order, tenant guard, atomicity…
Aug 6, 2026
92e17ad
feat(ai): pin four-eyes intent effect digests; fail release on drift
Aug 6, 2026
af62f31
feat(api): live-authorized paginated approvals list + count; transpor…
Aug 6, 2026
8aac906
feat(ai): supervised chat bridge, update_org status escalation, durab…
Aug 6, 2026
9ae3626
fix(ai): widen durable four-eyes contract to include input-aware tools
Aug 6, 2026
8a17684
test(ai): supervised/four-eyes end-to-end integration coverage
Aug 6, 2026
1dce5f2
fix(ai): update intentFanout/intentSelfApproveGuard fixtures for the …
Aug 6, 2026
ae5acdf
fix(ai-mcp): close three tier3-supervised-four-eyes review findings
Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
-- Spec docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md
-- §4.1 / §9.1 — tier-3 supervised/four_eyes intent classification split.
--
-- Adds five columns to action_intents:
-- * approval_scope, classification_version: immutable classification
-- content, decided once at createIntent time from checkGuardrails'
-- approvalScope. Live pre-migration rows backfill via DEFAULT to
-- 'four_eyes'/0 (spec §9.1: "Live pre-migration intents backfill as
-- four_eyes / version 0").
-- * effect_digest: immutable content-pinning hash for four_eyes intents
-- (script content hash / quote-invoice revision / target state-version,
-- pinned at creation; the release worker revalidates it and fails the
-- release with content_changed on drift). Supervised intents leave it
-- NULL (they skip pinning per spec).
-- * approval_expires_at: the pending-approval deadline, split out of the
-- single expires_at column (advisor-confirmed trap: a single expires_at
-- could reap an intent approved at 59:59 before the release worker
-- claims it). Lifecycle column — set at creation by application code
-- going forward; backfilled here from the legacy expires_at for rows
-- that predate the split.
-- * release_by: the execution lease deadline, stamped atomically by the
-- decide-path when an approval wins (Task 5). Lifecycle column.
--
-- approval_scope/classification_version/effect_digest are added to the
-- action_intents_immutable_trg deny-list (extending the function created in
-- 2026-07-18-action-intents.sql and already extended once in
-- 2026-08-06-e-action-intents-origin-principal.sql — CREATE OR REPLACE on
-- the existing function, no DROP/CREATE TRIGGER needed since the trigger
-- itself is unchanged, only the function body it points to). Existing
-- content columns are otherwise unchanged. approval_expires_at/release_by
-- are lifecycle columns and are deliberately NOT added to the deny-list —
-- release_by must remain writable when the decide-path stamps it, matching
-- the execution_started_at precedent from 2026-07-19.
--
-- Idempotent throughout: ADD COLUMN IF NOT EXISTS, DO-guarded constraint add,
-- CREATE OR REPLACE FUNCTION. autoMigrate wraps this file in one transaction
-- — no inner BEGIN/COMMIT.

ALTER TABLE action_intents ADD COLUMN IF NOT EXISTS approval_scope text NOT NULL DEFAULT 'four_eyes';
ALTER TABLE action_intents ADD COLUMN IF NOT EXISTS classification_version integer NOT NULL DEFAULT 0;
ALTER TABLE action_intents ADD COLUMN IF NOT EXISTS approval_expires_at timestamptz;
ALTER TABLE action_intents ADD COLUMN IF NOT EXISTS release_by timestamptz;
ALTER TABLE action_intents ADD COLUMN IF NOT EXISTS effect_digest char(64);

DO $$ BEGIN
ALTER TABLE action_intents ADD CONSTRAINT action_intents_approval_scope_chk
CHECK (approval_scope IN ('supervised','four_eyes'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

-- Backfill: pre-split rows are legacy four-eyes (spec §9.1); their approval
-- deadline is the old single deadline. approval_scope/classification_version
-- already land on these rows via the DEFAULTs above.
UPDATE action_intents SET approval_expires_at = expires_at
WHERE approval_expires_at IS NULL;

-- Extend the immutability trigger's content deny-list: approval_scope,
-- classification_version, and effect_digest are decided once at creation and
-- must never be edited afterward (an editable approval_scope would let an
-- intent switch classification after approvers have already acted on the
-- original scope). release_by and approval_expires_at are intentionally
-- excluded — see header.
CREATE OR REPLACE FUNCTION action_intents_block_content_update()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.org_id IS DISTINCT FROM OLD.org_id
OR NEW.requested_by_user_id IS DISTINCT FROM OLD.requested_by_user_id
OR NEW.requesting_api_key_id IS DISTINCT FROM OLD.requesting_api_key_id
OR NEW.source IS DISTINCT FROM OLD.source
OR NEW.origin_principal_kind IS DISTINCT FROM OLD.origin_principal_kind
OR NEW.origin_principal_id IS DISTINCT FROM OLD.origin_principal_id
OR NEW.action_name IS DISTINCT FROM OLD.action_name
OR NEW.action_version IS DISTINCT FROM OLD.action_version
OR NEW.arguments IS DISTINCT FROM OLD.arguments
OR NEW.argument_digest IS DISTINCT FROM OLD.argument_digest
OR NEW.target_summary IS DISTINCT FROM OLD.target_summary
OR NEW.impact_summary IS DISTINCT FROM OLD.impact_summary
OR NEW.reason IS DISTINCT FROM OLD.reason
OR NEW.risk_tier IS DISTINCT FROM OLD.risk_tier
OR NEW.connection_id IS DISTINCT FROM OLD.connection_id
OR NEW.tenant_id IS DISTINCT FROM OLD.tenant_id
OR NEW.idempotency_key IS DISTINCT FROM OLD.idempotency_key
OR NEW.correlation_id IS DISTINCT FROM OLD.correlation_id
OR NEW.created_at IS DISTINCT FROM OLD.created_at
OR NEW.expires_at IS DISTINCT FROM OLD.expires_at
OR NEW.approval_scope IS DISTINCT FROM OLD.approval_scope
OR NEW.classification_version IS DISTINCT FROM OLD.classification_version
OR NEW.effect_digest IS DISTINCT FROM OLD.effect_digest THEN
RAISE EXCEPTION 'action_intents content is immutable';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
37 changes: 27 additions & 10 deletions apps/api/src/__tests__/integration/intentFanout.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,13 +216,18 @@ describe('createActionIntent — approver fan-out across org+partner axes (real
const s = seeded!;
const auth = requesterAuth(s.requester, s.orgId, s.partnerId, s.requesterRoleId);

// execute_command is a base Tier-3 tool (registerScriptTools,
// aiToolsScripts.ts) — no `action` field needed to hit TIER3_ACTIONS,
// and createActionIntent never verifies the device exists (that happens
// later, at release/execution time), so a bare random UUID is fine here.
// restore_snapshot is a base Tier-3 tool classified whole-tool
// `four_eyes` (TIER3_FOUR_EYES_TOOLS, aiGuardrails.ts) — required for
// this fixture's two-approver fan-out. execute_command was used here
// pre-tier3-supervised-four-eyes-split, but that split classifies it
// `supervised`, which fans out to exactly ONE (requester-owned) row and
// silently broke this test's `toHaveLength(2)` assertion below — the
// same fix `approvalsDecideAtomicity.integration.test.ts` made for its
// own fixture. createActionIntent never verifies the snapshot/device
// exist (that happens at release time), so bare random UUIDs are fine.
const snapshot = await createActionIntent(auth, {
toolName: 'execute_command',
input: { deviceId: randomUUID(), commandType: 'kill_process' },
toolName: 'restore_snapshot',
input: { snapshotId: randomUUID(), deviceId: randomUUID() },
source: 'chat',
});

Expand Down Expand Up @@ -268,9 +273,16 @@ describe('createActionIntent — approver fan-out across org+partner axes (real
const s = seededSolo!;
const auth = requesterAuth(s.requester, s.orgId, s.partnerId, s.requesterRoleId);

// restore_snapshot (four_eyes) so this actually exercises the four_eyes
// SOLE-OPERATOR branch (`intentService.ts`'s `else if (requesterEligible)`)
// this test is documented to cover. execute_command is `supervised`
// post-split, whose unconditional single-row short-circuit fires BEFORE
// the eligible-approver branch regardless of eligibility — leaving this
// test green for the wrong reason (never reaching the sole-operator
// branch at all) if left unchanged.
const snapshot = await createActionIntent(auth, {
toolName: 'execute_command',
input: { deviceId: randomUUID(), commandType: 'kill_process' },
toolName: 'restore_snapshot',
input: { snapshotId: randomUUID(), deviceId: randomUUID() },
source: 'chat',
});

Expand All @@ -297,9 +309,14 @@ describe('createActionIntent — approver fan-out across org+partner axes (real
it('creates a NEW intent for an identical duplicate request once the prior intent has terminalized (partial idempotency index)', async () => {
const s = seeded!;
const auth = requesterAuth(s.requester, s.orgId, s.partnerId, s.requesterRoleId);
// restore_snapshot (four_eyes) — this test's assertions ride on the
// seeded scenario's two-approver fan-out (both on the first AND the
// re-derived second creation), which execute_command's post-split
// `supervised` classification no longer produces. See the top test's
// comment for the full rationale.
const input = {
toolName: 'execute_command',
input: { deviceId: randomUUID(), commandType: 'kill_process' },
toolName: 'restore_snapshot',
input: { snapshotId: randomUUID(), deviceId: randomUUID() },
source: 'chat' as const,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,15 +161,31 @@ async function seedScenario(opts: { withSecondApprover: boolean }): Promise<Scen
};
}

/** Creates a Tier-3 intent as the requester. `execute_command` is a base
* Tier-3 tool and createActionIntent never verifies the device exists (that
* happens at release time), so a bare random UUID is fine (mirrors
* intentFanout). */
/**
* Creates a Tier-3 intent as the requester. `restore_snapshot` is a base
* Tier-3 tool classified whole-tool `four_eyes` (TIER3_FOUR_EYES_TOOLS,
* aiGuardrails.ts) — required here, not optional: this whole file's guard
* (#2685's `not_sole_approver` re-derivation) lives ONLY in the decide
* route's four_eyes branch (`routes/approvals.ts`), never in the supervised
* self-decide branch. `execute_command` was used here pre-
* tier3-supervised-four-eyes-split, but that split classifies it
* `supervised`, which routes every one of this file's three tests through
* the WRONG decide-time branch entirely — the multi-approver fan-out
* collapses to a single requester-owned row (breaking the sanity check in
* "refuses a self-approve..."), and the sole-operator self-approve no longer
* reaches the L3 step-up gate (it hits the supervised branch's tool-RBAC
* recheck instead, which 403s `forbidden` since this scenario's role only
* holds `approvals:decide`, not the tool's RBAC permission). All three tests
* in this file are about the four_eyes guard specifically (see the file
* header), so the shared helper switches for all of them, not per-test.
* createActionIntent never verifies the snapshot/device exist (that happens
* at release time), so bare random UUIDs are fine (mirrors intentFanout).
*/
async function createIntent(s: Scenario) {
const auth = requesterAuth(s.requester, s.orgId, s.partnerId, s.orgRoleId);
return createActionIntent(auth, {
toolName: 'execute_command',
input: { deviceId: randomUUID(), commandType: 'kill_process' },
toolName: 'restore_snapshot',
input: { snapshotId: randomUUID(), deviceId: randomUUID() },
source: 'chat',
});
}
Expand Down
Loading
Loading