diff --git a/packages/views/issues/components/attribution-badge.tsx b/packages/views/issues/components/attribution-badge.tsx index 88aeb5c25..9ffa57784 100644 --- a/packages/views/issues/components/attribution-badge.tsx +++ b/packages/views/issues/components/attribution-badge.tsx @@ -46,6 +46,9 @@ export function AttributionBadge({ case "comment_source": sourceLabel = t(($) => $.execution_log.attribution.source_comment_source); break; + case "trigger_owner": + sourceLabel = t(($) => $.execution_log.attribution.source_trigger_owner); + break; case "rule_owner": sourceLabel = t(($) => $.execution_log.attribution.source_rule_owner); break; diff --git a/packages/views/locales/en/issues.json b/packages/views/locales/en/issues.json index 45de673b0..1898393ea 100644 --- a/packages/views/locales/en/issues.json +++ b/packages/views/locales/en/issues.json @@ -395,6 +395,7 @@ "source_direct_human": "Direct member action", "source_delegation": "Delegated from another run", "source_comment_source": "From a comment thread", + "source_trigger_owner": "Autopilot trigger creator", "source_rule_owner": "Autopilot rule publisher", "source_owner_fallback": "No precise owner — attributed to the agent owner", "source_backfill": "Backfilled attribution", diff --git a/packages/views/locales/ja/issues.json b/packages/views/locales/ja/issues.json index 1effc75ee..123139bb0 100644 --- a/packages/views/locales/ja/issues.json +++ b/packages/views/locales/ja/issues.json @@ -379,6 +379,7 @@ "source_direct_human": "メンバーの直接操作", "source_delegation": "別の実行から委任", "source_comment_source": "コメントスレッドから", + "source_trigger_owner": "オートパイロットのトリガー作成者", "source_rule_owner": "オートパイロットのルール公開者", "source_owner_fallback": "正確な責任者なし — エージェント所有者に帰属", "source_backfill": "バックフィルされた帰属", diff --git a/packages/views/locales/ko/issues.json b/packages/views/locales/ko/issues.json index 0237dc683..4aeb5a21d 100644 --- a/packages/views/locales/ko/issues.json +++ b/packages/views/locales/ko/issues.json @@ -379,6 +379,7 @@ "source_direct_human": "멤버 직접 작업", "source_delegation": "다른 실행에서 위임됨", "source_comment_source": "댓글 스레드에서", + "source_trigger_owner": "오토파일럿 트리거 작성자", "source_rule_owner": "오토파일럿 규칙 게시자", "source_owner_fallback": "정확한 담당자 없음 — 에이전트 소유자에게 귀속", "source_backfill": "백필된 귀속", diff --git a/packages/views/locales/zh-Hans/issues.json b/packages/views/locales/zh-Hans/issues.json index 517ab8298..3bee6e67d 100644 --- a/packages/views/locales/zh-Hans/issues.json +++ b/packages/views/locales/zh-Hans/issues.json @@ -379,6 +379,7 @@ "source_direct_human": "成员直接操作", "source_delegation": "由其他运行委托", "source_comment_source": "来自评论线程", + "source_trigger_owner": "Autopilot 触发器创建者", "source_rule_owner": "Autopilot 规则发布者", "source_owner_fallback": "无精确责任人 —— 归到 agent 所有者", "source_backfill": "回填的归因", diff --git a/server/internal/attribution/attribution.go b/server/internal/attribution/attribution.go index 58f61d24a..c73e6e8c4 100644 --- a/server/internal/attribution/attribution.go +++ b/server/internal/attribution/attribution.go @@ -42,8 +42,18 @@ const ( // agent/system-authored comment; the human is resolved through // comment.source_task_id (a special case of delegation, MUL-4302 §3.3). SourceCommentSource Source = "comment_source" - // SourceRuleOwner — an autopilot trigger enqueued the run; the accountable - // human is the publisher of the rule's active version (MUL-4302 §3.4). + // SourceTriggerOwner — an autopilot schedule/webhook trigger enqueued the run; + // the accountable human is the member who CREATED that specific trigger (set up + // the schedule / registered the webhook). Preferred over rule_owner: a run is + // accountable to whoever armed the trigger that fired it, not to whoever last + // published the rule (MUL-4302; Bohan's refinement). originator stays NULL — an + // autonomous fire carries no human authorization — so this is the same authz-safe + // audit-only divergence as rule_owner. + SourceTriggerOwner Source = "trigger_owner" + // SourceRuleOwner — an autopilot trigger enqueued the run but its creator is not + // recoverable (a trigger created before per-trigger creators were recorded); the + // accountable human degrades to the publisher of the rule's active version + // (MUL-4302 §3.4). Precise, but coarser than trigger_owner. SourceRuleOwner Source = "rule_owner" // SourceOwnerFallback — nothing above resolved a human, so attribution // degrades to the agent owner. This is DEGRADED, not compliance-grade, and @@ -63,7 +73,7 @@ const ( // attribution-coverage health metric (MUL-4302 §9). func (src Source) Precise() bool { switch src { - case SourceDirectHuman, SourceDelegation, SourceCommentSource, SourceRuleOwner: + case SourceDirectHuman, SourceDelegation, SourceCommentSource, SourceTriggerOwner, SourceRuleOwner: return true default: return false @@ -335,6 +345,27 @@ func RuleOwner(publisherUserID, ruleVersionID pgtype.UUID, evidenceKind Evidence return finalizeAttribution(r) } +// TriggerOwner builds attribution for an autopilot schedule/webhook run keyed to +// the human who created the firing trigger (MUL-4302; Bohan's refinement). Like +// RuleOwner, no human authorized the run, so UserID (originator) stays NULL and +// only the audit-accountable side is set — to creatorUserID, the trigger's member +// creator. Evidence is caller-supplied (autopilot_run for run_only, the issue for +// create_issue). An invalid creator degrades to unattributed so callers that lost +// the creator fall back to rule_owner rather than fabricating a human. +func TriggerOwner(creatorUserID pgtype.UUID, evidenceKind EvidenceKind, evidenceRefID pgtype.UUID) Result { + r := Result{ + EvidenceKind: evidenceKind, + EvidenceRefID: evidenceRefID, + } + if creatorUserID.Valid { + r.Source = SourceTriggerOwner + r.AccountableUserID = creatorUserID + } else { + r.Source = SourceUnattributed + } + return finalizeAttribution(r) +} + // OwnerFallback degrades an UNATTRIBUTED result to owner_fallback (MUL-4302 §3.5): // the agent owner becomes the accountable human so no run is left without one, but // this is a DEGRADED label (Source.Precise() == false) and must be surfaced diff --git a/server/internal/attribution/attribution_test.go b/server/internal/attribution/attribution_test.go index 1a289a3b7..dc09a5926 100644 --- a/server/internal/attribution/attribution_test.go +++ b/server/internal/attribution/attribution_test.go @@ -287,8 +287,9 @@ func TestAccountableMirrorsOriginatorInvariant(t *testing.T) { // Authorization must never be forged from the audit side: a non-NULL // accountable with a NULL originator is allowed (divergence), but the // reverse — originator set from accountable — must never happen implicitly. - if r.AccountableUserID.Valid && !r.UserID.Valid && r.Source != SourceRuleOwner && r.Source != SourceOwnerFallback { - t.Errorf("result[%d]: accountable set with NULL originator only allowed for rule_owner/owner_fallback, got source=%q", i, r.Source) + if r.AccountableUserID.Valid && !r.UserID.Valid && + r.Source != SourceRuleOwner && r.Source != SourceTriggerOwner && r.Source != SourceOwnerFallback { + t.Errorf("result[%d]: accountable set with NULL originator only allowed for trigger_owner/rule_owner/owner_fallback, got source=%q", i, r.Source) } } } @@ -341,6 +342,38 @@ func TestRuleOwner(t *testing.T) { } } +func TestTriggerOwner(t *testing.T) { + // The divergence case (MUL-4302; Bohan): an autopilot schedule/webhook run has + // NO authorizing human (originator NULL) but IS accountable to the member who + // created the firing trigger. + got := TriggerOwner(human, EvidenceAutopilotRun, issue) + if got.Source != SourceTriggerOwner { + t.Fatalf("source = %q, want trigger_owner", got.Source) + } + if !got.Source.Precise() { + t.Errorf("trigger_owner must be a precise source") + } + if got.UserID.Valid { + t.Errorf("trigger_owner must NOT set originator (authorization stays NULL), got %v", got.UserID) + } + if got.AccountableUserID != human { + t.Errorf("accountable should be the trigger creator, got %v", got.AccountableUserID) + } + if got.EvidenceKind != EvidenceAutopilotRun || got.EvidenceRefID != issue { + t.Errorf("evidence should be carried through") + } + + // No creator (unrecoverable) must not fabricate a human — degrades to + // unattributed so the caller can fall back to rule_owner. + none := TriggerOwner(pgtype.UUID{}, EvidenceAutopilotRun, issue) + if none.Source != SourceUnattributed { + t.Errorf("missing creator must degrade to unattributed, got %q", none.Source) + } + if none.UserID.Valid || none.AccountableUserID.Valid { + t.Errorf("missing creator must carry no human on either side") + } +} + func TestOwnerFallback(t *testing.T) { // Unattributed → owner_fallback: accountable = owner, originator stays NULL, // source is degraded (not precise). @@ -386,7 +419,7 @@ func TestUnattributed(t *testing.T) { } func TestSourcePrecise(t *testing.T) { - precise := []Source{SourceDirectHuman, SourceDelegation, SourceCommentSource, SourceRuleOwner} + precise := []Source{SourceDirectHuman, SourceDelegation, SourceCommentSource, SourceTriggerOwner, SourceRuleOwner} degraded := []Source{SourceOwnerFallback, SourceBackfill, SourceUnattributed, Source("")} for _, s := range precise { if !s.Precise() { diff --git a/server/internal/handler/autopilot.go b/server/internal/handler/autopilot.go index 852955b01..fff84e28c 100644 --- a/server/internal/handler/autopilot.go +++ b/server/internal/handler/autopilot.go @@ -1314,6 +1314,10 @@ func (h *Handler) CreateAutopilotTrigger(w http.ResponseWriter, r *http.Request) NextRunAt: nextRunAt, Label: ptrToText(req.Label), WebhookToken: webhookToken, + // Record the creating member so runs this trigger fires attribute to them + // (source=trigger_owner, MUL-4302). + CreatedByType: pgtype.Text{String: "member", Valid: publisherID.Valid}, + CreatedByID: publisherID, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create trigger") @@ -1376,6 +1380,10 @@ func (h *Handler) createWebhookTriggerWithMintedToken( WebhookToken: pgtype.Text{String: token, Valid: true}, Provider: pgtype.Text{String: provider, Valid: provider != ""}, EventFilters: eventFilters, + // Record the creating member so runs this webhook fires attribute to + // them (source=trigger_owner, MUL-4302). + CreatedByType: pgtype.Text{String: "member", Valid: publisherID.Valid}, + CreatedByID: publisherID, }) if err != nil { tx.Rollback(ctx) diff --git a/server/internal/service/attribution_stamp_test.go b/server/internal/service/attribution_stamp_test.go index 42c18be7c..ceeb6614b 100644 --- a/server/internal/service/attribution_stamp_test.go +++ b/server/internal/service/attribution_stamp_test.go @@ -176,6 +176,93 @@ func TestEnqueueTaskForIssueWithHandoffAttributesToActor(t *testing.T) { } } +// TestTriggerOwnerAttribution_ScheduleTriggerCreator is the acceptance test for +// trigger_owner (MUL-4302; Bohan): an autopilot schedule/webhook run is accountable +// to the member who CREATED the firing trigger, with originator NULL (no human +// authorized the autonomous fire). +func TestTriggerOwnerAttribution_ScheduleTriggerCreator(t *testing.T) { + pool := newResolveOriginatorPool(t) + ctx := context.Background() + q := db.New(pool) + workspaceID, creatorID, agentID, _ := seedAttributionFixture(t, pool) + + var autopilotID string + if err := pool.QueryRow(ctx, ` + INSERT INTO autopilot (workspace_id, title, assignee_id, execution_mode, created_by_type, created_by_id) + VALUES ($1, 'trigger-owner-ap', $2, 'run_only', 'member', $3) RETURNING id`, + workspaceID, agentID, creatorID).Scan(&autopilotID); err != nil { + t.Fatalf("seed autopilot: %v", err) + } + t.Cleanup(func() { pool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, autopilotID) }) + + // Schedule trigger created by the member. + var triggerID string + if err := pool.QueryRow(ctx, ` + INSERT INTO autopilot_trigger (autopilot_id, kind, enabled, cron_expression, created_by_type, created_by_id) + VALUES ($1, 'schedule', true, '0 * * * *', 'member', $2) RETURNING id`, + autopilotID, creatorID).Scan(&triggerID); err != nil { + t.Fatalf("seed trigger: %v", err) + } + + got := triggerOwnerAttribution(ctx, q, + util.MustParseUUID(triggerID), util.MustParseUUID(workspaceID), util.MustParseUUID(autopilotID), + attribution.EvidenceAutopilotRun, util.MustParseUUID(autopilotID)) + if got.Source != attribution.SourceTriggerOwner { + t.Fatalf("source = %q, want trigger_owner", got.Source) + } + if got.UserID.Valid { + t.Errorf("trigger_owner is audit-only; originator must stay NULL, got %s", util.UUIDToString(got.UserID)) + } + if !got.AccountableUserID.Valid || got.AccountableUserID.Bytes != util.MustParseUUID(creatorID).Bytes { + t.Errorf("accountable = %s, want trigger creator %s", util.UUIDToString(got.AccountableUserID), creatorID) + } +} + +// TestTriggerOwnerAttribution_LegacyTriggerFallsBackToRuleOwner verifies the +// backward-compat path Bohan signed off on: a trigger with no recorded creator +// (created before this migration) degrades to the rule publisher (rule_owner), +// never fabricating a human. +func TestTriggerOwnerAttribution_LegacyTriggerFallsBackToRuleOwner(t *testing.T) { + pool := newResolveOriginatorPool(t) + ctx := context.Background() + q := db.New(pool) + workspaceID, publisherID, agentID, _ := seedAttributionFixture(t, pool) + + var autopilotID string + if err := pool.QueryRow(ctx, ` + INSERT INTO autopilot (workspace_id, title, assignee_id, execution_mode, created_by_type, created_by_id) + VALUES ($1, 'legacy-trigger-ap', $2, 'run_only', 'member', $3) RETURNING id`, + workspaceID, agentID, publisherID).Scan(&autopilotID); err != nil { + t.Fatalf("seed autopilot: %v", err) + } + t.Cleanup(func() { pool.Exec(context.Background(), `DELETE FROM autopilot WHERE id = $1`, autopilotID) }) + + // Trigger with NO creator recorded (pre-migration style). + var triggerID string + if err := pool.QueryRow(ctx, ` + INSERT INTO autopilot_trigger (autopilot_id, kind, enabled, cron_expression) + VALUES ($1, 'schedule', true, '0 * * * *') RETURNING id`, + autopilotID).Scan(&triggerID); err != nil { + t.Fatalf("seed trigger: %v", err) + } + // Active rule version so the fallback resolves to rule_owner (the publisher). + if _, err := pool.Exec(ctx, ` + INSERT INTO autopilot_rule_version (autopilot_id, workspace_id, published_by_type, published_by_id) + VALUES ($1, $2, 'member', $3)`, autopilotID, workspaceID, publisherID); err != nil { + t.Fatalf("seed rule version: %v", err) + } + + got := triggerOwnerAttribution(ctx, q, + util.MustParseUUID(triggerID), util.MustParseUUID(workspaceID), util.MustParseUUID(autopilotID), + attribution.EvidenceAutopilotRun, util.MustParseUUID(autopilotID)) + if got.Source != attribution.SourceRuleOwner { + t.Fatalf("source = %q, want rule_owner (legacy trigger falls back)", got.Source) + } + if !got.AccountableUserID.Valid || got.AccountableUserID.Bytes != util.MustParseUUID(publisherID).Bytes { + t.Errorf("accountable = %s, want rule publisher %s", util.UUIDToString(got.AccountableUserID), publisherID) + } +} + // TestEnqueueTaskForIssueAutopilotOriginStampsRuleOwner is the acceptance test for // rule_owner (MUL-4302 §3.4): an autopilot-origin issue's run has NO authorizing // human (originator_user_id stays NULL) but IS accountable to the publisher of the diff --git a/server/internal/service/autopilot.go b/server/internal/service/autopilot.go index 258dd6796..72192b5dc 100644 --- a/server/internal/service/autopilot.go +++ b/server/internal/service/autopilot.go @@ -650,16 +650,16 @@ func (s *AutopilotService) dispatchRunOnly(ctx context.Context, ap db.Autopilot, // the triggering member is direct_human and becomes BOTH originator (so the run // carries their authorization context) and accountable (MUL-4302 §4). A // schedule / webhook trigger has no human — originator_user_id stays NULL and - // the audit-accountable human is the rule_owner, the publisher of the active - // rule version, with rule_version_id recording the snapshot (MUL-4302 §3.4); a - // missing version/publisher degrades to unattributed (see ruleOwnerAttribution). - // Either way evidence points at the autopilot run and the row is never a - // NULL-source bypass. + // the audit-accountable human is the member who CREATED the firing trigger — + // trigger_owner, resolved from run.TriggerID (MUL-4302; Bohan's refinement) — + // degrading to the rule version publisher (rule_owner) when that creator is not + // recoverable, then to unattributed. Either way evidence points at the autopilot + // run and the row is never a NULL-source bypass. var autopilotAttr attribution.Result if actorUserID.Valid { autopilotAttr = attribution.DirectHumanRun(actorUserID, attribution.EvidenceAutopilotRun, run.ID) } else { - autopilotAttr = ruleOwnerAttribution(ctx, s.Queries, ap.WorkspaceID, ap.ID, attribution.EvidenceAutopilotRun, run.ID) + autopilotAttr = triggerOwnerAttribution(ctx, s.Queries, run.TriggerID, ap.WorkspaceID, ap.ID, attribution.EvidenceAutopilotRun, run.ID) } // If no precise human resolved (a version-less autopilot), degrade to // owner_fallback (accountable = agent owner), or skip the dispatch when the diff --git a/server/internal/service/task.go b/server/internal/service/task.go index 2847e9c8e..e41fe1860 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -380,13 +380,19 @@ func (s *TaskService) attributionForIssueTask(ctx context.Context, issue db.Issu } // Autopilot-origin issues (origin_id is the autopilot id) from a schedule / // webhook trigger: no human authorized the run, so originator stays NULL, but it - // is accountable to the publisher of the autopilot's active rule version — - // rule_owner (MUL-4302 §3.4). Resolved the same way run_only dispatch resolves + // is accountable to the human who CREATED the firing trigger — trigger_owner + // (MUL-4302; Bohan's refinement), degrading to the rule publisher when that + // creator is not recoverable. Resolved the same way run_only dispatch resolves // it, so both autopilot execution modes attribute identically. (A manual trigger - // carries an actor and is already handled above.) + // carries an actor and is already handled above.) The issue only stores the + // autopilot id, so bridge issue → active run → trigger_id to find the trigger. if s != nil && s.Queries != nil && issue.OriginType.Valid && issue.OriginType.String == "autopilot" && issue.OriginID.Valid { - return ruleOwnerAttribution(ctx, s.Queries, issue.WorkspaceID, issue.OriginID, attribution.EvidenceIssueAssignment, issue.ID) + var triggerID pgtype.UUID + if run, err := s.Queries.GetAutopilotRunByIssue(ctx, issue.ID); err == nil { + triggerID = run.TriggerID + } + return triggerOwnerAttribution(ctx, s.Queries, triggerID, issue.WorkspaceID, issue.OriginID, attribution.EvidenceIssueAssignment, issue.ID) } facts := attribution.DirectFacts{ IssueID: issue.ID, @@ -438,6 +444,24 @@ func ruleOwnerAttribution(ctx context.Context, q *db.Queries, workspaceID, autop return attribution.RuleOwner(publisher, ver.ID, evidenceKind, evidenceRefID) } +// triggerOwnerAttribution resolves an autopilot schedule/webhook run to the human +// who CREATED the firing trigger (MUL-4302; Bohan's refinement). triggerID is the +// autopilot_run's trigger_id. When it resolves to a member-created trigger, the run +// is trigger_owner-accountable to that member. A trigger with no recorded creator +// (created before per-trigger creators were captured) or an agent-created trigger +// degrades to ruleOwnerAttribution (rule publisher, then owner_fallback) — the same +// coarser behavior autopilots had before this change, so nothing regresses. Never +// errors: attribution must not fail an enqueue. +func triggerOwnerAttribution(ctx context.Context, q *db.Queries, triggerID, workspaceID, autopilotID pgtype.UUID, evidenceKind attribution.EvidenceKind, evidenceRefID pgtype.UUID) attribution.Result { + if q != nil && triggerID.Valid { + if trig, err := q.GetAutopilotTrigger(ctx, triggerID); err == nil && + trig.CreatedByType.Valid && trig.CreatedByType.String == "member" && trig.CreatedByID.Valid { + return attribution.TriggerOwner(trig.CreatedByID, evidenceKind, evidenceRefID) + } + } + return ruleOwnerAttribution(ctx, q, workspaceID, autopilotID, evidenceKind, evidenceRefID) +} + // ErrAttributionFailClosed signals that a run resolved to no precise accountable // human and the enqueue is REFUSED rather than started. It covers three cases, all // of which mean "we cannot guarantee an accountable human for this run" (MUL-4302 diff --git a/server/migrations/162_autopilot_trigger_creator.down.sql b/server/migrations/162_autopilot_trigger_creator.down.sql new file mode 100644 index 000000000..ce20209f9 --- /dev/null +++ b/server/migrations/162_autopilot_trigger_creator.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE autopilot_trigger DROP COLUMN IF EXISTS created_by_id; +ALTER TABLE autopilot_trigger DROP COLUMN IF EXISTS created_by_type; diff --git a/server/migrations/162_autopilot_trigger_creator.up.sql b/server/migrations/162_autopilot_trigger_creator.up.sql new file mode 100644 index 000000000..8ec99830e --- /dev/null +++ b/server/migrations/162_autopilot_trigger_creator.up.sql @@ -0,0 +1,28 @@ +-- Human Attribution — per-trigger creator (MUL-4302; Bohan's refinement of the +-- autopilot attribution rule). An autopilot run must be accountable to the human +-- who created the SPECIFIC trigger that fired it — the member who set up the +-- schedule, or who registered the webhook — not the publisher of the whole rule +-- (the previous rule_owner behavior). autopilot_trigger did not record a creator, +-- so this adds one. The resolver reads it as source=trigger_owner (accountable = +-- this member, originator NULL because a scheduled/webhook fire carries no human +-- authorization at fire time — same authz-safe divergence as rule_owner). +-- +-- Constraints (MUL-4302 §7 + workspace DB rules): NO foreign key, NO cascade +-- (integrity is app-layer), nullable with no default so the ALTER is a fast +-- metadata-only change (no table rewrite). NULL means "no creator recorded": +-- every trigger that predates this migration, which the resolver degrades to the +-- old rule_owner behavior (then owner_fallback) rather than fabricating a human. +-- created_by_type is 'member' | 'agent' (mirrors autopilot.created_by_type); only +-- a 'member' creator becomes the accountable human, an 'agent'-created trigger +-- degrades to rule_owner like any other agent action. +ALTER TABLE autopilot_trigger + ADD COLUMN created_by_type TEXT NULL; + +ALTER TABLE autopilot_trigger + ADD COLUMN created_by_id UUID NULL; + +COMMENT ON COLUMN autopilot_trigger.created_by_type IS + 'Actor type that created this trigger: member | agent. Paired with created_by_id. Consumed only for attribution (source=trigger_owner) — never authorization. NULL on pre-migration triggers (MUL-4302).'; + +COMMENT ON COLUMN autopilot_trigger.created_by_id IS + 'The member/agent that created this trigger. For a member creator this is the accountable human of runs this trigger fires (source=trigger_owner). No FK, app-layer integrity. NULL on pre-migration triggers, which degrade to rule_owner (MUL-4302).'; diff --git a/server/pkg/db/generated/autopilot.sql.go b/server/pkg/db/generated/autopilot.sql.go index 29367733d..f0e1f6100 100644 --- a/server/pkg/db/generated/autopilot.sql.go +++ b/server/pkg/db/generated/autopilot.sql.go @@ -382,13 +382,15 @@ func (q *Queries) CreateAutopilotTask(ctx context.Context, arg CreateAutopilotTa const createAutopilotTrigger = `-- name: CreateAutopilotTrigger :one INSERT INTO autopilot_trigger ( autopilot_id, kind, enabled, cron_expression, timezone, - next_run_at, webhook_token, label, provider, event_filters + next_run_at, webhook_token, label, provider, event_filters, + created_by_type, created_by_id ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, COALESCE($9::text, 'generic'), - $10 -) RETURNING id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters + $10, + $11, $12 +) RETURNING id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters, created_by_type, created_by_id ` type CreateAutopilotTriggerParams struct { @@ -402,6 +404,8 @@ type CreateAutopilotTriggerParams struct { Label pgtype.Text `json:"label"` Provider pgtype.Text `json:"provider"` EventFilters []byte `json:"event_filters"` + CreatedByType pgtype.Text `json:"created_by_type"` + CreatedByID pgtype.UUID `json:"created_by_id"` } func (q *Queries) CreateAutopilotTrigger(ctx context.Context, arg CreateAutopilotTriggerParams) (AutopilotTrigger, error) { @@ -416,6 +420,8 @@ func (q *Queries) CreateAutopilotTrigger(ctx context.Context, arg CreateAutopilo arg.Label, arg.Provider, arg.EventFilters, + arg.CreatedByType, + arg.CreatedByID, ) var i AutopilotTrigger err := row.Scan( @@ -434,6 +440,8 @@ func (q *Queries) CreateAutopilotTrigger(ctx context.Context, arg CreateAutopilo &i.Provider, &i.SigningSecret, &i.EventFilters, + &i.CreatedByType, + &i.CreatedByID, ) return i, err } @@ -695,7 +703,7 @@ func (q *Queries) GetAutopilotRunByTriggerAndPlanned(ctx context.Context, arg Ge } const getAutopilotTrigger = `-- name: GetAutopilotTrigger :one -SELECT id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters FROM autopilot_trigger +SELECT id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters, created_by_type, created_by_id FROM autopilot_trigger WHERE id = $1 ` @@ -718,12 +726,14 @@ func (q *Queries) GetAutopilotTrigger(ctx context.Context, id pgtype.UUID) (Auto &i.Provider, &i.SigningSecret, &i.EventFilters, + &i.CreatedByType, + &i.CreatedByID, ) return i, err } const getWebhookTriggerByToken = `-- name: GetWebhookTriggerByToken :one -SELECT t.id, t.autopilot_id, t.kind, t.enabled, t.cron_expression, t.timezone, t.next_run_at, t.webhook_token, t.label, t.last_fired_at, t.created_at, t.updated_at, t.provider, t.signing_secret, t.event_filters, a.workspace_id AS autopilot_workspace_id +SELECT t.id, t.autopilot_id, t.kind, t.enabled, t.cron_expression, t.timezone, t.next_run_at, t.webhook_token, t.label, t.last_fired_at, t.created_at, t.updated_at, t.provider, t.signing_secret, t.event_filters, t.created_by_type, t.created_by_id, a.workspace_id AS autopilot_workspace_id FROM autopilot_trigger t JOIN autopilot a ON a.id = t.autopilot_id WHERE t.kind = 'webhook' @@ -746,6 +756,8 @@ type GetWebhookTriggerByTokenRow struct { Provider string `json:"provider"` SigningSecret pgtype.Text `json:"signing_secret"` EventFilters []byte `json:"event_filters"` + CreatedByType pgtype.Text `json:"created_by_type"` + CreatedByID pgtype.UUID `json:"created_by_id"` AutopilotWorkspaceID pgtype.UUID `json:"autopilot_workspace_id"` } @@ -773,6 +785,8 @@ func (q *Queries) GetWebhookTriggerByToken(ctx context.Context, webhookToken pgt &i.Provider, &i.SigningSecret, &i.EventFilters, + &i.CreatedByType, + &i.CreatedByID, &i.AutopilotWorkspaceID, ) return i, err @@ -947,7 +961,7 @@ func (q *Queries) ListAutopilotSubscribers(ctx context.Context, autopilotID pgty const listAutopilotTriggers = `-- name: ListAutopilotTriggers :many -SELECT id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters FROM autopilot_trigger +SELECT id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters, created_by_type, created_by_id FROM autopilot_trigger WHERE autopilot_id = $1 ORDER BY created_at ASC ` @@ -980,6 +994,8 @@ func (q *Queries) ListAutopilotTriggers(ctx context.Context, autopilotID pgtype. &i.Provider, &i.SigningSecret, &i.EventFilters, + &i.CreatedByType, + &i.CreatedByID, ); err != nil { return nil, err } @@ -1183,7 +1199,7 @@ SET webhook_token = $2, updated_at = now() WHERE id = $1 AND kind = 'webhook' -RETURNING id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters +RETURNING id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters, created_by_type, created_by_id ` type RotateAutopilotTriggerWebhookTokenParams struct { @@ -1213,6 +1229,8 @@ func (q *Queries) RotateAutopilotTriggerWebhookToken(ctx context.Context, arg Ro &i.Provider, &i.SigningSecret, &i.EventFilters, + &i.CreatedByType, + &i.CreatedByID, ) return i, err } @@ -1305,7 +1323,7 @@ SET signing_secret = $2, updated_at = now() WHERE id = $1 AND kind = 'webhook' -RETURNING id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters +RETURNING id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters, created_by_type, created_by_id ` type SetAutopilotTriggerSigningSecretParams struct { @@ -1337,6 +1355,8 @@ func (q *Queries) SetAutopilotTriggerSigningSecret(ctx context.Context, arg SetA &i.Provider, &i.SigningSecret, &i.EventFilters, + &i.CreatedByType, + &i.CreatedByID, ) return i, err } @@ -1346,7 +1366,7 @@ UPDATE autopilot_trigger SET webhook_token = $2, updated_at = now() WHERE id = $1 -RETURNING id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters +RETURNING id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters, created_by_type, created_by_id ` type SetAutopilotTriggerWebhookTokenParams struct { @@ -1378,6 +1398,8 @@ func (q *Queries) SetAutopilotTriggerWebhookToken(ctx context.Context, arg SetAu &i.Provider, &i.SigningSecret, &i.EventFilters, + &i.CreatedByType, + &i.CreatedByID, ) return i, err } @@ -1732,7 +1754,7 @@ UPDATE autopilot_trigger SET event_filters = COALESCE($7, event_filters), updated_at = now() WHERE id = $1 -RETURNING id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters +RETURNING id, autopilot_id, kind, enabled, cron_expression, timezone, next_run_at, webhook_token, label, last_fired_at, created_at, updated_at, provider, signing_secret, event_filters, created_by_type, created_by_id ` type UpdateAutopilotTriggerParams struct { @@ -1772,6 +1794,8 @@ func (q *Queries) UpdateAutopilotTrigger(ctx context.Context, arg UpdateAutopilo &i.Provider, &i.SigningSecret, &i.EventFilters, + &i.CreatedByType, + &i.CreatedByID, ) return i, err } diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 792f7adfb..b76a745ed 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -238,6 +238,10 @@ type AutopilotTrigger struct { Provider string `json:"provider"` SigningSecret pgtype.Text `json:"signing_secret"` EventFilters []byte `json:"event_filters"` + // Actor type that created this trigger: member | agent. Paired with created_by_id. Consumed only for attribution (source=trigger_owner) — never authorization. NULL on pre-migration triggers (MUL-4302). + CreatedByType pgtype.Text `json:"created_by_type"` + // The member/agent that created this trigger. For a member creator this is the accountable human of runs this trigger fires (source=trigger_owner). No FK, app-layer integrity. NULL on pre-migration triggers, which degrade to rule_owner (MUL-4302). + CreatedByID pgtype.UUID `json:"created_by_id"` } type ChannelBindingToken struct { diff --git a/server/pkg/db/queries/autopilot.sql b/server/pkg/db/queries/autopilot.sql index 8497504e3..1fd0edcca 100644 --- a/server/pkg/db/queries/autopilot.sql +++ b/server/pkg/db/queries/autopilot.sql @@ -122,12 +122,14 @@ WHERE id = $1; -- name: CreateAutopilotTrigger :one INSERT INTO autopilot_trigger ( autopilot_id, kind, enabled, cron_expression, timezone, - next_run_at, webhook_token, label, provider, event_filters + next_run_at, webhook_token, label, provider, event_filters, + created_by_type, created_by_id ) VALUES ( $1, $2, $3, sqlc.narg('cron_expression'), sqlc.narg('timezone'), sqlc.narg('next_run_at'), sqlc.narg('webhook_token'), sqlc.narg('label'), COALESCE(sqlc.narg('provider')::text, 'generic'), - sqlc.narg('event_filters') + sqlc.narg('event_filters'), + sqlc.narg('created_by_type'), sqlc.narg('created_by_id') ) RETURNING *; -- name: UpdateAutopilotTrigger :one