diff --git a/internal/handlers/resource.go b/internal/handlers/resource.go
index 2413286..88c58c1 100644
--- a/internal/handlers/resource.go
+++ b/internal/handlers/resource.go
@@ -661,7 +661,20 @@ func (h *ResourceHandler) Pause(c *fiber.Ctx) error {
h.rdb.Del(ctx, fmt.Sprintf("res:%s", token.String()))
// Best-effort audit event. Failure must not block the response.
+ //
+ // metadata.resource_id is REQUIRED here (not just the ResourceID column):
+ // the dashboard's per-resource AuditPanel (instanode-web fetchResourceAudit)
+ // filters the team audit window by `metadata.resource_id`, and the JSON
+ // serializer (auditEventToMap) surfaces ONLY the JSONB metadata — the
+ // ResourceID column is never echoed into the wire `metadata`. Omitting it
+ // (as this site did before) made resource.paused rows invisible in the
+ // resource's Audit tab even though the column was set. Mirrors the
+ // resource_id-in-metadata convention of emitResourceReadAudit / backup.
safego.Go("resource.bg", func() {
+ metaBlob, _ := json.Marshal(map[string]string{
+ "resource_id": resource.ID.String(),
+ "resource_type": resource.ResourceType,
+ })
_ = models.InsertAuditEvent(context.Background(), h.db, models.AuditEvent{
TeamID: teamID,
Actor: "agent",
@@ -669,6 +682,7 @@ func (h *ResourceHandler) Pause(c *fiber.Ctx) error {
ResourceType: resource.ResourceType,
ResourceID: uuid.NullUUID{UUID: resource.ID, Valid: true},
Summary: "paused " + resource.ResourceType + " " + token.String()[:8] + "",
+ Metadata: metaBlob,
})
})
@@ -779,7 +793,15 @@ func (h *ResourceHandler) Resume(c *fiber.Ctx) error {
h.rdb.Del(ctx, fmt.Sprintf("res:%s", token.String()))
+ // metadata.resource_id REQUIRED — see the matching comment in Pause: the
+ // dashboard AuditPanel filters on metadata.resource_id and the wire
+ // serializer never echoes the ResourceID column, so without this the
+ // resource.resumed row would not appear in the resource's Audit tab.
safego.Go("resource.bg", func() {
+ metaBlob, _ := json.Marshal(map[string]string{
+ "resource_id": resource.ID.String(),
+ "resource_type": resource.ResourceType,
+ })
_ = models.InsertAuditEvent(context.Background(), h.db, models.AuditEvent{
TeamID: teamID,
Actor: "agent",
@@ -787,6 +809,7 @@ func (h *ResourceHandler) Resume(c *fiber.Ctx) error {
ResourceType: resource.ResourceType,
ResourceID: uuid.NullUUID{UUID: resource.ID, Valid: true},
Summary: "resumed " + resource.ResourceType + " " + token.String()[:8] + "",
+ Metadata: metaBlob,
})
})
diff --git a/internal/handlers/resource_pause_test.go b/internal/handlers/resource_pause_test.go
index ffa9a0d..48dde31 100644
--- a/internal/handlers/resource_pause_test.go
+++ b/internal/handlers/resource_pause_test.go
@@ -19,10 +19,12 @@ import (
"context"
"database/sql"
"encoding/json"
+ "errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -343,3 +345,73 @@ func TestPausedStorageStillCountsTowardQuota(t *testing.T) {
assert.Equal(t, int64(500*1024*1024), total,
"deleted rows must be excluded from storage sum")
}
+
+// auditMetadataResourceID polls audit_log for the newest row of `kind` in the
+// team and returns the value of metadata->>'resource_id' (empty string when
+// the key is absent). The audit insert runs in a best-effort goroutine
+// (safego.Go), so callers wrap this in require.Eventually.
+func auditMetadataResourceID(t *testing.T, db *sql.DB, teamID, kind string) (found bool, resourceID string) {
+ t.Helper()
+ var rid sql.NullString
+ err := db.QueryRowContext(context.Background(), `
+ SELECT metadata->>'resource_id'
+ FROM audit_log
+ WHERE team_id = $1::uuid AND kind = $2
+ ORDER BY created_at DESC
+ LIMIT 1
+ `, teamID, kind).Scan(&rid)
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, ""
+ }
+ require.NoError(t, err)
+ return true, rid.String
+}
+
+// TestPauseResource_EmitsMetadataResourceID is the regression guard for the
+// AuditPanel-invisibility bug (BUGHUNT 2026-06-06): the resource.paused audit
+// row set the ResourceID column but NOT metadata.resource_id. The dashboard's
+// per-resource AuditPanel filters the team audit window by
+// `metadata.resource_id`, and auditEventToMap serialises ONLY the JSONB
+// metadata (the ResourceID column is never echoed onto the wire), so a paused
+// resource's most important state-change event never appeared in its Audit tab.
+//
+// This asserts the row's metadata->>'resource_id' equals the resource UUID, so
+// the row survives the UI's client-side filter. Pairs with the resume guard
+// below; if a future edit drops Metadata from either emit site, one of these
+// fails.
+func TestPauseResource_EmitsMetadataResourceID(t *testing.T) {
+ fix := setupPauseFixture(t, "pro", "postgres")
+
+ resp := doPauseOrResume(t, fix.app, fix.jwt, "pause", fix.resourceToken)
+ resp.Body.Close()
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+
+ require.Eventually(t, func() bool {
+ found, rid := auditMetadataResourceID(t, fix.db, fix.teamID, "resource.paused")
+ return found && rid == fix.resourceID
+ }, 3*time.Second, 50*time.Millisecond,
+ "resource.paused audit row must carry metadata.resource_id == resource UUID "+
+ "so the dashboard AuditPanel (filters on metadata.resource_id) shows it")
+}
+
+// TestResumeResource_EmitsMetadataResourceID — the resume-side twin of the
+// guard above.
+func TestResumeResource_EmitsMetadataResourceID(t *testing.T) {
+ fix := setupPauseFixture(t, "pro", "postgres")
+
+ // Pause first to reach a paused state, then resume.
+ resp := doPauseOrResume(t, fix.app, fix.jwt, "pause", fix.resourceToken)
+ resp.Body.Close()
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+
+ resp = doPauseOrResume(t, fix.app, fix.jwt, "resume", fix.resourceToken)
+ resp.Body.Close()
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+
+ require.Eventually(t, func() bool {
+ found, rid := auditMetadataResourceID(t, fix.db, fix.teamID, "resource.resumed")
+ return found && rid == fix.resourceID
+ }, 3*time.Second, 50*time.Millisecond,
+ "resource.resumed audit row must carry metadata.resource_id == resource UUID "+
+ "so the dashboard AuditPanel shows it")
+}