Skip to main content

LLD 14 — Approval Gate Integration for delegate_to_agent

FieldValue
StatusDraft — awaiting architect/founder approval
Version1.0
Date2026-04-13
Parent HLD#430
PRD#380 (P4.8.1)
Milestone9
SizeM
Depends onLLD 10 (schema), LLD 11 (tool handler), LLD 12 (coordinator), Wave 2 LLD 1 (approval engine), Wave 2 LLD 2 (PauseManager)

1. Scope

Per founder decision #7, delegate_to_agent is approval-gateable — when governance policy returns requires_approval for a delegation call, the parent enters an approval-pause, a human approves, and only then is the child spawned. This LLD wires that end-to-end.

In scope:

  1. Policy-resolver seed entries mapping action=subagent_invocationrequires_approval for the default critical-path template and high-clearance roles.
  2. New PauseSource variant PAUSE_SOURCE_DELEGATION_APPROVAL (shipped schema-wise in LLD 10; this LLD wires the state transitions).
  3. Approval request envelope for delegation — ApprovalRequest.tool = 'delegate_to_agent', args_sha256 = sha256(to_role || task || context_refs_sorted), risk_level = 'high'.
  4. Approval-decision handler — on approve, post-approval kick calls Coordinator.Invoke (LLD 12). On deny, parent resumes with error-shaped tool_result.
  5. Idempotency across approval retries — the same tool_call_id + args_sha256 never approves twice.
  6. Audit chain — approval_requestedapproval_approved/approval_deniedsubagent_invoked (or subagent_invocation_failed) all thread via Wave 1's ChainTracker using parent_action_id.

Out of scope — approval channel adapters (dashboard/email — Wave 2 LLD 3), post-invocation lifecycle (LLD 12), result delivery (LLD 13).


2. Schema / migration SQL

2.1 Extend approval_requests payload discriminator

-- Migration 047_subagent_approval_request_type.up.sql
-- Add subagent_invocation as a valid action_type on existing approval_requests.
ALTER TABLE approval_requests
DROP CONSTRAINT IF EXISTS approval_requests_action_type_check;
ALTER TABLE approval_requests
ADD CONSTRAINT approval_requests_action_type_check
CHECK (action_type IN ('tool_call','delegation_transfer','subagent_invocation'));

-- Optional: typed payload columns so queries can filter by target role
-- without parsing JSONB. Nullable — only populated for subagent_invocation.
ALTER TABLE approval_requests
ADD COLUMN IF NOT EXISTS subagent_target_role TEXT,
ADD COLUMN IF NOT EXISTS subagent_target_agent_id UUID,
ADD COLUMN IF NOT EXISTS subagent_args_sha256 BYTEA;

CREATE INDEX IF NOT EXISTS idx_approval_requests_subagent_role
ON approval_requests(org_id, subagent_target_role)
WHERE subagent_target_role IS NOT NULL;

2.2 Seed policy entries

-- seeds/governance/subagent_approval_defaults.sql
-- Template: "critical-path" requires approval for any delegation to role with
-- inherent_clearance >= 4. Tenants opt in by selecting this template in
-- tenant_security_config.governance_template.
INSERT INTO governance_policy_templates (template_id, action, resource_pattern, decision, metadata)
VALUES
('critical-path', 'subagent_invocation', 'agent_role:*',
'requires_approval',
'{"channels":["dashboard","email"],"timeout_hours":24,"reason":"high_clearance_delegation"}'::jsonb),
('default', 'subagent_invocation', 'agent_role:*', 'allow', '{}'::jsonb)
ON CONFLICT DO NOTHING;

-- Tenant-specific high-risk role override:
-- Any delegation to role starting with 'admin_' always requires approval.
INSERT INTO governance_policy_templates (template_id, action, resource_pattern, decision, metadata)
VALUES
('default', 'subagent_invocation', 'agent_role:admin_*',
'requires_approval',
'{"channels":["dashboard"],"timeout_hours":4}'::jsonb)
ON CONFLICT DO NOTHING;

3. Go interfaces

3.1 Approval orchestration

// internal/runtime/subagent/approval.go
package subagent

// ApprovalOrchestrator handles the flow:
//
// governance.Check returns requires_approval
// ↓
// EnqueueApproval
// - writes approval_requests row (action_type=subagent_invocation)
// - writes parent audit row action=subagent_approval_requested
// - pauseMgr.RequestPause(parent, PAUSE_SOURCE_DELEGATION_APPROVAL)
// ↓
// (operator approves or denies via Wave 2 approval channels)
// ↓
// Approved(approvalID) / Denied(approvalID, reason)
// - Approved: coord.Invoke (LLD 12) with the ORIGINAL snapshotted request
// (role was snapshotted at enqueue time — role catalog
// edits during pending approval are ignored)
// - Denied: pauseMgr.ResumePaused(parent, operator_input=error envelope)
type ApprovalOrchestrator struct {
db DB
approvalSvc ApprovalService // Wave 2 LLD 1
coord Invoker // LLD 12
pauseMgr PauseManager // Wave 2 LLD 2
resolver RoleResolver // LLD 11
marshaler Marshaler // LLD 13
clock clock.Clock
audit audit.Writer
metrics Metrics
log logr.Logger
}

// EnqueueApproval is called by the tool handler (LLD 11) when governance
// returns requires_approval. Captures a snapshot of the invocation request
// so subsequent role catalog changes don't mutate the pending spawn.
func (o *ApprovalOrchestrator) EnqueueApproval(ctx context.Context, req EnqueueApprovalRequest) (EnqueueApprovalResponse, error)

type EnqueueApprovalRequest struct {
OrgID string
ParentSessionID string
ParentActionID string
ParentToolCallID string
TargetRole string
ResolvedAgentID string
ParentClearance int
RoleInherentClearance int
TenantCeiling *int
Task string
StructuredInput []byte
ContextRefs []string
TimeoutSeconds int
Channels []string // from policy metadata
ApprovalTimeoutHours int // from policy metadata
}

type EnqueueApprovalResponse struct {
ApprovalRequestID string
ArgsSHA256 []byte
}

// Approved is invoked by the approval engine when a pending delegation is
// approved. Idempotent on approvalRequestID.
func (o *ApprovalOrchestrator) Approved(ctx context.Context, approvalRequestID string) error

// Denied is invoked by the approval engine on denial or timeout expiry.
func (o *ApprovalOrchestrator) Denied(ctx context.Context, approvalRequestID, reason string) error

3.2 Args hashing

// args_hash.go

// ComputeArgsSHA256 is deterministic over the tuple (target_role,
// canonical_task, sorted_context_refs). Used as an idempotency anchor for
// approval replays — two identical approval requests with same args hash
// the same, so a second approve is a no-op.
//
// Canonicalization:
// - target_role: lowercased, trimmed
// - task: NFKC-normalized, trimmed, limited to first 16384 chars
// - context_refs: UUIDs lowercased, sorted lexicographically, deduplicated
func ComputeArgsSHA256(targetRole, task string, contextRefs []string) []byte

3.3 Approval-channel payload shape

Extends Wave 2 LLD 3's ChannelPayload:

// channel_payload.go
type SubagentApprovalPayload struct {
ApprovalRequestID string
ParentSessionID string
ParentAgent AgentSummary
TargetRole string
TargetAgentID string
RequestedByAgent AgentSummary
TaskPreview string // first 500 chars
ContextRefCount int
ParentClearance int
ChildClearance int // already-clamped preview
Deadline time.Time
ApprovalURL string // dashboard deep link
}

Dashboard and email adapters (Wave 2) render this payload.

3.4 Policy resolver extension

LLD 11 already extended the resolver for action=subagent_invocation. This LLD adds:

// internal/governance/policy/subagent_resolver.go

// ResolveSubagent combines:
// 1. Tenant policy rows (org_governance_policies) for action=subagent_invocation
// 2. Template rules (governance_policy_templates) selected by tenant config
// 3. High-clearance role override (always-requires-approval for
// inherent_clearance >= tenant.subagent_approval_clearance_threshold)
//
// Returns the EFFECTIVE decision plus channel/timeout metadata for
// requires_approval outcomes.
func (r *Resolver) ResolveSubagent(ctx context.Context, orgID, toRole string, roleMeta ResolvedRole) (SubagentPolicyDecision, error)

type SubagentPolicyDecision struct {
Decision Decision // allow | requires_approval | deny
Channels []string
ApprovalTimeoutHours int
Reason string // for audit
}

4. Proto changes

// proto/upsquad/approval/v1/approval.proto (additive)

// ApprovalRequest.action_type already exists; add value:
enum ApprovalActionType {
APPROVAL_ACTION_TYPE_UNSPECIFIED = 0;
APPROVAL_ACTION_TYPE_TOOL_CALL = 1;
APPROVAL_ACTION_TYPE_DELEGATION_TRANSFER = 2; // Wave 2
APPROVAL_ACTION_TYPE_SUBAGENT_INVOCATION = 3; // NEW
}

message SubagentApprovalDetail {
string target_role = 1;
string target_agent_id = 2;
bytes args_sha256 = 3;
int32 parent_clearance = 4;
int32 child_clearance_preview = 5; // post-clamp preview
string task_preview = 6;
int32 context_ref_count = 7;
}

// Added to existing ApprovalRequest oneof payload:
message ApprovalRequest {
// ... existing ...
oneof payload {
ToolCallDetail tool_call = 10;
DelegationTransferDetail delegation_transfer = 11;
SubagentApprovalDetail subagent_invocation = 12; // NEW
}
}

5. Redis key schemas

KeyTypeTTLPurpose
upsquad:subagent:approval:pending:{approvalRequestID}hashapproval_timeoutFast lookup of the snapshotted EnqueueApprovalRequest for post-approval Invoke
upsquad:subagent:approval:processed:{approvalRequestID}string NX7 daysIdempotency guard — prevents double-spawn on channel retry

Hash contents: all fields of EnqueueApprovalRequest except StructuredInput (stored on the approval row in DB to keep Redis small).


6. State machines + invariants

6.1 Delegation approval flow

[tool handler, LLD 11]
governance.ResolveSubagent → requires_approval


ApprovalOrchestrator.EnqueueApproval
├── audit: subagent_approval_requested
├── approval_requests INSERT (action_type=subagent_invocation, ...)
├── Redis: SET pending:{id} = snapshot
└── pauseMgr.RequestPause(parent, PAUSE_SOURCE_DELEGATION_APPROVAL)

▼ (operator side, Wave 2 channels)
approve/deny/timeout

┌─────┴─────┬─────────────┐
▼ ▼ ▼
Approved Denied Timeout
│ │ │
│ └─── both ──► pauseMgr.ResumePaused(parent, envelope=error)
│ audit: subagent_approval_denied


ApprovalOrchestrator.Approved
├── Redis: SETNX processed:{id} (idempotency)
├── load snapshot from pending:{id}
├── Coordinator.Invoke (LLD 12) ← this kick-off path only
│ ├── success → child spawned, parent already paused with
│ │ DELEGATION_APPROVAL; must transition pause_source
│ │ to 'delegation' (same row, different source) so
│ │ LLD 13's delivery routes via Path A
│ └── failure (quota, cycle, etc.) → resume parent with error envelope
└── audit: subagent_approval_approved

6.2 Pause-source transition on approval

Critical subtlety: between EnqueueApproval (parent paused with DELEGATION_APPROVAL) and Approved → Invoke (coordinator expects to pause parent with DELEGATION), the parent must transition pause source without an unpause-repause cycle.

Implementation: ApprovalOrchestrator.Approved calls pauseMgr.ReplacePauseSource(parentSessionID, from=DELEGATION_APPROVAL, to=DELEGATION) — a new Wave 2 PauseManager entry point added in this LLD (trivial one-field UPDATE inside the existing session_pauses row).

// Addition to Wave 2 PauseManager surface:
func (p *PauseManager) ReplacePauseSource(ctx context.Context, sessionID string, from, to PauseSource) error

6.3 Invariants

  • I-1: args_sha256 is stable; two approval requests with identical canonicalized args hash to the same value.
  • I-2: Redis processed:{id} + DB approval_requests.status != 'pending' guarantee no double-spawn.
  • I-3: Approved path MUST call ReplacePauseSource before Coordinator.Invoke, else delivery (LLD 13) routes to Path B incorrectly.
  • I-4: Denied path never calls Coordinator.Invoke — parent resumes with error envelope.
  • I-5: Approval timeout (channel-level) triggers Denied(approvalID, reason="approval_timeout") via existing Wave 2 scheduler.
  • I-6: Snapshot in Redis + approval_requests.request_payload (DB) are two copies — DB is authoritative; Redis is a hot-cache optimization and can be rebuilt from DB on miss.

7. Unit + integration test plan

7.1 Unit tests

TestFileAsserts
TestComputeArgsSHA256_Stableargs_hash_test.gosame inputs → same hash across runs
TestComputeArgsSHA256_SortsContextRefsreorder refs → same hash
TestComputeArgsSHA256_NormalizesUnicodeNFKC variants collapse
TestResolveSubagent_DefaultTemplate_Allowsubagent_resolver_test.godefault → allow
TestResolveSubagent_CriticalPathTemplate_RequiresApprovalcritical-path → requires_approval with channels/timeout
TestResolveSubagent_AdminRolePattern_RequiresApprovaladmin_* override applies
TestResolveSubagent_TenantOverride_Winsorg-level rule precedes template
TestEnqueueApproval_WritesAudit_BeforePauseapproval_test.goaudit row written first for hash-chain order
TestEnqueueApproval_SnapshotInRedis_Completeall request fields recoverable
TestApproved_ReplacePauseSource_ThenInvokepause row updated, then coord.Invoke
TestApproved_IdempotentOnReplaysecond Approved call is no-op
TestApproved_CoordInvokeFailure_ResumesWithErrorquota violation post-approval → error envelope
TestDenied_ResumeWithErrorEnvelopestatus=error, error_code=approval_denied
TestDenied_TimeoutReason_Propagateserror_message includes timeout reason

7.2 Integration tests

TestFileAsserts
TestE2E_Delegation_RequiresApproval_ApprovedFlowapproval_integration_test.gogovernance requires_approval → parent paused → dashboard approve → child spawned → result delivered (LLD 13)
TestE2E_Delegation_RequiresApproval_DeniedFlowdeny → parent resumed with error(approval_denied), no child spawned
TestE2E_Delegation_ApprovalTimeout_DeniesAutomatically4 h timeout → scheduler denies → parent resumed with error(approval_timeout)
TestE2E_Delegation_ApprovalReplay_IdempotentSpawnapprove twice (retry) → child spawned once
TestE2E_Delegation_PauseSourceTransition_LLD13_PathAapproved → pause_source=delegation → result delivered via Path A, not rendezvous
TestE2E_Delegation_HighClearanceRole_TriggersApproval_WithoutExplicitPolicyinherent_clearance=5 role, no tenant rule → critical-path template kicks in
TestE2E_Delegation_SeedTemplate_AppliedToNewOrgnew org auto-inherits critical-path + admin_* rules
TestE2E_Delegation_AuditChain_ApprovalSpansCrossSessionchain from approval_requested → approved → subagent_invoked → child actions all verified via cross-session walk (LLD 13)

8. Rollout / feature flag

  • Shares subagent_invocation_enabled flag (LLD 11).
  • Tenants default to the default template (allow) — approval gate is only active when tenant selects critical-path OR sets specific high-clearance roles.
  • Platform override: architect can force requires_approval on any role via platform_policy_overrides (existing mechanism).
  • Rollback: drop tenant's approval rules; in-flight approval requests drain via Wave 2 scheduler (approved → spawn, denied → error).

9. Known edge cases + non-goals

Edge cases:

  • Role disabled during pending approval → on Approved, Coordinator.Invoke fails at role re-resolution (LLD 12's guard). Parent resumed with error(role_disabled_post_approval).
  • Quota violated between approval and Invoke → same failure path; parent observes quota error.
  • Parent terminated mid-approval → approval auto-cancels via Wave 2's pause-lifecycle hook (approval_requests row marked cancelled, no invoke).
  • Approver is same user as parent-agent's owner → permitted; Wave 2's approval conflict-of-interest rules apply unchanged.
  • Two different parents request delegation to same role simultaneously → independent approvals, independent approval IDs; no cross-talk.

Non-goals:

  • No N-of-M approval for delegation (consensus deferred per PRD P4.3.10).
  • No self-approval for delegation.
  • No approval delegation (handing approval authority to another human) for subagent_invocation in Wave 3 — Wave 2's human-to-human delegation primitive already supports it if tenants wire it, but this LLD does not add specific UX/docs for it.
  • No partial approval (approve role change but not task) — single atomic decision.

10. Estimated size

M — reuses most of Wave 2's approval engine; net new code is the orchestrator, args hashing, policy resolver extension, pause-source transition helper, and seed templates. Integration tests are the bulk of the work.


11. Dependencies on other LLDs

LLDDirection
LLD 10hard dep — PAUSE_SOURCE_DELEGATION_APPROVAL enum
LLD 11hard dep — tool handler routes here when governance returns requires_approval
LLD 12hard dep — post-approval Coordinator.Invoke call
LLD 13hard dep — pause-source replacement ensures Path A delivery
Wave 2 LLD 1 (approval engine)hard dep — reuses core flow
Wave 2 LLD 2 (PauseManager)extends — adds ReplacePauseSource
Wave 2 LLD 3 (channel adapters)consumes — dashboard/email render SubagentApprovalPayload
Wave 2 LLD 6 (scheduler)hard dep — approval timeout sweeper reused