mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
feat(attribution): autopilot runs attribute to the firing trigger's creator (MUL-4302)
Per Bohan: an autopilot schedule/webhook run should be accountable to the human who created the SPECIFIC trigger that fired it, not the rule publisher. (Manual triggers already attribute to the invoking member via direct_human — unchanged.) - Migration 162: add autopilot_trigger.created_by_type/created_by_id (nullable, no FK/cascade). Capture the creating member at both trigger-create sites (schedule + webhook). - New precise source trigger_owner: originator stays NULL (an autonomous fire carries no human authorization — same authz-safe divergence as rule_owner), accountable = the trigger's member creator. - triggerOwnerAttribution resolves run.trigger_id → creator; wired into run_only dispatch and the create_issue path (bridging issue → active run → trigger_id). Legacy triggers with no recorded creator, and agent-created triggers, degrade to rule_owner then owner_fallback — nothing regresses. - Frontend: trigger_owner source label in all four locales + badge switch case. - Tests: attribution TriggerOwner unit + Precise/invariant; DB-backed resolver tests (member creator → trigger_owner; creatorless → rule_owner fallback). Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -379,6 +379,7 @@
|
||||
"source_direct_human": "メンバーの直接操作",
|
||||
"source_delegation": "別の実行から委任",
|
||||
"source_comment_source": "コメントスレッドから",
|
||||
"source_trigger_owner": "オートパイロットのトリガー作成者",
|
||||
"source_rule_owner": "オートパイロットのルール公開者",
|
||||
"source_owner_fallback": "正確な責任者なし — エージェント所有者に帰属",
|
||||
"source_backfill": "バックフィルされた帰属",
|
||||
|
||||
@@ -379,6 +379,7 @@
|
||||
"source_direct_human": "멤버 직접 작업",
|
||||
"source_delegation": "다른 실행에서 위임됨",
|
||||
"source_comment_source": "댓글 스레드에서",
|
||||
"source_trigger_owner": "오토파일럿 트리거 작성자",
|
||||
"source_rule_owner": "오토파일럿 규칙 게시자",
|
||||
"source_owner_fallback": "정확한 담당자 없음 — 에이전트 소유자에게 귀속",
|
||||
"source_backfill": "백필된 귀속",
|
||||
|
||||
@@ -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": "回填的归因",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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).';
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user