Ctrl K
ComunidadGuías…

EVENTS.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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`.