EVENTS.md
Rama main · réplica de lectura del repositorio original.
# Event Contracts
Good Phorum stores durable domain events in `event_log` and delivery work in
`outbox_messages`.
## Standard Envelope
`GPForum::Domain::EventEnvelope` defines the canonical event shape.
Required event record fields:
- `event_id`
- `event_type`
- `schema_version`
- `aggregate_type`
- `aggregate_id` when the event has a concrete aggregate
- `aggregate_version`
- `actor_id`
- `correlation_id`
- `causation_id`
- `idempotency_key`
- `payload`
- `metadata`
- `created_at`
Transport payloads keep legacy worker compatibility:
- top-level `event_type`, `aggregate_type`, `aggregate_id`, `actor_id`;
- top-level `domain_payload`;
- structured `aggregate`, `actor`, `metadata`, and `transport`;
- `contract => gpforum.domain_event`;
- `contract_version => 1`.
## Transport Readiness
The envelope includes transport metadata for:
- Minion: `domain_event.dispatch`;
- PostgreSQL LISTEN/NOTIFY: `gpforum_domain_events`;
- future stream topics: `gpforum.domain_events`;
- future NATS/Kafka partitioning by aggregate id.
Realtime transport now uses a separate public websocket envelope through
`GPForum::Service::Realtime::EventEnvelope`. Domain transport payloads remain
the worker contract; realtime payloads are derived, bounded JSON messages for
fanout only.
## Recording Boundary
`GPForum::Infrastructure::EventRecorder` records:
- the event log row;
- the corresponding outbox message;
- related audit rows when requested.
Canonical audit hashing and verification live in
`GPForum::Infrastructure::AuditRecord`. The recorder still looks up
`previous_hash` and persists the AuditLog row.
Adopted write paths:
- forum thread creation;
- forum post creation;
- forum post revision (`post.updated`);
- forum post author delete (`post.deleted`);
- forum post author restore (`post.undeleted`);
- forum thread title revision (`thread.updated`);
- forum thread author delete (`thread.deleted`);
- forum thread author restore (`thread.undeleted`);
- forum thread category move (`thread.moved`);
- moderation report lifecycle (`Moderation::Event` owns created, duplicate,
and transition hashes; `ReportStore` persists them);
- moderation action lifecycle (`Moderation::Event` owns created-action and
reversal hashes; `ActionStore` persists them);
- moderation suspension lifecycle (`Moderation::Event` owns suspend and revoke
hashes; `SuspensionStore` persists them);
- privacy deletion workflow (`Privacy::Event` owns request, approval, hold,
block, and completion hashes; the deletion workflow persists them);
- privacy retention holds (`Privacy::Event` owns created-hold envelopes and
audit hashes; `RetentionHoldStore` persists them);
- privacy export request lifecycle;
- attachment upload, scan, quarantine, and deletion lifecycle
(`Attachment::Event` owns envelopes and audit hashes; the store persists
them);
- admin role catalog and role binding audit lifecycle (`Admin::Event` owns
audit hashes; `RoleCatalog` and `RoleBindingStore` persist them);
- identity registration events and identity security audits
(`Identity::Event` owns `user.registered` envelopes, typed identity audit
hashes, and login/logout request hashes; `Identity::Audit` and
`Identity::SecurityAudit` persist them);
- rate-limit block audits;
- mention fanout-limit audits.
The service layer no longer writes `EventLog`, `OutboxMessage`, or `AuditLog`
rows directly; those writes route through the infrastructure recorder.
## Cataloged Domain Events (emitted)
Payload fields below match the emitting store/`*::Event` modules. Envelope
fields follow `GPForum::Domain::EventEnvelope` (`schema_version` 1 unless
noted). The formal ADR catalog also lives in
[ADR 0091](docs/adr/0091-executable-architecture-contract.md) and
[ADR 0071](docs/adr/0071-event-catalog-workflow-contracts.md).
### Attachment
- `attachment.uploaded`
- producer: `GPForum::Service::Attachment::Store` via
`Attachment::Event`
- aggregate_type: `attachment`; aggregate_id: `attachment_id`
- idempotency_key: `attachment.uploaded:{attachment_id}`
- payload: `attachment_id`, `byte_size`, `media_type`, `object_key`,
`owner_user_id`
- `attachment.deleted`
- producer: `GPForum::Service::Attachment::Store` via
`Attachment::Event`
- aggregate_type: `attachment`; aggregate_id: `attachment_id`
- idempotency_key: `attachment.deleted:{attachment_id}`
- payload: `attachment_id`, `reason`
- `attachment.scanned` / `attachment.quarantined`
- producer: scan path in `Attachment::Store` via
`Attachment::Event::scan_event_type`
- payload: `attachment_id`, `reason`, `scan_status`
### Moderation reports
- `report.created`
- producer: `GPForum::Service::Moderation::ReportStore` via
`Moderation::Event::report_created_envelope`
- aggregate_type: `report`; aggregate_id: `report_id`
- idempotency_key: `report.created:{report_id}`
- payload: `reason`, `report_id`, `target_id`, `target_type`
- `report.assigned`
- producer: `ReportStore::assign_report` via
`Moderation::Event::report_transition_envelope`
- aggregate_type: `report`; aggregate_id: `report_id`
- idempotency_key: `report.assigned:{report_id}:{event_id}`
- payload: `report_id`, `target_id`, `target_type`,
`assigned_moderator_user_id`
- `report.released`
- producer: `ReportStore::release_report` via
`report_transition_envelope`
- aggregate_type: `report`; aggregate_id: `report_id`
- idempotency_key: `report.released:{report_id}:{event_id}`
- payload: `report_id`, `target_id`, `target_type`,
`assigned_moderator_user_id` (set to undef / null on release)
- `report.resolved`
- producer: `ReportStore::resolve_report` via
`report_transition_envelope`
- aggregate_type: `report`; aggregate_id: `report_id`
- idempotency_key: `report.resolved:{report_id}:{event_id}`
- payload: `report_id`, `target_id`, `target_type`, `resolution`,
`resolved_at`
### Search
The search handler's own continuations: each carries one batch of work to
the next outbox message, and only the search handler consumes them.
- `search.rebuild_requested` / `search.rebuild_completed`
- producer: `GPForum::Service::Search::RebuildRun` (the console's
rebuild, one step per message)
- aggregate_type: `search_rebuild`; aggregate_id: `run_id`
- idempotency_key: `{event_type}:{run_id}:{stage}:{after}`, `start` for
either when absent
- payload: `entity_type`, `run_id`, `step`, `totals`, and `stage`,
`after` while steps remain
- `search.thread_posts_requested`
- producer: `GPForum::Worker::Handler::SearchIndexing`, when a renamed,
moved, restored or undeleted thread, or one whose moderation was
reversed, has more posts than one batch
- aggregate_type: `thread`; aggregate_id: `thread_id`; causation_id: the
event whose message asked for it (the one that started the chain, then
each batch's own)
- idempotency_key: `search.thread_posts:{cause_event_id}:{after}`
- payload: `after` (the position the batch starts after),
`cause_event_id` (the event that started the chain: the thread event or
the `moderation_action.reversed`), `thread_id`
## Realtime Propagation
`GPForum::Service::Outbox::DomainEventTransport` can map domain events to
realtime events and publish them through PostgreSQL `NOTIFY`.
Current mappings:
- `post.created` -> `thread.update`;
- `post.updated` -> `thread.update`;
- `post.deleted` -> `thread.update`;
- `post.undeleted` -> `thread.update`;
- `thread.created` -> `thread.update`;
- `thread.updated` -> `thread.update`;
- `thread.deleted` -> `thread.update`;
- `thread.undeleted` -> `thread.update`;
- `thread.moved` -> `thread.update`;
- `thread.hidden` -> `thread.update`;
- `thread.restored` -> `thread.update`;
- `moderation.*` and `report.*` -> `moderation.queue.invalidate`.
No domain event maps to a badge. The notification dispatcher NOTIFYs a
`notification.badge` on the same channel whenever an unread count changes,
from a web request or from the worker's fanout, so every process sees it
once; the hub sends the same frame as a snapshot when a client subscribes to
`notifications:<user_id>`. See `docs/realtime.md`.