diff --git a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java
index 839143ca..fe40501d 100644
--- a/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java
+++ b/mateclaw-server/src/main/java/vip/mate/approval/ApprovalWorkflowService.java
@@ -20,6 +20,7 @@ import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.agent.context.ChatOriginHolder;
+import vip.mate.approval.event.ApprovalResolutionEvent;
import vip.mate.approval.event.WorkflowApprovalResolvedEvent;
import vip.mate.approval.model.ToolApprovalEntity;
import vip.mate.approval.repository.ToolApprovalMapper;
@@ -707,6 +708,41 @@ public class ApprovalWorkflowService implements ApplicationRunner {
}
}
+ // Phase 5 — generic resolution event so the auto-grant resolution log
+ // can record this final decision. Distinct from the workflow-bridge
+ // event above: this fires for EVERY resolved approval (not just wf-*),
+ // and its consumer writes one mate_approval_resolution_log row.
+ // SUPERSEDED is not a user decision — the replacement pending will fire
+ // its own event when it resolves, so we skip the event here.
+ if (events != null && !"SUPERSEDED".equals(dbStatus)) {
+ String decisionSource = "TIMEOUT".equals(dbStatus)
+ ? "TIMEOUT"
+ : "USER_MANUAL";
+ String note = "USER_MANUAL".equals(decisionSource) && "DENIED".equals(dbStatus)
+ ? "denied"
+ : null;
+ ApprovalResolutionEvent resolutionEvent = new ApprovalResolutionEvent(
+ snapshot.getPendingId(),
+ snapshot.getConversationId(),
+ snapshot.getAgentId(),
+ /* userId resolves to actor or original requester */
+ userId != null ? userId : snapshot.getUserId(),
+ snapshot.getToolName(),
+ snapshot.getToolArguments(),
+ snapshot.getMaxSeverity(),
+ snapshot.getFindingsJson(),
+ decisionSource,
+ note);
+ afterCommit(() -> {
+ try {
+ events.publishEvent(resolutionEvent);
+ } catch (Exception e) {
+ log.warn("[ApprovalWorkflow] failed to publish ApprovalResolutionEvent for {}: {}",
+ snapshot.getPendingId(), e.getMessage());
+ }
+ });
+ }
+
boolean consumed = "consumed".equals(snapshotStatus);
ResolveOutcome outcome = consumed
? ResolveOutcome.consumed(snapshot, true, rewritten)
diff --git a/mateclaw-server/src/main/java/vip/mate/approval/event/ApprovalResolutionEvent.java b/mateclaw-server/src/main/java/vip/mate/approval/event/ApprovalResolutionEvent.java
new file mode 100644
index 00000000..68e958fb
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/approval/event/ApprovalResolutionEvent.java
@@ -0,0 +1,41 @@
+package vip.mate.approval.event;
+
+/**
+ * Generic application event fired AFTER an approval row reaches a final
+ * decision through the human-approval path (approved / denied / consumed) or
+ * through the timeout sweep.
+ *
+ * Distinct from {@code WorkflowApprovalResolvedEvent}, which is workflow-bridge
+ * specific (only published for {@code pendingId} starting with {@code "wf-"}).
+ * This event is published for every tool-call approval row so the auto-grant
+ * resolution-log subsystem can record exactly one row per final decision.
+ *
+ *
Decision source mapping (see {@code ApprovalResolutionLog.DecisionSource}):
+ *
+ * - {@code APPROVED} / {@code DENIED} / {@code CONSUMED} → {@code USER_MANUAL}
+ * - {@code TIMEOUT} → {@code TIMEOUT}
+ * - {@code SUPERSEDED} → no event (not a final user decision; the replacement
+ * approval will fire its own event when it resolves)
+ *
+ *
+ * {@code findingsJson} is the original JSON serialization of the
+ * {@code GuardEvaluation.findings} captured at {@code createPending} time.
+ * The listener extracts {@code ruleId}s from it for {@code resolution_log.rule_ids}.
+ *
+ *
All fields are nullable: a row created via the legacy command-injection
+ * path may not carry every snapshot field. The listener treats missing fields
+ * as empty rather than skipping the row, so the resolution-log audit stays
+ * complete even when upstream context is partial.
+ */
+public record ApprovalResolutionEvent(
+ String pendingId,
+ String conversationId,
+ String agentId,
+ String userId,
+ String toolName,
+ String toolArguments,
+ String maxSeverity,
+ String findingsJson,
+ String decisionSource,
+ String resolutionNote
+) {}
diff --git a/mateclaw-server/src/main/java/vip/mate/approval/grant/listener/ApprovalResolutionLogListener.java b/mateclaw-server/src/main/java/vip/mate/approval/grant/listener/ApprovalResolutionLogListener.java
new file mode 100644
index 00000000..3ed639f1
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/approval/grant/listener/ApprovalResolutionLogListener.java
@@ -0,0 +1,104 @@
+package vip.mate.approval.grant.listener;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+import vip.mate.approval.event.ApprovalResolutionEvent;
+import vip.mate.approval.grant.WorkspaceLookupCache;
+import vip.mate.approval.grant.entity.ApprovalResolutionLog;
+import vip.mate.approval.grant.repository.ApprovalResolutionLogMapper;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+/**
+ * Records one row in {@code mate_approval_resolution_log} per final
+ * human-approval decision (USER_MANUAL / TIMEOUT), complementing the rows
+ * written directly by {@code ApprovalGrantResolver} for HARD_BLOCK and
+ * AUTO_GRANT.
+ *
+ * The listener runs out-of-tx (the publisher fires events from an
+ * {@code afterCommit} hook), so a DB write failure here cannot roll back the
+ * already-committed approval state. We log the failure and continue — losing
+ * one resolution-log row is far less harmful than re-opening the approval row
+ * for double-resolve.
+ *
+ *
Workspace resolution goes through {@link WorkspaceLookupCache} so we get
+ * the same conversation→workspace mapping the resolver uses on the hot path,
+ * with the same null-fallback behavior: a deleted conversation produces a row
+ * with {@code workspace_id = null}, which is allowed by the V128 schema.
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class ApprovalResolutionLogListener {
+
+ private static final int ARGS_PREVIEW_MAX = 500;
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private final ApprovalResolutionLogMapper resolutionMapper;
+ private final WorkspaceLookupCache workspaceLookupCache;
+
+ @EventListener
+ public void onApprovalResolved(ApprovalResolutionEvent event) {
+ try {
+ ApprovalResolutionLog row = new ApprovalResolutionLog();
+ row.setWorkspaceId(workspaceLookupCache.resolveByConversation(event.conversationId()));
+ row.setConversationId(event.conversationId());
+ row.setAgentId(event.agentId());
+ row.setUserId(event.userId());
+ row.setToolName(event.toolName());
+ row.setMaxSeverity(event.maxSeverity());
+ row.setRuleIds(extractRuleIds(event.findingsJson()));
+ row.setDecisionSource(event.decisionSource());
+ row.setGrantId(null);
+ row.setPendingId(event.pendingId());
+ row.setArgsPreview(previewArgs(event.toolArguments()));
+ row.setNote(event.resolutionNote());
+
+ resolutionMapper.insert(row);
+ } catch (Exception e) {
+ log.warn("[APPROVAL] ApprovalResolutionLogListener failed to record {} for pending {}: {}",
+ event.decisionSource(), event.pendingId(), e.getMessage());
+ }
+ }
+
+ /**
+ * Pulls {@code ruleId}s out of the serialized findings JSON captured at
+ * {@code createPending} time. The JSON is the standard
+ * {@code GuardFinding.toMap()} array form (a {@code List