07 — DP5 Trigger-in / Trigger-out Vocabulary
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_registrydispatch table — instead of bespoke functions per consumer (fn_iu_auto_instantiate_from_eventstyle). - 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_routerow:enabled+dry_runflags (activation lifecycle).consumer_registryrow:enabled+dry_runflags (same activation lifecycle).- Per-event dispatch: deduped via
(job_kind, idempotency_key)UNIQUE onjob_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_registryinsert/update isworkflow_adminonly.fn_consumer_dispatchruns in worker context (dot_executor role); cannot bypass the registry.- Dispatched jobs inherit
executorfrom registry — cannot be smuggled past §11.5 whitelist (CHECK onconsumer_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_dispatchshort-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_enqueuemust exist. - Vocab widening of
iu_sql_event_routerequires 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).