LLD 14 — Approval Gate Integration for delegate_to_agent
| Field | Value |
|---|---|
| Status | Draft — awaiting architect/founder approval |
| Version | 1.0 |
| Date | 2026-04-13 |
| Parent HLD | #430 |
| PRD | #380 (P4.8.1) |
| Milestone | 9 |
| Size | M |
| Depends on | LLD 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:
- Policy-resolver seed entries mapping
action=subagent_invocation→requires_approvalfor the defaultcritical-pathtemplate and high-clearance roles. - New
PauseSourcevariantPAUSE_SOURCE_DELEGATION_APPROVAL(shipped schema-wise in LLD 10; this LLD wires the state transitions). - Approval request envelope for delegation —
ApprovalRequest.tool = 'delegate_to_agent',args_sha256 = sha256(to_role || task || context_refs_sorted),risk_level = 'high'. - Approval-decision handler — on
approve, post-approval kick callsCoordinator.Invoke(LLD 12). Ondeny, parent resumes with error-shaped tool_result. - Idempotency across approval retries — the same tool_call_id + args_sha256 never approves twice.
- Audit chain —
approval_requested→approval_approved/approval_denied→subagent_invoked(orsubagent_invocation_failed) all thread via Wave 1's ChainTracker usingparent_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
| Key | Type | TTL | Purpose |
|---|---|---|---|
upsquad:subagent:approval:pending:{approvalRequestID} | hash | approval_timeout | Fast lookup of the snapshotted EnqueueApprovalRequest for post-approval Invoke |
upsquad:subagent:approval:processed:{approvalRequestID} | string NX | 7 days | Idempotency 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_sha256is stable; two approval requests with identical canonicalized args hash to the same value. - I-2:
Redis processed:{id}+ DBapproval_requests.status != 'pending'guarantee no double-spawn. - I-3: Approved path MUST call
ReplacePauseSourcebeforeCoordinator.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
| Test | File | Asserts |
|---|---|---|
TestComputeArgsSHA256_Stable | args_hash_test.go | same inputs → same hash across runs |
TestComputeArgsSHA256_SortsContextRefs | ″ | reorder refs → same hash |
TestComputeArgsSHA256_NormalizesUnicode | ″ | NFKC variants collapse |
TestResolveSubagent_DefaultTemplate_Allow | subagent_resolver_test.go | default → allow |
TestResolveSubagent_CriticalPathTemplate_RequiresApproval | ″ | critical-path → requires_approval with channels/timeout |
TestResolveSubagent_AdminRolePattern_RequiresApproval | ″ | admin_* override applies |
TestResolveSubagent_TenantOverride_Wins | ″ | org-level rule precedes template |
TestEnqueueApproval_WritesAudit_BeforePause | approval_test.go | audit row written first for hash-chain order |
TestEnqueueApproval_SnapshotInRedis_Complete | ″ | all request fields recoverable |
TestApproved_ReplacePauseSource_ThenInvoke | ″ | pause row updated, then coord.Invoke |
TestApproved_IdempotentOnReplay | ″ | second Approved call is no-op |
TestApproved_CoordInvokeFailure_ResumesWithError | ″ | quota violation post-approval → error envelope |
TestDenied_ResumeWithErrorEnvelope | ″ | status=error, error_code=approval_denied |
TestDenied_TimeoutReason_Propagates | ″ | error_message includes timeout reason |
7.2 Integration tests
| Test | File | Asserts |
|---|---|---|
TestE2E_Delegation_RequiresApproval_ApprovedFlow | approval_integration_test.go | governance requires_approval → parent paused → dashboard approve → child spawned → result delivered (LLD 13) |
TestE2E_Delegation_RequiresApproval_DeniedFlow | ″ | deny → parent resumed with error(approval_denied), no child spawned |
TestE2E_Delegation_ApprovalTimeout_DeniesAutomatically | ″ | 4 h timeout → scheduler denies → parent resumed with error(approval_timeout) |
TestE2E_Delegation_ApprovalReplay_IdempotentSpawn | ″ | approve twice (retry) → child spawned once |
TestE2E_Delegation_PauseSourceTransition_LLD13_PathA | ″ | approved → pause_source=delegation → result delivered via Path A, not rendezvous |
TestE2E_Delegation_HighClearanceRole_TriggersApproval_WithoutExplicitPolicy | ″ | inherent_clearance=5 role, no tenant rule → critical-path template kicks in |
TestE2E_Delegation_SeedTemplate_AppliedToNewOrg | ″ | new org auto-inherits critical-path + admin_* rules |
TestE2E_Delegation_AuditChain_ApprovalSpansCrossSession | ″ | chain from approval_requested → approved → subagent_invoked → child actions all verified via cross-session walk (LLD 13) |
8. Rollout / feature flag
- Shares
subagent_invocation_enabledflag (LLD 11). - Tenants default to the
defaulttemplate (allow) — approval gate is only active when tenant selectscritical-pathOR sets specific high-clearance roles. - Platform override: architect can force
requires_approvalon any role viaplatform_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.Invokefails 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
| LLD | Direction |
|---|---|
| LLD 10 | hard dep — PAUSE_SOURCE_DELEGATION_APPROVAL enum |
| LLD 11 | hard dep — tool handler routes here when governance returns requires_approval |
| LLD 12 | hard dep — post-approval Coordinator.Invoke call |
| LLD 13 | hard 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 |