Skip to content

feat(automation): event outbox + hook policy layer (MUL-4332 PR1+PR2)#5467

Open
Bohan-J wants to merge 10 commits into
mainfrom
agent/j/e95f03a9
Open

feat(automation): event outbox + hook policy layer (MUL-4332 PR1+PR2)#5467
Bohan-J wants to merge 10 commits into
mainfrom
agent/j/e95f03a9

Conversation

@Bohan-J

@Bohan-J Bohan-J commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Cumulative PR1 + PR2 of the Event Hooks MVP (MUL-4332). It lands the durable event foundation and the store-only policy/debug layer on top of it. There is no matcher/executor and no consumer yet, and the whole hook API surface sits behind the default-off automation_event_hooks flag, so this is an additive, zero-behavior-change increment for existing workspaces. The matcher/executor (PR3), remaining actions/autopilot interop (PR4), and stage sensor/system hooks (PR5) follow in this same PR.

PR1 — transactional-outbox event layer

  • domain_event table (§4.1): fact envelope (workspace_id, type, schema_version, subject_*, actor_*, payload) + causal chain (correlation_id, causation_*, hop_count) + single-consumer outbox lease + monotonic seq. No FK / no cascade. Migrations 202–207; every secondary/unique index is its own single-statement CREATE INDEX CONCURRENTLY migration.
  • internal/domainevent: versioned v1 event catalog, typed payload structs, tx-aware Write (event commits atomically with the fact) and a WriteInTx helper.
  • Write-path convergence — the event commits in the same transaction as the fact — for issue.created, issue.status_changed, issue.assigned, comment.created, task.completed, task.failed across the v1 producers (HTTP create/update, batch update, GitHub merge close, task terminal/reset, bulk sweepers, daemon orphan recovery).
  • Retention sweeper (90-day TTL, explicit no-op until PR3), and the automation_event_hooks flag (default off), reserved for the PR3 executor.

PR2 — hook policy & debug layer (store-only, behind the flag)

  • Persistence (migrations 208–217): hook, hook_revision (immutable), automation_state, hook_execution, hook_action_effect — no FK, concurrent indexes.
  • internal/automation: the user-authored hook spec (when/if/fire/do), the machine-readable event schema registry declaring matchable fields per event, the fixed condition/action vocabularies, and a strict typed validator (unknown-field rejection, per-action allowed fields, status enum, rising-edge dependency, id/message limits).
  • internal/service (HookService) + /api/hooks REST + multica hook CLI: create / get / list / update (append immutable revision + repoint active) / enable / disable / soft-archive / executions.
  • Authorization & safety (all fail-closed, inside the write transaction):
    • Only the hook's original authorization principal or a workspace owner/admin may edit; the principal is never transferred on edit.
    • Every referenced target is validated to exist in the workspace; trigger_agent/run_autopilot targets are admitted against the hook's stored principal (agent invocation / autopilot write permission), not the editor.
    • Membership/role are re-derived inside the write transaction; concurrent PATCHes serialize via SELECT ... FOR UPDATE, so revisions stay contiguous.
  • internal/admission: pure agent-invocation / autopilot-write predicates shared by the HTTP handlers and the hook service so both judge access identically.

Still to come in this PR

  • PR3 — matcher/executor: leases, revision pinning, per_event/rising_edge, condition snapshot, effect idempotency, set_issue_status/trigger_agent, and all depth/width/rate/concurrency/permission/fuse guardrails with crash-window tests. Also POST /api/hooks/dry-run|explain and GET /api/events?correlation_id= (re-sliced here from the design's PR2 because they need the matcher's evaluation — pending product-owner confirmation).
  • PR4add_comment / send_inbox / run_autopilot execution + Autopilot interop + correlation trace.
  • PR5 — durable stage sensor, set_issue_status_many / trigger_issue_assignee, S1/S2 managed system hooks, shadow/canary + old-path mutual exclusion.

The execution flag stays off until all guardrails exist (§14), so no reaction ever fires from a partially-built engine.

@vercel

vercel Bot commented Jul 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
multica-docs Ready Ready Preview, Comment Jul 16, 2026 12:13pm

Request Review

@Bohan-J Bohan-J force-pushed the agent/j/e95f03a9 branch from 31e559b to 0fde4d4 Compare July 15, 2026 10:55
Bohan-J pushed a commit that referenced this pull request Jul 16, 2026
… (MUL-4332 PR1)

Second-round review must-fixes on PR #5467:

1. Concurrent status-only update no longer rolls back a concurrent writer's
   assignee (or other nullable field): LockIssueStatusForEvent now locks and
   returns the full row (LockIssueRowForUpdate); UpdateIssue/BatchUpdateIssues
   rebuild every untouched bare-narg column from the locked row instead of the
   pre-tx snapshot, and drive all post-commit side effects (realtime, enqueue,
   parent-notify) from that authoritative pre-image.

2. Bulk task.failed is now poison-isolated and bounded. Replaced the four
   condition-scoped fail UPDATEs with bounded candidate SELECTs (FOR UPDATE SKIP
   LOCKED + LIMIT) + a shared FailAgentTasksByIDs; FailBulkTasksWithEvents
   resolves each candidate's workspace, fails only the resolvable set with its
   event atomically, and skips (fail-closed) an unresolvable row without
   aborting the batch. Offline-runtime task cleanup now runs every tick
   (sweepOfflineRuntimeTasks), so a rolled-back or leftover orphan is retried
   instead of stranded.

3. Bulk task.failed events are attributed to SystemActor (sweeper/orphan
   recovery, not the agent) and carry retryable computed from the shared
   retryEligible predicate, so the event agrees with the post-commit auto-retry
   decision instead of hard-coding false.

4. Lock-internal pre-image now drives lock-external side effects: the stuck-issue
   reset re-checks status==in_progress and no-active-task UNDER the lock (CAS) so
   a user-completed issue is never reopened; the GitHub merge close suppresses
   the parent child-done comment / realtime status_changed on a no-op transition.

Nit: 196_domain_event.down.sql comment now says "Reverses 196".

Tests: concurrent reassign-not-rolled-back, poison-row isolation, transient
failure next-tick recovery, terminal-vs-reset race (lock-hold), GitHub no-op
suppression, and actor/retryable assertions on the bulk fail. Verified against a
fresh fully-migrated DB across domainevent/service/handler/cmd-server.

Co-authored-by: multica-agent <github@multica.ai>
@Bohan-J Bohan-J force-pushed the agent/j/e95f03a9 branch from dec6b91 to ffd0f75 Compare July 16, 2026 04:02
Bohan-J pushed a commit that referenced this pull request Jul 16, 2026
… (MUL-4332 PR1)

Rebased onto current main and renumbered the six domain_event migrations to
200–205 (main advanced to 199 with issue_property_icon + agent_task_attribution),
restoring the numeric-prefix uniqueness the CI lint enforces.

Second-round review must-fixes on PR #5467:

1. Concurrent status-only update no longer rolls back a concurrent writer's
   assignee (or other nullable field): LockIssueStatusForEvent now locks and
   returns the full row (LockIssueRowForUpdate); UpdateIssue/BatchUpdateIssues
   rebuild every untouched bare-narg column from the locked row instead of the
   pre-tx snapshot, and drive all post-commit side effects (realtime, enqueue,
   parent-notify) from that authoritative pre-image.

2. Bulk task.failed is now poison-isolated and bounded. Replaced the four
   condition-scoped fail UPDATEs with bounded candidate SELECTs (FOR UPDATE SKIP
   LOCKED + LIMIT) + a shared FailAgentTasksByIDs; FailBulkTasksWithEvents
   resolves each candidate's workspace, fails only the resolvable set with its
   event atomically, and skips (fail-closed) an unresolvable row without aborting
   the batch. Offline-runtime task cleanup now runs every tick
   (sweepOfflineRuntimeTasks), so a rolled-back or leftover orphan is retried
   instead of stranded.

3. Bulk task.failed events are attributed to SystemActor (sweeper/orphan
   recovery, not the agent) and carry retryable computed from the shared
   retryEligible predicate, so the event agrees with the post-commit auto-retry
   decision instead of hard-coding false.

4. Lock-internal pre-image now drives lock-external side effects: the stuck-issue
   reset re-checks status==in_progress and no-active-task UNDER the lock (CAS) so
   a user-completed issue is never reopened; the GitHub merge close suppresses
   the parent child-done comment / realtime status_changed on a no-op transition.

Nit: the domain_event down migration comment now says "Reverses 200".

Tests: concurrent reassign-not-rolled-back, poison-row isolation, transient
failure next-tick recovery, terminal-vs-reset race (lock-hold), GitHub no-op
suppression, and actor/retryable assertions on the bulk fail. Verified against a
fresh DB fully migrated to 205 (main + this branch) across
domainevent/service/handler/cmd-server, plus full up/down/replay.

Co-authored-by: multica-agent <github@multica.ai>
Bohan-J pushed a commit that referenced this pull request Jul 16, 2026
… (MUL-4332 PR1)

Rebased onto current main and renumbered the six domain_event migrations to
200–205 (main advanced to 199 with issue_property_icon + agent_task_attribution),
restoring the numeric-prefix uniqueness the CI lint enforces.

Second-round review must-fixes on PR #5467:

1. Concurrent status-only update no longer rolls back a concurrent writer's
   assignee (or other nullable field): LockIssueStatusForEvent now locks and
   returns the full row (LockIssueRowForUpdate); UpdateIssue/BatchUpdateIssues
   rebuild every untouched bare-narg column from the locked row instead of the
   pre-tx snapshot, and drive all post-commit side effects (realtime, enqueue,
   parent-notify) from that authoritative pre-image.

2. Bulk task.failed is now poison-isolated and bounded. Replaced the four
   condition-scoped fail UPDATEs with bounded candidate SELECTs (FOR UPDATE SKIP
   LOCKED + LIMIT) + a shared FailAgentTasksByIDs; FailBulkTasksWithEvents
   resolves each candidate's workspace, fails only the resolvable set with its
   event atomically, and skips (fail-closed) an unresolvable row without aborting
   the batch. Offline-runtime task cleanup now runs every tick
   (sweepOfflineRuntimeTasks), so a rolled-back or leftover orphan is retried
   instead of stranded.

3. Bulk task.failed events are attributed to SystemActor (sweeper/orphan
   recovery, not the agent) and carry retryable computed from the shared
   retryEligible predicate, so the event agrees with the post-commit auto-retry
   decision instead of hard-coding false.

4. Lock-internal pre-image now drives lock-external side effects: the stuck-issue
   reset re-checks status==in_progress and no-active-task UNDER the lock (CAS) so
   a user-completed issue is never reopened; the GitHub merge close suppresses
   the parent child-done comment / realtime status_changed on a no-op transition.

Nit: the domain_event down migration comment now says "Reverses 200".

Tests: concurrent reassign-not-rolled-back, poison-row isolation, transient
failure next-tick recovery, terminal-vs-reset race (lock-hold), GitHub no-op
suppression, and actor/retryable assertions on the bulk fail. Verified against a
fresh DB fully migrated to 205 (main + this branch) across
domainevent/service/handler/cmd-server, plus full up/down/replay.

Co-authored-by: multica-agent <github@multica.ai>
@Bohan-J Bohan-J force-pushed the agent/j/e95f03a9 branch from 116e59d to bdd7e99 Compare July 16, 2026 05:38
Bohan-J pushed a commit that referenced this pull request Jul 16, 2026
…-4332 PR1)

Address Elon's round-4 review on PR #5467.

1. recover-orphans keyset cursor + SKIP LOCKED race (point 1):
   SelectOrphanedTasksForRuntime now uses plain FOR UPDATE. The drain pages
   forward with a keyset cursor that advances permanently, so SKIP LOCKED could
   silently drop a briefly-locked older orphan and step the cursor past it,
   leaking it forever (the re-registered runtime is already online, so the
   offline sweep won't reap it). Plain FOR UPDATE waits for the lock and
   re-checks status, so the older row is included. Only this query changes;
   the sweepers re-scan every tick and keep SKIP LOCKED.

2. Legacy daemon {} backward compat (point 2):
   A current daemon now sends paginate:true and drives the drain itself. A
   legacy daemon POSTs {} once and ignores the body, so the server drains every
   page itself in bounded per-page transactions instead of leaving 501+ orphans
   stranded past the first page. Both modes share recoverOrphansPage.

Tests: TestRecoverOrphansWaitsForLockedOldestOrphan (blocks on a locked oldest
orphan, then fails it with its event once released), and
TestRecoverOrphansLegacyClientDrainsServerSide (501 rows, one {} call, all
failed + events). The existing paginated drain test now sends paginate:true.

Co-authored-by: multica-agent <github@multica.ai>
Bohan-J and others added 5 commits July 16, 2026 15:12
PR1 of the Event Hooks MVP: land the durable `domain_event` transactional
outbox and converge the v1 domain write paths onto it. There is NO consumer
yet — rows are written dispatch_status='pending' and nothing reads them, so
this is an additive, zero-behavior-change increment. The PR3 matcher/executor
will claim pending rows via the lease columns.

Foundation
- New `domain_event` table (MUL-4332 §4.1): fact envelope + causal chain +
  single-consumer outbox lease + monotonic seq. No FK / no cascade per the
  workspace rules; five CONCURRENTLY indexes in their own single-statement
  migrations; a 90-day retention sweeper (a no-op until events dispatch).
- `internal/domainevent`: versioned v1 event catalog, typed payload structs,
  a tx-aware Write (event commits atomically with the fact) and a WriteInTx
  helper that wraps otherwise-bare autocommit writes.

Write-path convergence (event committed in the same tx as the fact)
- issue.created: HTTP create, autopilot dispatch, onboarding (both shims).
- issue.status_changed + issue.assigned: single UpdateIssue, BatchUpdateIssues,
  GitHub merge auto-close, task-failure stuck-issue reset.
- task.completed / task.failed: CompleteTask / FailTask (status CAS makes the
  event exactly-once on daemon-callback replay).

Separate from internal/events (the in-memory Bus), which keeps serving realtime
UI. Verified: all 198 migrations apply + reverse cleanly; writer atomicity
(commit persists / rollback persists nothing / WriteInTx all-or-nothing) on a
live DB; the full service + handler suites pass; an end-to-end test proves the
outbox fires through the real HTTP handlers.

Co-authored-by: multica-agent <github@multica.ai>
… PR1)

Rebased onto main and resolved all six merge-blocking review points:

1. Renumber migrations 196–201 (193–195 are now issue_property on main); the
   TestMigrationNumericPrefixesStayUniqueAfterLegacySet lint passes again.
2. Converge the remaining v1 producers in-tx: agent-runtime and system
   child-done comments (comment.created), and all four bulk task.failed paths
   — the three runtime sweepers plus daemon orphan-recovery — through a shared
   FailTasksInTxWithEvents helper. No terminal path commits a fact without its
   event.
3. Read issue status/assignee under a row lock INSIDE the update tx so the
   event `from` is the true edge under concurrent transitions (single + batch
   UpdateIssue, GitHub merge close, task-failure reset), not a pre-tx snapshot.
4. Task-event workspace resolution is fail-closed: resolve via the full
   attribution chain (issue/chat/autopilot/quick-create/agent) on the tx handle,
   and roll the terminal transition back if unresolvable rather than skipping
   the event best-effort.
5. Retention is now an explicit no-op until PR3 — removed the weak
   dispatched+TTL delete that lacked the hook_execution terminal predicate.
6. Actor identity is fail-closed: ActorFrom no longer laundelrs an unknown type
   into system, and validate rejects a system actor carrying an id or a
   member/agent/hook actor missing one.

Tests: concurrent status-edge correctness (-race), fail-closed workspace
resolution, bulk task.failed emission via the shared helper, and expanded
actor-validation cases. Full internal/service, internal/handler,
internal/domainevent and migration-lint suites pass on a migrated DB.

Co-authored-by: multica-agent <github@multica.ai>
… (MUL-4332 PR1)

Rebased onto current main and renumbered the six domain_event migrations to
200–205 (main advanced to 199 with issue_property_icon + agent_task_attribution),
restoring the numeric-prefix uniqueness the CI lint enforces.

Second-round review must-fixes on PR #5467:

1. Concurrent status-only update no longer rolls back a concurrent writer's
   assignee (or other nullable field): LockIssueStatusForEvent now locks and
   returns the full row (LockIssueRowForUpdate); UpdateIssue/BatchUpdateIssues
   rebuild every untouched bare-narg column from the locked row instead of the
   pre-tx snapshot, and drive all post-commit side effects (realtime, enqueue,
   parent-notify) from that authoritative pre-image.

2. Bulk task.failed is now poison-isolated and bounded. Replaced the four
   condition-scoped fail UPDATEs with bounded candidate SELECTs (FOR UPDATE SKIP
   LOCKED + LIMIT) + a shared FailAgentTasksByIDs; FailBulkTasksWithEvents
   resolves each candidate's workspace, fails only the resolvable set with its
   event atomically, and skips (fail-closed) an unresolvable row without aborting
   the batch. Offline-runtime task cleanup now runs every tick
   (sweepOfflineRuntimeTasks), so a rolled-back or leftover orphan is retried
   instead of stranded.

3. Bulk task.failed events are attributed to SystemActor (sweeper/orphan
   recovery, not the agent) and carry retryable computed from the shared
   retryEligible predicate, so the event agrees with the post-commit auto-retry
   decision instead of hard-coding false.

4. Lock-internal pre-image now drives lock-external side effects: the stuck-issue
   reset re-checks status==in_progress and no-active-task UNDER the lock (CAS) so
   a user-completed issue is never reopened; the GitHub merge close suppresses
   the parent child-done comment / realtime status_changed on a no-op transition.

Nit: the domain_event down migration comment now says "Reverses 200".

Tests: concurrent reassign-not-rolled-back, poison-row isolation, transient
failure next-tick recovery, terminal-vs-reset race (lock-hold), GitHub no-op
suppression, and actor/retryable assertions on the bulk fail. Verified against a
fresh DB fully migrated to 205 (main + this branch) across
domainevent/service/handler/cmd-server, plus full up/down/replay.

Co-authored-by: multica-agent <github@multica.ai>
…-4332 PR1)

Address Elon 3rd-round review (2 must-fixes):

1. recover-orphans now drains every orphan for a runtime instead of capping
   at one 500-row page. Registration flips the runtime back online, so the
   offline sweep never reaps rows left past the first page; SelectOrphanedTasksForRuntime
   gains a (created_at, id) keyset cursor, the handler returns has_more +
   next_cursor per page, and the daemon client loops until drained. The cursor
   advances over skipped poison rows so a page of poison at the front can't stall
   the drain. Tests: handler limit+1 end-to-end drain, service poison-next-page.

2. task.failed.retryable renamed to retry_eligible with atomic, decidable
   semantics: it reports only that the failure met the eligibility predicate at
   commit time, computed in the same tx as the fail+event on every path. It no
   longer promises a fresh attempt will arrive — the bulk paths create the retry
   child best-effort after commit. Dropped the "not yet terminal, a fresh attempt
   is coming" consumer guarantee. Test: retry child crash-window contract.

Co-authored-by: multica-agent <github@multica.ai>
…-4332 PR1)

Address Elon's round-4 review on PR #5467.

1. recover-orphans keyset cursor + SKIP LOCKED race (point 1):
   SelectOrphanedTasksForRuntime now uses plain FOR UPDATE. The drain pages
   forward with a keyset cursor that advances permanently, so SKIP LOCKED could
   silently drop a briefly-locked older orphan and step the cursor past it,
   leaking it forever (the re-registered runtime is already online, so the
   offline sweep won't reap it). Plain FOR UPDATE waits for the lock and
   re-checks status, so the older row is included. Only this query changes;
   the sweepers re-scan every tick and keep SKIP LOCKED.

2. Legacy daemon {} backward compat (point 2):
   A current daemon now sends paginate:true and drives the drain itself. A
   legacy daemon POSTs {} once and ignores the body, so the server drains every
   page itself in bounded per-page transactions instead of leaving 501+ orphans
   stranded past the first page. Both modes share recoverOrphansPage.

Tests: TestRecoverOrphansWaitsForLockedOldestOrphan (blocks on a locked oldest
orphan, then fails it with its event once released), and
TestRecoverOrphansLegacyClientDrainsServerSide (501 rows, one {} call, all
failed + events). The existing paginated drain test now sends paginate:true.

Rebased onto main (inbox index migrations landed at 200/201); renumbered the
six domain_event migrations 200-205 -> 202-207 and fixed their cross-references.

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
…332 PR2)

Builds the Event Hooks policy & debug layer on top of the PR2 persistence
model (hook/revision/state/execution/effect tables, 208-217). Store-only and
gated behind the automation_event_hooks feature flag (default off), so it adds
zero runtime behavior until the matcher/executor slice ships: creating hooks
persists rows that nothing yet consumes.

- internal/automation: the user-authored hook spec (when/if/fire/do), the
  machine-readable event schema registry that declares which envelope/payload
  fields each domain event exposes for matching (§5.3/§6), the fixed condition
  and action vocabularies, and a pure typed Validator. The validator rejects an
  illegal rule at the API boundary (unknown/undeclared fields, bad uuids, wrong
  action shape, >8 actions, system-only actions on a user hook) and enforces the
  rising_edge dependency rule: a latch hook must listen to exactly the event that
  can change its condition (§5.2).
- pkg/db/queries/hook.sql: workspace-scoped CRUD for hooks, immutable revisions,
  and the execution trace. Hook + revision #1 are created together with
  app-generated ids (they reference each other, no FK).
- internal/service/hook.go: HookService — create (validate → resolve accountable
  principal → tx insert hook+revision), get, list, patch (append immutable
  revision + repoint active), enable/disable, soft-archive, executions read.
  System-managed hooks are not editable through this API (§8).
- internal/handler/hook.go + routes: /api/hooks REST surface, every endpoint
  gated on the feature flag (404 when off) and resolving the audit creator +
  accountable human principal — an agent author with no resolvable member is
  refused (§8).
- cmd/multica/cmd_hook.go: hook create/list/get/update/enable/disable/delete/
  executions CLI.

Tests: automation validator units (accepts A2 join / per_event, rejects 20+
illegal shapes, rising-edge coverage, ParseMatch clauses); handler CRUD
lifecycle (create→get→update-appends-revision-2→list→disable→enable→
executions→soft-archive→404), feature-flag-off 404, invalid-spec 400, and
agent-without-principal 403.

Remaining MVP slices continue in this PR: PR3 matcher/executor + guardrails,
PR4 actions/autopilot, PR5 stage sensor + system hooks. The execution flag
stays off until all guardrails exist (§14).

Co-authored-by: multica-agent <github@multica.ai>
…L-4332 PR2)

Address Elon's four must-fixes on the hook CRUD layer. All four become real
authorization / correctness issues once PR3 starts consuming saved rules.

1. [P0] Edit authorization gate. Any workspace member could PATCH/enable/disable/
   delete any user hook while it kept running under the original principal
   (authorization-principal substitution). Now only the hook's original
   authorization principal OR a workspace owner/admin may modify it; the
   principal is NOT transferred on edit. UpdateHook/SetEnabled/ArchiveHook
   enforce authorizeHookEdit. (Deny-by-default per the proposed semantic,
   pending Bohan's final confirmation; easy to switch to principal-transfer.)

2. [P0] Fail-closed target/workspace validation. Create/update now validate,
   in the write transaction under the current principal, that every referenced
   target exists in the hook's workspace: issue scope, condition issue ids, and
   action issue/agent/member/autopilot targets — with the trigger_agent target
   also checked for archived/runtime and canInvokeAgent admission. The resolved
   principal must still be a workspace member. A missing target now yields 400,
   not a persisted 201 (§13). Adds GetMemberInWorkspace.

3. [P1] Strict schema. The handler decodes with DisallowUnknownFields (rejects
   unknown top-level/nested keys); the validator rejects per-action
   disallowed-but-known fields (e.g. agent_id on add_comment), validates the
   issue-status enum on set_issue_status / issues_status / issue_field(status),
   and bounds id-set and message sizes.

4. [P1] Concurrent PATCH. UpdateHook now SELECT ... FOR UPDATE locks the hook
   row and re-checks archived/origin/authorization inside the lock before
   allocating MAX(revision)+1, so concurrent edits serialize instead of
   colliding on idx_hook_revision_unique (was 23505 → 500). Adds GetHookForUpdate.

Tests: strict-schema rejections (unknown field, disallowed action field, bad
status enum), fail-closed targets (nonexistent issue/member/agent/autopilot →
400; real invokable agent → 201), edit authorization (non-principal non-admin →
403 on patch/disable/delete; principal and owner/admin → 200; principal never
transferred), and 8-way concurrent PATCH asserting contiguous revisions 1..9
with no error.

dry-run/explain + event-correlation queries are re-sliced to PR3 (they need the
matcher's evaluation), pending Bohan's confirmation.

Co-authored-by: multica-agent <github@multica.ai>
…-4332 PR2)

Address Elon's round-3 review on the hook policy layer.

1. Admission by the hook's STORED principal, not the editor. Previously
   resolveHookWriter captured the requester's principal in a canInvoke closure,
   so a workspace admin editing member A's hook could add a trigger_agent for
   the admin's own private agent — saved as A, who cannot invoke it. Target
   admission is now evaluated inside the write transaction against the principal
   read from the FOR UPDATE-locked hook row (the creator on create, the stored
   principal on update). New internal/admission package holds the pure agent/
   autopilot predicates so handler and service share one source of truth.

2. run_autopilot now checks the stored principal's write permission
   (role/authorship/collaborator, reusing memberCanWriteAutopilot semantics via
   admission.AutopilotWriteByOwnership), not mere existence. issue_field
   parent_issue_id / assignee_id eq|in operands are now validated to resolve to
   workspace resources (issue, or member/agent for assignees).

3. Membership, role and the edit gate are re-derived INSIDE the write
   transaction: create re-checks the principal is a current member; update/
   enable/disable/archive load the FOR UPDATE-locked row, re-read the editor's
   live membership/role, authorize, and mutate in one transaction. HookAuthor no
   longer carries a handler-computed IsWorkspaceAdmin snapshot.

4. decodeHookSpec now rejects a smuggled trailing second JSON document (a second
   Decode must return io.EOF), which DisallowUnknownFields alone did not catch.

Tests: admission unit tests; stored-principal admission (admin cannot add a
private agent A can't invoke → 400; owner's own hook may → 201); run_autopilot
write-permission (non-writer member → 400, creator → 201); issue_field operand
validation; non-member principal rejected at the service (nothing persisted);
trailing-JSON rejection. Existing CRUD/auth/concurrency/strict-schema/fail-closed
suites still pass.

Co-authored-by: multica-agent <github@multica.ai>
@Bohan-J Bohan-J changed the title feat(automation): transactional-outbox domain event layer (MUL-4332 PR1) feat(automation): event outbox + hook policy layer (MUL-4332 PR1+PR2) Jul 16, 2026
…ssion (MUL-4332 PR2)

Address Elon's round-4 review on the hook policy layer.

1. Stored principal's live membership is now a HARD gate on re-arming a hook.
   Update and Enable load the FOR UPDATE-locked hook's stored principal as a
   current workspace member and fail closed (403) if it departed — an admin can
   no longer re-point or re-enable a departed principal's hook. Disable/archive
   stay allowed (safe degrading). validateTargets is now only ever called with a
   pre-resolved live-member principal, and AgentInvocableByMember returns false
   for a non-member even in the agent-owner branch.

2. run_autopilot admission now also gates the autopilot's ASSIGNEE invocation,
   not just write ACL: it resolves the assignee to its executing agent (the
   agent, or a non-archived squad's leader) and requires the stored principal be
   able to invoke it; a dangling/archived assignee fails closed. A collaborator
   with write access whose autopilot targets an agent they cannot invoke is now
   rejected at save. (Shared checkAgentInvocable with trigger_agent.)

3. issue_field.assignee_id operands are validated with the correct identity
   types: a MEMBER operand is a USER id (GetMemberByUserAndWorkspace, not a
   member-row id — a real member's user id was previously 400'd), plus agent and
   non-archived squad. Issue assignees are polymorphic member|agent|squad.

Tests: departed-principal gate (update/enable 403, disable/archive allowed);
collaborator-write autopilot with un-invocable assignee rejected, owner accepted;
assignee_id member user id / agent / squad accepted, ghost rejected; admission
unit test for the non-member owner branch. No migration/sqlc changes.

Co-authored-by: multica-agent <github@multica.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant