KB-791E

13 — event_outbox Transition Plan (event-vs-job coexistence, no duplicate SoT)

9 min read Revision 1
design-packdieu-45event-outboxtransition-planevent-vs-jobno-duplicate-sotdesign-only

13 — event_outbox Transition Plan

DESIGN-ONLY. Cites Điều 45 §6.6 (event vs job), §20.1 (transitional), §3.3. Explains how the existing live event substrate keeps running while the new job substrate (DP2) is added — without duplicating the source of truth.


§1. Goal

Reassure that introducing job_queue (DP2) does NOT:

  • Modify event_outbox schema.
  • Change event_outbox semantics.
  • Create a second fact ledger.
  • Create a second job ledger.

It only adds a new layer (jobs) that may be produced by events and may produce events, while neither owns the other's truth.


§2. The single-SoT rule

Layer Truth holder What it answers
Event layer event_outbox "What happened?"
Job layer job_queue "What needs doing? What is being done? What was the result of doing it?"
Read-state layer event_read "Who has seen this fact?"
Audit ledger dot_iu_command_run "Which function call produced which mutation?"

A consumer who needs to know whether a fact happened reads event_outbox. A consumer who needs to know whether the action triggered by that fact has finished reads job_queue. There is no row in event_outbox that describes a job's progress (that's a job_queue concern). There is no row in job_queue that describes a fact that wasn't acted on (that's an event_outbox concern).


§3. The two directions

§3.1 Event → Job (via consumer_registry, DP5)

event_outbox row inserted (fact)
        │
        ▼
worker scans event_outbox for new events (DP1 cadence)
        │
        ▼
fn_consumer_dispatch(event_id) (DP5)
        │
        ▼
INSERT job_queue rows for each matched consumer
        │
        ▼
job_queue row references event_outbox via causation_event_id

Idempotency: (job_kind, idempotency_key) UNIQUE — re-dispatch of the same event is a no-op.

§3.2 Job → Event (via fn_iu_emit_event from job executor)

job_queue row in succeeded/failed (terminal)
        │
        ▼
fn_job_succeed / fn_job_fail_* (DP2)
        │
        ▼ (side-effect inside the gated function)
fn_iu_emit_event(domain='dot', type='job.succeeded'|'job.failed', subject_table='job_queue', subject_ref=job_id)
        │
        ▼
event_outbox row inserted (job-completion fact)

This emits a "job X finished" fact, separately from the original "fact F caused job X to be enqueued" fact. Two related rows, two different timestamps, two different id values. They are linked by correlation_id (preserved through dispatch) or by event_outbox.event_subject_ref = job_id.


§4. What does NOT happen

  • event_outbox rows are never updated by job lifecycle changes. The fact is immutable.
  • job_queue rows are never the place to look for "did this event happen?" — only event_outbox.
  • A consumer never reads from both tables to reconstruct fact-state. It picks one source per concern.
  • An event whose only consumer is "log it" (e.g. system/issue_opened) does NOT generate a job row. Jobs exist only when there is execution work.

§5. Legacy compatibility

Legacy surface Status
iu_notification_event (0 rows) Survey Q7 still open. Pack proposes: deprecate (drop) in a separate cleanup pack after Council confirms no remaining consumer.
iu_notification_read (0 rows) Same.
fn_iu_auto_instantiate_from_event Becomes the implementation of consumer_id='auto_instantiate_from_template' in consumer_registry. Function body unchanged; dispatch becomes config-driven.
dot_iu_command_run Unchanged. Continues to hold the run ledger. New job_queue rows reference runs via correlation_id or via FK on a run_id column (DP2-Q5).

§6. Migration footprint summary (across the whole pack)

Live object This pack's effect
event_outbox Unchanged. No schema mutation. (Phase 1 of doc 14.)
event_pending Unchanged.
event_read Unchanged.
event_subscription Unchanged schema; rows added per DP6 (Phase 2).
event_type_registry Unchanged schema; ratification of new types (system/queue_worker_silent, dot.job.*, optional customer.*) per Council.
iu_route_worker_cursor Unchanged.
iu_route_dead_letter Unchanged. DP3 may add job_dead_letter as a sibling.
iu_sql_event_route CHECK widened (DP5). Schema additive.
dot_iu_runtime_lease Unchanged. DP3 exercises it.
iu_core.iu_staging_record Unchanged.
iu_core.iu_staging_payload Unchanged.
dot_iu_command_run Unchanged.
dot_iu_command_catalog Unchanged.
iu_auto_instantiate_event_log Unchanged.
iu_lifecycle_log Unchanged.
iu_vector_sync_point Unchanged.
iu_notification_event/read Pending Council Q7.

New objects (proposed, Phase 1):

job_queue                        (new)
job_workflow                     (new, Phase 6 candidate)
job_dead_letter                  (new)
queue_heartbeat                  (new)
consumer_registry                (new)
job_subscription                 (new)
v_queue_health                   (new view)
v_job_queue_backlog              (new view)
v_job_queue_in_progress          (new view)
v_job_queue_dead_letter_open     (new view)
v_consumer_registry_active       (new view)
v_consumer_dispatch_recent       (new view)
v_sql_event_route_active         (new view)
v_subscription_broadcast_used    (new view)
v_executor_subscription_health   (new view)
v_job_workflow_health            (new view)
v_queue_retention_pressure       (new view)
v_dead_letter_all                (new view)
fn_job_enqueue, fn_job_claim, fn_job_progress, fn_job_succeed,
fn_job_fail_transient, fn_job_fail_permanent, fn_job_cancel,
fn_job_cleanup_sweep, fn_job_lease_reaper, fn_job_dead_letter_replay,
fn_queue_heartbeat_register, fn_queue_heartbeat_write, fn_queue_stale_check,
fn_consumer_dispatch, fn_queue_wake_notify_event_outbox, fn_queue_wake_notify_job_queue,
fn_queue_notify_enabled, fn_mot_graph_emit, fn_job_workflow_refresh
                                   (new functions)

Plus dot_config keys under queue.* namespace.


§7. Migration order (Phase 1, see doc 14)

  1. Add new tables + indexes (no triggers, no fns yet).
  2. Add new fns with bodies that no-op when their disable-flag is false.
  3. Add new dot_config keys with default false for all enable-flags.
  4. Add new views.
  5. Operator runbook: register first executor (probably the existing iu_outbound_default worker writes its heartbeat).
  6. Operator enables one consumer_registry row at a time, dry-run first.
  7. Council watches v_queue_health for one cadence, ratifies, then enable next.

No event_outbox row migrated. No legacy function dropped.


§8. Backwards-compatibility guarantees

  • Existing consumers of event_outbox (D22 system_issues, D43 red_zones, D31 watchdog, Directus collections) continue to read with no change.
  • Existing fn_iu_* callers see no signature change.
  • Cutting flow (fn_iu_op_*) callers see no signature change.
  • dot_config keys not removed; only added.

§9. Compatibility with Điều 45 v1.0

Clause Compliance
§3.1 single substrate per category ✅ events stay on event_outbox; jobs on job_queue; no third substrate
§6.6 event-vs-job ✅ explicitly two layers, no SoT overlap
§11.2 producer/queue/executor ✅ event_outbox is queue; job_queue is queue; producers/consumers separate
§16.2 no schema change ✅ no live event_outbox mutation
§18.2 Phase 1 ban hành = preserve ✅ this pack adds, does not subtract
§20.1 transitional substrate preserved ✅ every row in §6 table preserved

§10. Open questions

# Question Routed to
TX-Q1 When (if ever) do we drop iu_notification_event/read? Council Q7. Council
TX-Q2 Should dot.job.* event types be registered at Phase 1 or only when a job actually emits? Council vocab gate
TX-Q3 If consumer_registry matches multiple consumers for the same event, do they all enqueue (default) or compete? Council
TX-Q4 Cycle detection — should event A → job → event A be globally refused, or allowed up to depth N? Council

Transition plan. No mutation. No duplicate SoT. Authored 2026-05-26.

Back to Knowledge Hub knowledge/dev/laws/dieu44-trien-khai/v0.6-dieu45-full-queue-orchestration-design-pack/13-event-outbox-transition-plan.md