# Direct realtime / no-outbox enforcing allowlist

> Canonical path: `guardrails/message/direct-no-outbox-baseline.md`
> Enforced by: `guardrails/message/direct-no-outbox.sh`

This file is the message-domain **enforcing allowlist** for production paths
that intentionally use direct realtime or write canonical message facts without
creating a `scope_fanout_outbox` job. The filename still says `baseline` for
compatibility with existing links, but the contract is no longer passive.

Ordinary human/agent/app message creation must use the canonical message fact
path and a durable `scope_fanout_outbox` job. Do not add a production direct
realtime or no-outbox path by editing code alone: update this allowlist, the
enforcing guardrail, and the relevant canonical spec so the exception has a
named owner, durable facts, recovery story, and regression coverage.

## Allowlist columns

- **Path / owner** — current code location and owning domain.
- **Durable facts** — facts written in the transaction or committed store path.
- **Direct/no-outbox shape** — whether no `scope_fanout_outbox` is written or a
  direct post-commit notification is used.
- **Recovery / fallback** — how clients or agents recover if direct delivery is
  missed.
- **Enforcement notes** — the guardrail surface that must change before this
  exception can move, disappear, or expand.

## Enforced exceptions / special paths

| Path / owner | Durable facts | Direct/no-outbox shape | Recovery / fallback | Enforcement notes |
| --- | --- | --- | --- | --- |
| `internal/api/im/reactions_handler.go`, `internal/api/agent/reaction_flow.go`, `internal/store/reaction/reactions.go`, `internal/api/im/stream_events_projector.go`, and `internal/api/im/fanout/{fanout.go,stream_events.go}` (`ReactionsHandler.Add` / `Remove`, agent `ReactionHandler.Add` / `Remove`) | `scope_message_reactions`; add to an agent-authored target may also write `scope_activity_events` with `activity_ref_type='reaction'`. | Production reaction handlers publish committed reaction changes directly through Workspace IM stream events or `DeliveryFanout.DeliverReactionAdded` / `DeliverReactionRemoved`. There is no production `scope_fanout_outbox` row for reaction fanout. | Browser can refetch reaction aggregates with history/message views. Agent-facing add signals use durable `scope_activity_events` when created; missed direct wake/realtime is repaired by receive/refetch rather than a reaction outbox job. | `direct-no-outbox.sh` allowlists only the IM reaction handler/projector, agent handler, and fanout files for direct reaction publish calls. `specs/message/reaction.md` must keep the direct post-commit reaction fanout exception explicit. |
| `internal/api/im/rewind_handler.go` and `internal/api/im/stream_events_projector.go` (`RewindHandler`) | Rewind applies message-domain suffix lifecycle mutation through rewind/message stores and suppresses/scrubs derived facts. It also appends one durable Human-visible `system` notice row after the rewind operation succeeds. | After the store operation commits, the API appends a direct `conversation.rewound` invalidation through `publishConversationRewoundEvent` and `publishRewindNotice` directly publishes the notice's `message.created` projection. By product decision, the invalidation/notice does not enter ordinary unread, notification, or Agent-wake fanout. | Clients invalidate/refetch conversation caches; the notice is recoverable from the durable system message row through history/refetch. Message sync is not a tombstone page; durable rewind facts live in the rewind/message stores. | `direct-no-outbox.sh` allowlists only the IM rewind handler/projector for the named direct rewind projections. `rewind.sh` owns the deeper rewind lifecycle/projection contract. |
| `internal/store/app/channel_member_events.go` (`appendChannelMemberEventMessage`), `internal/api/channel/api_channel_membership.go` (`emitChannelMemberEvent`), `internal/store/app/agent_lifecycle.go` (`CreateAgentForUser` / `CreateWorkspaceAgentInTx`), and `internal/service/channeladmin/service.go` (Agent Exec member add/remove via `agentteam exec channel`) | Writes a channel-scope `scope_messages` row of type `system` with channel member event metadata through `ApplyMessagePlanTx`, and the Agent Exec service records a terminal idempotency receipt in the same transaction as the membership mutation and the system message. This includes initial explicit-channel memberships created by regular Agent creation, duplication, and role materialization; the implicit `#all` private roster remains silent. | Uses `MessagePlanSideEffectProfileNoOutbox`, then a committed owner calls the named `EmitCommittedMessageProjectionOnly` policy to publish only the browser `message.created` presentation. `CreateAgentForUser` retains all event results and emits them only after its own commit. Caller-owned `CreateWorkspaceAgentInTx` returns the transaction-bound results; its caller must discard them on rollback or publish each only after commit. The Agent Exec service emits the projection-only `message.created` after its transaction commits, driven by in-transaction captured effects; exact receipt replay is a pure cache hit, and receipt conflict/no-op/rollback emit nothing. By product decision none of these paths applies ordinary unread, user notification, Agent wake, or outbox completion. | The system message row, membership facts, and Agent Exec receipt are durable in the channel transcript and can be read through channel history/sync. Missed immediate UI projection is repaired by refetch/sync rather than a no-outbox fanout job. A rolled-back Agent create (or a rolled-back Agent Exec mutation) leaves neither the membership/event fact nor a realtime projection. | `direct-no-outbox.sh` allowlists the Store boundaries, the exact Channel API caller, the Agent Exec service file, and the projection-only shared-fanout method; the AST inventory closes regular/duplicate/role Agent-create callers, rejects discarded initial-membership results, and requires the Store-owned emit after commit. It also rejects ordinary `EmitCommittedHumanMessage`/`DeliverCommittedMessage` from these membership projection paths. Ordinary channel/DM/reply-thread message sends remain non-exceptions. |
| `internal/store/taskboard/detail_mutation.go` (`anchorTaskDescriptionAttachments`) | Writes one hidden channel-scope `scope_messages` row of type `task_description_attachment` and atomically claims the referenced attachment sidecars for the task description. | Uses `MessagePlanSideEffectProfileNoOutbox` through `ApplyMessagePlanTxWithSidecarWriter`; the row is an attachment authority anchor, not a Human-visible transcript message, so it intentionally creates no unread, notification, realtime message, or Agent wake. | Task detail/description reads expose the durable attachment references, and download authorization follows the persisted hidden anchor plus attachment claims. No realtime message replay is required. | `direct-no-outbox.sh` allowlists `detail_mutation.go` for both the no-outbox profile and the sidecar-writing applier sink. The helper name, hidden message type, and sidecar sink must remain explicit; ordinary task discussion creation remains a non-exception. |

## Explicit non-exceptions

| Path / owner | Why it is not no-outbox |
| --- | --- |
| `internal/store/privateagent/conversation_write.go` ordinary channel/DM/reply-thread message sends | The ordinary conversation builder declares `MessagePlanSideEffectProfileScopeFanout` and passes `BuildScopeMessageFanoutOutboxRecord(...)` in `MessagePlan.OutboxEvents`. The message write is ordinary scope fanout, not direct/no-outbox. `post-commit-producers-ast.go` closes the exported and lowercase writer chain and admits the file's single `ApplyMessagePlanTxWithSidecarWriter` call only in `writeScopeMessageToConversationResult`, with an exact function-local count; it also locks the final lowercase `message.applyMessagePlanTx` seam to the two exported applier wrappers. The file-level allowlist alone is not authority for another producer. |
| `internal/store/message/workflow_scope.go` Workflow product-scope requests and replies | The product-scope writer declares `MessagePlanSideEffectProfileProductScopeFanout`, builds one durable `scope_fanout_outbox` record, and applies both the message fact and outbox intent in the caller-owned transaction. It is an ordinary outbox-backed producer, not a direct/no-outbox exception. |
| `internal/store/taskboard/task_scope_message.go` ordinary task discussion creation | It declares `MessagePlanSideEffectProfileScopeFanout` and passes `BuildScopeMessageFanoutOutboxRecord(...)` in `MessagePlan.OutboxEvents`. The first row can be nil-payload, but it is still an outbox row. |
| `internal/store/app/task_scope_discussion_delivery.go` task discussion delivery projection | It fills/inserts the task discussion fanout payload and calls `SetPayloadForMessage`; `payload_missing` is a transitional suppression guard, not a no-outbox product semantic. |
| `internal/store/app/service_event_task_delivery.go` service/app-authored Task discussion append (`appendServiceEventTaskDiscussionTx`) | The canonical service/app-authored Task discussion seam writes a visible `task_discussion` `scope_messages` row with `MessagePlanSideEffectProfileScopeFanout` and an ordinary `BuildScopeMessageFanoutOutboxRecord(...)` outbox event, then appends the frozen targeted audience/outbox payload through `projectTaskScopeDiscussionDeliveryTx`. Provider events (GitHub/Gitea) return the delivery through `AppWebhookResult.Deliveries` so `DeliverFollowupMessages` owns first fanout; external App runtime replies prepare a store-owned recoverable post-commit token via `preparePostCommitMessage`. The retired silent/no-outbox `appendServiceEventTaskDiscussionEntry` path no longer exists. |

## Enforcement procedure

Run the guardrail whenever changing message write paths, reaction fanout,
rewind realtime, service-event task-target delivery, or message-domain specs:

```sh
guardrails/message/direct-no-outbox.sh --check-only
```

Use inventory mode to inspect the current allowlist and direct/no-outbox code
references:

```sh
guardrails/message/direct-no-outbox.sh --inventory
```

A change that introduces a new production no-outbox `MessagePlan` call site,
new direct reaction/rewind emitter, or new `ApplyMessagePlanTx` /
`ApplyMessagePlanTxWithSidecarWriter` production owner must fail until this
allowlist and the script are updated intentionally.
