13 — event_outbox Transition Plan (event-vs-job coexistence, no duplicate SoT)
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_outboxschema. - Change
event_outboxsemantics. - 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_outboxrows are never updated by job lifecycle changes. The fact is immutable.job_queuerows are never the place to look for "did this event happen?" — onlyevent_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)
- Add new tables + indexes (no triggers, no fns yet).
- Add new fns with bodies that no-op when their disable-flag is
false. - Add new
dot_configkeys with defaultfalsefor all enable-flags. - Add new views.
- Operator runbook: register first executor (probably the existing
iu_outbound_defaultworker writes its heartbeat). - Operator enables one consumer_registry row at a time, dry-run first.
- Council watches
v_queue_healthfor 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_configkeys 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.