KB-E40B

07 — DP5 Trigger-in / Trigger-out Vocabulary

12 min read Revision 1
design-packdieu-45dp5trigger-intrigger-outconsumer-registryiu-sql-event-routedesign-only

07 — DP5 — Trigger-in / Trigger-out Vocabulary

DESIGN-ONLY. Cites Điều 45 §12 (all sub-sections), §3.2, §6.4, §7. No DDL, no DML.


§1. Goal

Generalise the trigger-in / trigger-out machinery so that:

  • Trigger-in: any SQL event (INSERT/UPDATE/DELETE/CALL/DDL on any registered source) can become a queue event without bespoke triggers per domain. The substrate is iu_sql_event_route (already live, dry-run); DP5 widens its vocab and lifts the activation gate.
  • Trigger-out: any queue event (event_outbox row) can spawn a job (job_queue row) through a single, idempotent consumer_registry dispatch table — instead of bespoke functions per consumer (fn_iu_auto_instantiate_from_event style).
  • The same vocabulary applies to same-file piece interaction (an IU piece change triggers re-cut of dependent pieces) and cross-file piece interaction (a Điều 22 system_issue resolution triggers a Điều 36 staging cleanup, etc.).
  • The vocabulary is forward-compatible with MOT (doc 11) and customer-care (doc 12) without redesign.

§2. Current state

Component Live state Gap
iu_sql_event_route 1 row, enabled=false, dry_run=true, target_event_domain ∈ {iu, iu_sql} CHECK too narrow; never activated
Generic dispatch from event_outbox → action fn_iu_auto_instantiate_from_event (bespoke per consumer) No registry; new consumers need new functions
Idempotency log per consumer iu_auto_instantiate_event_log (per-consumer) Pattern works but each consumer recreates it
Cross-piece interaction Implicit via shared collection_id / parent_or_container_ref No formal trigger-out edge

§3. Proposed design

§3.1 Trigger-IN — widen vocab on iu_sql_event_route

NON-EXECUTABLE DESIGN SKETCH — DO NOT APPLY

ALTER TABLE iu_sql_event_route
  DROP CONSTRAINT iu_sql_event_route_target_event_domain_check;
ALTER TABLE iu_sql_event_route
  ADD CONSTRAINT iu_sql_event_route_target_event_domain_check
    CHECK (target_event_domain IN
       ('iu','piece','staging','birth_registry','governance','tac','kg','system','dot','health','iu_sql'));
-- Preserves the legacy 'iu_sql' bucket for routes that don't fit the §6.1 9-domain vocab cleanly.

A new route row example (still NON-EXECUTABLE):

NON-EXECUTABLE DESIGN SKETCH — DO NOT APPLY

INSERT INTO iu_sql_event_route (route_code, source_object_kind, source_schema, source_table,
                                sql_event, target_event_domain, target_event_type, target_event_stream,
                                enabled, dry_run, fail_closed,
                                idempotency_policy, payload_contract)
VALUES ('iu_lifecycle_log.insert', 'table', 'public', 'iu_lifecycle_log',
        'insert', 'piece', 'lifecycle_changed', 'update',
        false, true, true,
        '{"key":"(unit_id,to_status,changed_at)"}'::jsonb,
        '{"keep_refs":["unit_id","change_set_id"],"forbid_keys":["body","content","raw","vector","embedding","secret","token","password","ssn","personal_data"]}'::jsonb);

Activation rule preserved (CHECK (enabled=false) OR (dry_run=true) — i.e., enable only after dry-run review).

§3.2 Trigger-OUT — consumer_registry

A new design table that replaces the bespoke per-consumer dispatch by mapping event_type → executor + job_kind + idempotency key formula:

NON-EXECUTABLE DESIGN SKETCH — DO NOT APPLY

table public.consumer_registry (
  consumer_id            text PRIMARY KEY,                  -- e.g. 'auto_instantiate_from_template'
  event_domain           text NOT NULL,
  event_type             text NOT NULL,
  event_stream           text NULL,                          -- nullable = wildcard within (domain,type)
  job_kind               text NOT NULL,                     -- DP2 vocab
  executor               text NOT NULL                       -- §11.5 whitelist
       CHECK (executor IN ('DOT','Agent','Hermes','Codex','PG_worker','external_worker','future_Kestra_adapter')),
  idempotency_key_template text NOT NULL,                    -- e.g. '{event_id}:auto_instantiate'
  payload_ref_template     text NULL,                        -- e.g. '{event_subject_ref}'
  enabled                boolean NOT NULL DEFAULT false,
  dry_run                boolean NOT NULL DEFAULT true,
  fail_closed            boolean NOT NULL DEFAULT true,
  priority               smallint NOT NULL DEFAULT 0,
  notes                  text NULL,
  registered_by          text NOT NULL,
  registered_at          timestamptz NOT NULL DEFAULT now(),
  UNIQUE (event_domain, event_type, event_stream, consumer_id),
  CHECK ((enabled = false) OR (dry_run = true) OR (consumer_id IS NOT NULL))   -- mirror iu_sql_event_route activation safety
);

Dispatch function (called by worker on each unseen event):

NON-EXECUTABLE DESIGN SKETCH — DO NOT APPLY

function fn_consumer_dispatch(p_event_id uuid) RETURNS jsonb
-- 1. SELECT event row from event_outbox.
-- 2. SELECT consumer_registry rows WHERE event_domain/type/stream matches AND enabled AND NOT dry_run.
-- 3. For each match:
--      idempotency_key := interpolate(idempotency_key_template, event_row)
--      payload_ref     := interpolate(payload_ref_template, event_row)
--      PERFORM fn_job_enqueue(
--        p_kind => consumer.job_kind,
--        p_idempotency_key => idempotency_key,
--        p_executor => consumer.executor,
--        p_source_ref => event_row.canonical_address,
--        p_target_ref => event_row.event_subject_ref,
--        p_payload_ref => payload_ref,
--        p_safe_payload => jsonb_build_object('consumer_id', consumer.consumer_id,
--                                             'event_type', event_row.event_type),
--        p_priority => consumer.priority,
--        p_process_after => now(),
--        p_correlation_id => event_row.correlation_id,
--        p_causation_event_id => event_row.id,
--        p_actor => 'consumer:'||consumer.consumer_id
--      );
-- 4. RETURN summary of enqueued job_ids.

The bespoke fn_iu_auto_instantiate_from_event becomes the implementation of the job_kind='auto_instantiate_from_template' executor — the dispatch is now config-driven.

§3.3 Same-file vs cross-file piece interaction

Vocabulary table (no new structure required; just naming convention for job_kind + event_type):

Scenario Producer (event) Consumer (job)
Same-file: piece reorder in same IU piece.piece_reordered job_kind='iu_recompose_pieces' (rebuilds container view)
Same-file: piece deprecation piece.piece_deprecated job_kind='iu_axis_refresh' (D9 three-axis)
Cross-file: D22 system_issue resolved system.issue_resolved job_kind='health_followup_review' (optional human task)
Cross-file: D36 staging cleanup needed staging.record_expired job_kind='staging_cleanup_sweep'
Cross-file: D35 DOT run finished dot.run.applied job_kind='kg_link_refresh' (KG re-index)
Cross-file: Birth registry entity born birth_registry.entity_born job_kind='auto_instantiate_from_template' (existing logic)

All routed by consumer_registry, all enqueued into job_queue.


§4. Lifecycle / status

  • iu_sql_event_route row: enabled + dry_run flags (activation lifecycle).
  • consumer_registry row: enabled + dry_run flags (same activation lifecycle).
  • Per-event dispatch: deduped via (job_kind, idempotency_key) UNIQUE on job_queue (DP2).

§5. Indexes / performance

NON-EXECUTABLE DESIGN SKETCH — DO NOT APPLY

INDEX  ix_consumer_registry_lookup
  ON consumer_registry(event_domain, event_type, event_stream)
  WHERE enabled = true AND dry_run = false;

INDEX  ix_iu_sql_event_route_lookup
  ON iu_sql_event_route(source_schema, source_table, sql_event)
  WHERE enabled = true AND dry_run = false;

Dispatch is O(1) per event in registry size at expected scale (<100 consumers).


§6. Security / governance

  • consumer_registry insert/update is workflow_admin only.
  • fn_consumer_dispatch runs in worker context (dot_executor role); cannot bypass the registry.
  • Dispatched jobs inherit executor from registry — cannot be smuggled past §11.5 whitelist (CHECK on consumer_registry.executor).
  • Idempotency template interpolation is string-substitution only on whitelisted event-row fields; no eval, no jsonb_path execution against arbitrary payload.

§7. Rollback / disable

  • Per-row: set enabled=false.
  • Global: dot_config.queue.consumer_dispatch.enabled='false'fn_consumer_dispatch short-circuits.
  • Per-route trigger-in: set iu_sql_event_route.enabled=false.

§8. Healthcheck / observability

NON-EXECUTABLE DESIGN SKETCH — DO NOT APPLY

view v_consumer_registry_active AS
  SELECT * FROM consumer_registry WHERE enabled = true AND dry_run = false;

view v_consumer_dispatch_recent  AS
  SELECT consumer_id, count(*) AS dispatched_24h
    FROM job_queue
   WHERE causation_event_id IS NOT NULL
     AND enqueued_at > now() - interval '24 hours'
   GROUP BY (safe_payload->>'consumer_id');

view v_sql_event_route_active   AS
  SELECT * FROM iu_sql_event_route WHERE enabled = true AND dry_run = false;

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

Clause Compliance
§12.1 trigger-in registry ✅ widened, still registered, still has activation safety CHECK
§12.2 trigger-out consumer contract ✅ idempotency log via job_queue.idempotency_key (centralised)
§12.2 rule 1 (idempotency log) ✅ inherits DP2 unique constraint
§12.2 rule 2 (lease) ✅ inherits DP3 lease
§12.2 rule 3 (dead-letter) ✅ inherits DP3 DLQ
§12.2 rule 4 (no cycle without causation_id) causation_event_id on job_queue; cycle detector adds depth limit in dispatch
§3.2 vocab discipline ✅ no new domain; widens existing CHECK to §6.1 9-domain set
§6.4 inclusion criteria ✅ each consumer must reference an event_type already in registry
§7 idempotency ✅ enforced at job_queue insert

§10. Implementation prerequisites

  • DP2 job_queue + fn_job_enqueue must exist.
  • Vocab widening of iu_sql_event_route requires Council approval (it changes a live CHECK constraint — Council ratification of vocab, per §3.2).
  • Migration is reversible (re-narrow CHECK), but no rollback story exists for routes activated and consumed.

§11. Open questions

# Question Routed to
DP5-Q1 Approve widening iu_sql_event_route.target_event_domain CHECK to 10 values? Council
DP5-Q2 Approve consumer_registry as the single dispatch surface? Council
DP5-Q3 Idempotency template DSL: simple string interpolation vs jsonb_path? Council
DP5-Q4 Should fn_consumer_dispatch enforce cycle detection depth-limit? Default depth? Council
DP5-Q5 Migrate fn_iu_auto_instantiate_from_event into a registered consumer at Phase 1, or leave standalone? Council
DP5-Q6 Per-event_type rate limit (worker-side) — add now or defer? Council

§12. Self-test

self_test:
  cites_dieu45_section: §3.2,§6.4,§7,§12
  defines_status_lifecycle_compatible_with_§6_7: inherits DP2
  defines_idempotency_key: yes (idempotency_key_template)
  defines_retry_dlq: inherits DP3
  defines_lease: inherits DP3
  defines_observability_view: v_consumer_registry_active, v_consumer_dispatch_recent
  defines_dot_config_disable_flag: queue.consumer_dispatch.enabled
  defines_executor_set_compatible_with_§11_5: yes (CHECK)
  no_vector_in_transient: yes (payload templates ref-only)
  signal_not_data: yes
  pg_sot: yes
  rollback_concept: per-row + global flag
  no_pg_cron_dependency_phase_1: yes
  no_pg_18_dependency: yes
  no_mutation_authored: yes

DP5 design. No mutation. Authored 2026-05-26 by Claude Opus 4.7 (1M).

Back to Knowledge Hub knowledge/dev/laws/dieu44-trien-khai/v0.6-dieu45-full-queue-orchestration-design-pack/07-DP5-trigger-in-out-vocabulary.md