ADR-055: Curation → Worlds interface contract — `rule_candidate_published` event + Worlds outcome materialization
Context
ADR-044 locked the event substrate between contexts; SPEC-269 (authority version metadata) landed authority-lineage fields. This ADR specifies the rule_candidate_published event payload + the rejection semantics on the Worlds consumer side.
The event represents an already-curated, already-approved candidate crossing the context boundary — algorithmic curation + HITL approval (where applicable) happen upstream of the event in the Operations curation pipeline. TA-7’s surface starts at the moment Operations decides to publish.
Decision
D1 — Event: rule_candidate_published
event_type:rule_candidate_publishedsource:platform(Operations curation pipeline lives in platform)target:worldsidempotency_key:str(event_id)(no stronger business key at alpha; Operations may strengthen later)- Lives in
spectral.platform.contracts.events.rule_candidate_publishedper ADR-065 D2 (producer-owned typed payloads live in<producer>.contracts.events.*)
D2 — Payload schema
class RuleCandidatePublishedPayload(BaseModel): rule_candidate_id: UUID domain_id: UUID rule_text: str authority_version_id: UUID # SPEC-269 authority metadata authority_lineage: list[UUID] # full lineage chain per SPEC-269 published_at: datetime published_by_user_id: UUID | None # nullable for system-initiated publication curation_metadata: dict[str, Any] # extensible curation provenanceAdditive-only versioning per ADR-044 D11. curation_metadata provides extensibility without a schema bump for evolving curation provenance.
D3 — Worlds rejection semantics: worlds.rule_candidate_outcomes outcome table
Worlds consumes the event and classifies the outcome:
- Transient failure (DB unavailable, network hiccup, OperationalError): handler raises exception → ADR-054 retry → eventual DLQ if exhausted. No outcome row written.
- Terminal failure (schema invalid, authority mismatch, business conflict): handler writes outcome row with
outcome='rejected'+reason_code+reason; ACKs the event (no retry). - Success: handler integrates rule into worlds-side state; writes outcome row with
outcome='accepted'.
Schema:
create table worlds.rule_candidate_outcomes ( event_id uuid primary key, rule_candidate_id uuid not null, outcome text not null check (outcome in ('accepted', 'rejected')), reason_code text null, reason text null, recorded_at timestamptz not null default now());Reason codes (alpha): schema_invalid | authority_mismatch | business_conflict | other. Additive-only enum.
Outcome read mechanism between contexts. Worlds emits a rule_candidate_outcome_recorded event onto the substrate per ADR-064; Operations materializes a local replica in platform.rule_candidate_outcomes_replica and reads from same-context state. No inter-context SQL grant — the read crosses the boundary as an event, per the no-SQL-grants discipline (ADR-063). The strict-unidirectional posture holds: worlds writes the outcome; operations consumes the event and reads its own replica — no rejection event flows back to worlds beyond the outcome event itself.
D4 — Backpressure via existing TA-16 observability surface
No net-new TA-7 mechanism. core.outbox pending-row count by event_type is exposed via TA-16 observability; Sentry alerts fire on lag exceeding threshold per handler. The Operations curation pipeline can throttle publication via the same EventPublisher interface — no special backpressure protocol on this event.
D5 — Additive-only versioning ratified from ADR-044 D11
Payload field additions are non-breaking; field removals or semantic changes require an event_version bump to a new payload model (RuleCandidatePublishedPayloadV2), with both consumers staying live during the migration window. Standard ADR-044 versioning posture.
Alternatives considered
Rejection event back to Operations (rule_candidate_rejected). Rejected; introduces bidirectional event flow Worlds↔Operations; rejection is a state observation, not a “thing happened” signal; Operations actively queries for outcome when the operator UX needs it.
Terminal-as-DLQ (handler raises on terminal failures, lets retry exhaust). Rejected; wastes retries on known-terminal failures; conflates substrate failure with domain rejection; operator’s mental model is “did my candidate land?” not “look at DLQ rows.”
Outcome table at producer side (Operations writes “I published X”). Rejected; producer doesn’t know outcome until Worlds writes it; producer-side outcome would just shadow worlds-side state.
Single outcomes table for all candidate types. Accepted at alpha; if rule-candidate types diverge later, schema split is straightforward.
Consequences
- Symmetric outcome surface — acceptance and rejection both live in the same query path (read via the local replica, ADR-064).
- No bidirectional event topology for the substrate.
- Read pattern between contexts — no inter-context SQL grant; Worlds emits the outcome event and Operations reads its own local replica (ADR-064), under the no-SQL-grants discipline (ADR-063).
- Operator surface (whatever consumes outcomes) is decoupled from event flow.
- Pull-based outcome visibility — operator UX queries on demand vs reactive notification (acceptable: outcome render happens when operator opens candidate detail; no real-time-update requirement).
- Rejection reasons are an enum at alpha — extensions require schema bump (acceptable: enum is small + stable).
- High-volume + high-rejection scenario — outcome-query path may become hot read; standard query optimization + index on
(rule_candidate_id, outcome)mitigates.
References
- ADR-065 —
spectral.coreadmission discipline - ADR-044 — event substrate; envelope shape; additive-only versioning
- ADR-054 — retry semantics for the Worlds consumer
- ADR-060 —
RuleCandidateOutcomeReaderreader pattern - ADR-063 — no inter-context SQL grants
- TA-7 disposition — SPEC-310 comment
48240483 - TA-7 verification — SPEC-310 comment
3df0f3ee src/spectral/platform/contracts/events/rule_candidate_published.py- Codex
system-design/foundations/contract-surfaces/event-substrate.mdx— close-pass updates