Skip to main content

Atlas Adoption

This page documents the 8 services adopted from a co-founder's parallel Atlas codebase into Trust Relay. Each service was analysed, extracted, and reimplemented in Trust Relay's tech stack (PydanticAI, SQLAlchemy, Alembic, Next.js/shadcn). The source analysis is at docs/research/atlas-extraction/ and the design specification is at docs/plans/2026-03-31-atlas-adoption-roadmap-design.md.


Design Principle

Atlas uses LangChain/LangGraph, Flyway, asyncpg, and Blueprint.js. Trust Relay uses PydanticAI, Alembic, SQLAlchemy, and Next.js.


Architecture Overview — 4 Waves

The 8 services are organised into 4 dependency-ordered waves. Later waves depend on earlier ones: the EBA risk matrix (Wave 3) performs country risk lookups against the reference data loaded in Wave 1, and the survivorship resolver (Wave 4) consumes entity matches produced in Wave 3.

WaveGoalServices
Wave 1Structured reference data, quality measurement, cost-optimised modelsReferenceDataService, QualityScorer, ModelTiers
Wave 2Regulatory citations in prompts, segment-specific severityPrompt templates (not standalone services — see app/prompts/)
Wave 3Regulatory-grade risk scoring, auditable event chains, entity deduplicationEBARiskMatrix, DomainEvents, EntityMatcher
Wave 4Provable data quality, declarative workflow typesSurvivorshipResolver, WorkflowSchema

Wave 1 Services

ReferenceDataService

backend/app/services/reference_data_service.py

A singleton that loads the declared JSON compliance datasets at startup from backend/config/reference_data/. Every subsequent service that needs a country risk score, PEP tier, or industry classification calls this service rather than embedding hardcoded lists.

Since #937 (ADR-0163) the loader fails closed: the roster of datasets that must load is declared in code as REQUIRED_DATASETS, and a declared dataset that is missing, unparseable or empty raises ReferenceDataIntegrityError naming every failure at once. assert_reference_data_loadable() runs it at boot from both app/main.py and app/worker.py.

Why that matters: before #937 a load failure was logged and swallowed, and absence is answered benignly by every accessor (get_dataset → [], is_in_dataset → False, get_risk_score → None). In eba_risk_matrix._get_country_risk_score, a False from the FATF membership test falls through to return 10.0 # Default: low risk. Measured on the real scorer with three files deleted, an Iranian gambling company with a head-of-state PEP went from 90.0/CRITICAL with a FATF_BLACKLIST escalation to 42.5/MEDIUM with none — silently. Reference data is deployment input, not runtime evidence, so the fix is to refuse to boot rather than to emit a per-case data gap (ADR-0163 §4).

Boot-time refusal is only half of it. Before #937 a second reader — risk_config_service.build_default_config — swallowed the same failure into reference_datasets[key] = [] and persisted it into the tenant's audited, versioned risk configuration, from where reassess_risk_activity hands it to compute_eba_risk as ref_datasets. Deleting that fallback stops new empties; it does not touch the rows already holding one, and those rows survive both a repaired file and a restart. So risk_config_service.repair_reference_datasets is a read-time fail-closed clamp applied by get_active_config (the scoring path) and get_version (the preview path): any empty or absent declared dataset in a persisted reference_datasets block is served from the boot-verified file value instead. Measured against a persisted configuration — intact 90.0/CRITICAL, whole block emptied 42.5/MEDIUM — the same collapse, reached from the database rather than from a deleted file.

The stored row is deliberately not rewritten: risk_configurations is the version-of-record for which calibration scored a past case (EU AI Act Art. 12 / AMLR), so repairing it in place would alter an audited row and destroy the evidence that the tenant was scored against an empty list. The repair enters the record through the normal audited path instead — create_draft clones the corrected values with the officer's rationale on the audit row (ADR-0163 §1b).

17 datasets (the 12 original Wave-1 sets plus amlr_annex_factors (#551), connector_contracts (#518), declared_signal_vocabulary (ADR-0155), model_tier_eval_set (#855) and regulator_registers (#770)). The original twelve:

DatasetRecordsPurpose
fatf_grey_list22 countriesFATF increased monitoring
fatf_black_list3 countriesFATF high-risk (IR, KP, MM)
eu_high_risk_third_countries27 countriesEU delegated regulation
eu_tax_blacklist10 jurisdictionsEU non-cooperative tax jurisdictions
secrecy_jurisdictions12 jurisdictionsTax Justice Network FSI
cpi_below_4044 countriesCorruption Perception Index < 40
pep_tiers8 levels with scoresPEP classification hierarchy
industry_risk_classification15 sectors with scoresML/TF industry risk
product_risk_taxonomy11 categories with scoresProduct/service risk scoring
sanctions_defaults4 lists + match weightsSanctions configuration
ubo_thresholdsPer-jurisdiction %UBO identification thresholds
dataset_typesSchema definitionsJSON Schema per dataset type

Key methods:

  • get_dataset(name) — returns the full dataset (list or dict)
  • is_in_dataset(dataset_name, value) — membership check for list and dict datasets
  • get_risk_score(dataset_name, key) — returns a numeric risk score, handling three payload shapes: direct numeric, {"score": N}, and {"risk_score": N}
  • get_all_datasets() — sorted list of all loaded dataset names
  • get_reference_data() — module-level factory returning the singleton

Each JSON file uses a standard envelope — a contract since #937, not a convention:

{
"list_key": "fatf_grey_list",
"type_id": "country_risk_list",
"name": "FATF Grey List (Increased Monitoring)",
"source": "FATF Public Statement",
"source_url": "https://www.fatf-gafi.org/en/topics/grey-list.html",
"source_date": "2026-02-21",
"fetched_at": null,
"schema_version": "1.0.0",
"data": ["AF", "KH", "KG", "..."]
}

fetched_at (when we last went and looked) and schema_version (the version of our shape for data) were added by #937. fetched_at and source_date answer different questions and neither substitutes for the other: a source published in 2024 and retrieved yesterday is current; a source published yesterday and retrieved in 2024 is not. fetched_at is null — a declared gap — wherever no retrieval was ever recorded, rather than back-filled with a plausible date.

Provenance contract and staleness (app/services/reference_data_provenance.py) is the single interpreter of that envelope. It returns five states, not a boolean, because the failure kinds have different owners: provenance_gap (a required field present but null/blank), malformed (a required field absent; a source_url that is neither a well-formed absolute http(s) URL nor a path inside the repository; a fetched_at that is unparseable or dated in the future), stale (not published or re-verified inside the source-class window), unverifiable (the check cannot be performedsource_date unparseable, or a repo-relative source_url in a deployment with no repository) and unclassified (no declared source class). The last two fail closed — "could not check" must never read as "checked and fine".

Staleness windows are publisher cadence + 90 days, declared per source class in code, not in the JSON: a dataset must not certify its own freshness requirement, or the cheapest way to fix a red staleness test would be to widen the window inside the file being checked. The shape generalises the art62_bo_dataset.currency block in dataset_types.json (ADR-0136).

The clock runs from the later of source_date and fetched_at, because every window is a re-verification cadence rather than an expiry date. Measuring publication alone meant an old-but-still-current edition could only be cleared by writing today's date into source_date — claiming a publication that did not happen, which is exactly the fabricated provenance this work exists to remove. fetched_at is validated before it can buy any freshness (unparseable or future → malformed, counts for nothing), and where it is null the clock is unchanged.

tests/test_reference_data_service.py enforces it in two tiers: a hard tier admitting no exceptions (all five keys present, parseable date, declared class) and a ratcheted tier whose real violations are declared with a reason and an owning issue, pinned in both directions — an undeclared violation fails, and a declared exception that no longer reproduces also fails, forcing its removal.

Integration: The EBARiskMatrix calls get_reference_data() to check fatf_grey_list, eu_high_risk_third_countries, and cpi_below_40 during geographic scoring. The ARIA risk matrix service was previously hardcoding these lists inline.

API endpoint: GET /api/reference-data/{dataset} — exposes all 12 datasets to the frontend. As of 2026-03-31 the reference data browser is integrated into the Risk Configuration admin page (/admin/risk-configuration, Reference Datasets tab) rather than the standalone /admin/reference-data page, which now redirects. The backing endpoint app/api/reference_data.py has been superseded by app/api/risk_config.py.


QualityScorer

backend/app/services/quality_scorer.py

An LLM-as-judge evaluator that runs after the pre-investigation OSINT pass and stores the result in additional_data.quality_scores. It scores the investigation on 5 dimensions, gated by settings.quality_scoring_enabled (with a quality_scoring_mock_mode for demos).

QualityScore dimensions (each scored 0.0–1.0 by the judge):

DimensionWeightCriterion
completeness0.25Are all required verification domains covered with substantive content? No obvious gaps?
source_diversity0.20Findings drawn from a diverse set of independent sources, not a single feed?
evidence_quality0.25Facts attributed to specific data points? No sign of hallucinated or unverified information?
risk_justification0.20Risk assessment logically follows from the evidence?
actionability0.10Recommendations specific and decision-ready for a compliance officer?

overall is the weighted average of the five dimensions (weights above, summing to 1.0), rounded to 3 decimals via QualityScore.compute_overall().

Key functions:

  • score_investigation(case_id, findings, synthesis, sources, discrepancies, verification_checks) — async entry point; renders the quality_scorer prompt via PromptRegistry, runs a PydanticAI Agent with output_type=QualityScore, and returns a QualityScore (or None on failure / when disabled)
  • QualityScore.compute_overall() — computes the weighted average and sets overall
  • QualityScore.to_dict() — serialises to dict for JSONB storage

Judge model: settings.quality_scoring_model, default openai:gpt-4.1 (overridable by env var). The findings list is truncated to the first 20 entries when building the prompt.

Best-effort, non-blocking: Scoring runs as the Temporal activity score_investigation_quality and is wrapped in a try/except in compliance_case.py — failures are logged and the workflow continues. There is no automatic re-investigation or synthesis-retry threshold; the score is stored and surfaced for human review only.

Integration: Invoked as the score_investigation_quality Temporal activity in the pre-investigation tail of compliance_case.py. Scores appear on the case detail page.

Why it exists: Establishes a quality baseline before prompt changes are made in Wave 2. Measures regression when switching model tiers.


ModelTiers

backend/app/services/model_tiers.py

Maps each agent to a cost tier, and each tier to a specific model. Environment variables override the defaults, enabling deployment-time model swaps without code changes.

4 tiers (defaults in _DEFAULT_TIER_MODELS):

TierCriterionDefault Model
premiumAccuracy critical (sanctions, synthesis)openai:gpt-5.2
midJudgment-heavy tasks (most agents)openai:gpt-5.2
valueReserved for future cost optimisationopenai:gpt-4.1-mini
budgetReserved for future cost optimisationopenai:gpt-4.1-mini

13 agent-to-tier assignments (AGENT_TIERS). Note: several agents originally placed in value/budget were upgraded back to mid after the budget tier (gpt-4.1-mini) was found to drop documents with OCR noise and to under-synthesise officer-facing output. As a result only sanctions_resolver and synthesis use premium; everything else currently resolves to mid:

AgentTierNote
sanctions_resolverpremium
synthesispremium
registry_investigationmid
belgian_investigationmid
person_validationmidupgraded from value — identity validation is compliance-critical
adverse_mediamid
task_generatormidupgraded from budget — synthesises all findings into officer actions
document_validatormidupgraded from budget — gpt-4.1-mini drops documents with OCR noise
mcc_classifiermid
scan_synthesismid
case_intelligencemid
document_extractionmidupgraded from budget — same OCR-noise issue
precious_metals_riskmid

Key functions:

  • get_model_for_agent(agent_name, override_tier=None) — resolves agent → tier → model, with fallback to DEFAULT_TIER (mid)
  • get_agent_tier(agent_name) — returns the tier string for a given agent
  • get_tier_config() — returns the current tier-to-model mapping (used by health endpoint)
  • _reload_config() — re-reads environment variables (called by tests to reset state)

Env var overrides: MODEL_TIER_PREMIUM, MODEL_TIER_MID, MODEL_TIER_VALUE, MODEL_TIER_BUDGET — as of app/services/model_tier_gate.py (#855, ADR-0156), an override that would DOWNGRADE a tier's model (is_model_downgrade, unranked models fail closed) is refused unless an eval result is on record authorizing that exact override; a lateral/upgrade override still applies unconditionally. AGENT_TIERS moves are similarly gated via request_tier_downgrade — the gate runs each agent's declarative eval cases (config/reference_data/model_tier_eval_set.json) against the candidate model before a downgrade (of either kind) is allowed to take effect.

Cost impact: The tier indirection lets a deployment swap models per tier without code changes. The aggressive value/budget assignments that originally targeted a ~60–80% per-investigation cost reduction have largely been rolled back to mid for accuracy reasons, so the realised cost saving today is smaller; the saving is realised only when a deployment overrides the value/budget tiers to a cheaper model via env vars.


Wave 3 Services

EBARiskMatrix

backend/app/services/eba_risk_matrix.py

Implements EBA/GL/2021/02 (Guidelines on risk factors) as a scored 5-dimension matrix with SHA-256 determinism proof. This is intended to replace ARIA's 4-dimension weighted-average scoring as the default risk scoring engine.

5 dimensions and 15 factors:

DimensionWeightFactors
Customer0.30ownership_complexity, pep_exposure, sanctions_exposure, adverse_media, business_profile
Geographic0.25jurisdiction_risk, operational_geography, ubo_geography
Product/Service0.20product_complexity, regulatory_status
Delivery Channel0.10non_face_to_face, digital_presence
Transaction0.15financial_profile, transaction_patterns

Aggregation — weighted_max: The overall score is a weighted average of dimension scores, with a floor boost applied when any single dimension exceeds 80. This means a very high-risk dimension dominates the result rather than being averaged away — consistent with EBA guidance that a single critical risk factor can elevate the overall assessment.

5 risk levels:

LevelScore Range
Critical90+
High70–89
Medium40–69
Low20–39
Clear0–19

SHA-256 audit trail: EBARiskResult carries two hashes:

  • input_hash — SHA-256 of the canonical (sort_keys=True) JSON representation of EBARiskInput
  • output_hash — SHA-256 of the dimension and factor scores

These hashes allow an auditor to verify that a stored result was produced from a specific input without re-running the scorer. The matrix version (eba_standard_v1) is captured in every result, satisfying immutable audit requirements under AML 5-year retention rules.

Key functions:

  • compute_eba_risk(inp: EBARiskInput, config: dict | None = None) -> EBARiskResult — entry point; runs all 5 dimensions, applies _aggregate_weighted_max, hashes input and output. config allows the versioned risk_configurations row to override weights/thresholds
  • apply_risk_calibration(...) — applies segment-specific calibration to a raw score
  • map_risk_level(score) -> str — converts 0–100 score to level string
  • _hash_canonical(obj) -> str — SHA-256 of JSON-serialised dict with sorted keys (MATRIX_VERSION = "eba_standard_v1")

Reference data integration: Geographic dimension calls get_reference_data() for fatf_black_list, fatf_grey_list, eu_high_risk_third_countries, eu_tax_blacklist, secrecy_jurisdictions, and cpi_below_40. Customer dimension uses pep_tiers and product_risk_taxonomy.

Production status: As of 2026-03-31 EBARiskMatrix is the only active risk scoring engine. The ARIA risk matrix and the EBA_RISK_MATRIX_ENABLED feature flag have been removed. Risk configuration is now managed via the versioned risk_configurations table (see Risk Assessment).


DomainEvents

backend/app/services/domain_events.py

Adds correlation/causation chain semantics to the audit log. Every significant action emits a DomainEvent. Events from the same investigation share a correlation_id; each event records the causation_id of the event that triggered it.

Why this matters: The flat audit_events table records what happened and when. Domain event chains record why — "the sanctions match on officer X caused the escalation which caused the 60-day extension which caused the MLRO notification." This chain reconstruction is required for SAR filing and regulatory examination responses.

Data classes:

DomainEvent
event_id: UUID (auto)
event_type: str
data: dict
timestamp: datetime (UTC)
correlation_id: UUID | None — groups all events in one investigation
causation_id: UUID | None — points to the triggering event
actor_id: str | None — user or agent ID
actor_type: str | None — "system" | "user" | "agent"
source_service: str | None — originating service name

EventChain class:

  • EventChain.start(context) — creates a new chain with a fresh correlation_id
  • chain.emit(event_type, data, caused_by, ...) — emits an event linked to caused_by
  • chain.get_chain() — returns all events sorted by timestamp
  • chain.get_caused_by(event) — returns all events directly caused by a specific event

Factory function: create_investigation_chain(case_id) — creates a chain scoped to one investigation; used by Temporal activities so all activities within the same workflow run share a correlation_id.

Integration: Temporal activities call create_investigation_chain() at the start of each investigation workflow run. The chain is passed through activity parameters and used to emit events at each stage (documents submitted, OSINT started, finding discovered, decision made). The correlation_id and causation_id columns are added to the audit_events table via an Alembic migration.


EntityMatcher

backend/app/services/entity_matcher.py

Algorithmic entity matching for cross-investigation deduplication. When two investigations reference the same company or person under slightly different names or registration number formats, this service detects and links them.

Matching pipeline:

  1. Blocking key{jurisdiction}:{first_3_chars_of_stripped_normalised_name}. Entities with different blocking keys are never compared, reducing the comparison space from O(n²) to O(n).
  2. Registration number comparison — exact match after stripping country and court prefixes (CHE, BE, DE, FR9201., etc.). Match → 0.99 confidence.
  3. Legal suffix removal — 25 patterns: Ltd, GmbH, BV, NV, SA, SAS, SARL, SRL, LLC, PLC, Inc, Corp, AG, SE, AB, AS, OY, eG, KG, OHG, GbR, and others.
  4. NFKD normalisation — strips diacritics (Bolloré → Bollore, Société → Societe).
  5. Name similaritySequenceMatcher on normalised strings after suffix removal.
  6. Person matching — parts sorted alphabetically before comparison, so "Jean-Pierre Dupont" matches "Dupont Jean-Pierre".

Match thresholds:

ConfidenceDecision
> 0.85Auto-match
0.70 – 0.85Review queue
< 0.70Distinct entities

Key functions:

  • match_entities(entity_a, entity_b) -> MatchResult — primary entry point; accepts dicts with type, name, registration_number, jurisdiction
  • compute_blocking_key(jurisdiction, name) -> str — generates the blocking key
  • normalize_company_name(name), normalize_person_name(name) — normalisation utilities
  • strip_legal_suffix(name), strip_country_prefix(reg_number) — cleaning utilities

Integration: Runs after graph ETL on newly created entity nodes. Detected potential matches are stored in a review queue. Confirmed matches feed into the SurvivorshipResolver (Wave 4) for field-level conflict resolution.


Wave 4 Services

SurvivorshipResolver

backend/app/services/survivorship.py

When the EntityMatcher links two records as the same entity, their field values may conflict. The SurvivorshipResolver selects the winning value using per-source trust scores, logs all conflicts, and protects sensitive fields from lower-trust overrides.

Trust hierarchy (representative scores):

SourceTrust ScoreCategory
kbo0.98Government registry
gleif0.97Government registry
nbb0.95Government registry
vies0.93EU VAT validation
eori0.93EU customs validation
peppol0.90Official e-invoicing
sanctions_resolver0.96Authoritative sanctions source
northdata0.85Structured third-party API
opencorporates0.84Structured third-party API
brightdata0.72Web scraping
crawl4ai0.70Web scraping
llm_extraction0.75AI-extracted data

Protected fields: Certain fields can only be set by authorised sources regardless of trust score.

FieldAuthorised Sources
is_sanctionedsanctions_resolver, kbo, gleif
is_peppep_resolver, kbo
sanctions_detailssanctions_resolver
pep_detailspep_resolver

Conflict detection: When two sources provide different values for the same field and their trust scores differ by less than TRUST_CONFLICT_DELTA (0.02), both values are preserved as a ConflictRecord for human review rather than silently picking a winner.

Key methods:

  • SurvivorshipResolver.resolve_field(field_name, candidates) -> FieldProvenance — selects the winning value
  • resolver.conflicts — list of ConflictRecord instances accumulated during a resolution session

Audit compliance: Every resolution decision is logged with the winning value, source, trust score, timestamp, and the reason for the decision. This satisfies EU AI Act Art. 12 (automatic logging) and AML 5-year retention requirements for data provenance.

Integration: Called from graph ETL during entity upsert when the EntityMatcher has flagged two records as the same entity. The ConflictRecord list is written to additional_data.survivorship_conflicts on the case for display in the Conflicts Panel (Wave 5 UI).


WorkflowSchema

backend/app/services/workflow_schema.py

A declarative YAML schema and compiler for compliance workflows. Defines the 5 phase types, validates schemas via Pydantic, and compiles them to dependency-ordered ExecutionPlan instances via Kahn's topological sort algorithm.

5 phase types:

TypePurpose
portalCustomer-facing data collection (forms + documents)
investigationAutomated due diligence modules (OSINT agents)
rule_evaluationEBA risk matrix scoring
reviewHuman or auto decision with conditional routing
actionPost-decision steps (activation, notification, scheduling)

Schema structure:

WorkflowSchema
schema_id: str
name: str
version: int
phases: list[WorkflowPhase]
description: str | None
category: str | None — kyb, periodic_review, vendor_dd, etc.
inputs: list[dict] | None
subject_entity_types: list[str] | None
audit: dict | None — retention, hashing config

WorkflowPhase
id: str — unique within the workflow
type: PhaseType
config: dict — phase-specific configuration
depends_on: list[str] | None — phases that must complete first
name: str | None
condition: dict | None — conditional execution for action phases
escalation: dict | None

Compilation: compile_workflow(schema) -> ExecutionPlan runs Kahn's algorithm to produce a topologically sorted list of ExecutionStep instances. Phases that have no unsatisfied dependencies at the same point in the sort are grouped into parallel_groups — these can execute as concurrent Temporal child workflows.

Key functions:

  • validate_workflow(schema) -> list[str] — structural validation; returns error messages (empty = valid). Checks for empty schema_id, duplicate phase IDs, unknown depends_on references, and cyclic dependencies.
  • compile_workflow(schema) -> ExecutionPlan — runs validation then topological sort; returns ExecutionPlan with has_errors=True and populated errors if validation fails.
  • load_workflow_yaml(yaml_text) -> WorkflowSchema — parses YAML, constructs Pydantic model.

Migration path: The hardcoded compliance_case.py Temporal workflow is mirrored by the first YAML template, backend/config/workflow_schemas/kyb_onboarding_v1.yaml. Two further workflow types already ship as YAML templates without code changes — periodic_review_v1.yaml and vendor_due_diligence_v1.yaml — and are listed/inspectable through the workflow-schemas API and admin page (though, as noted above, they are not yet registered as runnable Temporal workflows).

Integration: The compiled ExecutionPlan is consumed by DynamicWorkflowEngine in app/workflows/dynamic_workflow.py, which walks the topologically sorted steps via a DynamicWorkflowState machine (tested in tests/test_dynamic_workflow.py). The compiler and engine are exposed through the app/api/workflow_schemas.py router and the /admin/workflow-schemas frontend page (lists schemas, shows the compiled execution plan). Not yet fully wired to live Temporal execution: DynamicWorkflowEngine is not registered in app/worker.py (the worker registers ComplianceCaseWorkflow, ShipmentComplianceWorkflow, ContinuousMonitoringWorkflow, BatchRiskReEvaluationWorkflow, and SanctionsSuppressionRefreshWorkflow), so YAML-defined workflows are compiled and inspected but the hardcoded compliance_case.py workflow still runs production cases.

Fail-closed route evaluation (issue #912)

Route evaluation was rebuilt to obey the never-suppress doctrine (ADR-0067) after a latent false clear was found: evaluate_condition read only condition["field"] and had no branch for the compound operator: OR|AND + rules: form, so it returned its documented "safe default" of False. In kyb_onboarding_v1.yaml and vendor_due_diligence_v1.yaml the compound form is used by the auto_reject route while the simple form is used by auto_approve — so a sanctions-matched entity scoring 99 evaluated every escalation route to False and auto-approved. The defect was latent only because the engine is unwired; wiring a runner without this fix would have shipped the false clear.

Seven rules now govern evaluation, each with a standing regression test. Rules 1-3 came from #912 itself; rules 4-7 were added during review of the fix (#953), which found the same defect class in four more places:

  1. An uninterpretable condition raises (UnsupportedConditionError) — it never evaluates to False. On an escalation route False means "no escalation", so a silently-unparsed condition is a false clear. validate_schema_conditions() runs at engine construction, so an unreadable schema fails at load rather than mid-case.

  2. Evaluation is three-valued (ConditionOutcome.TRUE|FALSE|UNKNOWN). A field absent from the context is UNKNOWN, not False. Compound conditions use Kleene logic — an OR is TRUE if any limb is TRUE even alongside unknowns (a hit on partial data is still a hit), and is FALSE only when every limb is FALSE. A gap on any route evaluated before a matching clearing route withholds the auto-clear.

  3. A truthy blocking risk signal in the context vetoes any auto-clear, independent of route order. This is structural rather than schema-ordered because route order is not a control: periodic_review_v1.yaml declares an auto-approve route and no auto-reject route at all, so correct compound evaluation on its own would have auto-approved a sanctions-matched subject there. Presence is never evidence — only a truthy value vetoes, so sanctions_match: False and sanctions_matches: [] do not.

    A veto is only as good as the names it knows and the depth it reaches, and the #912 version failed at both — found during the #953 review. The list was hand-maintained, and it had drifted: it carried criminal_investigation, a name no producer emits as a key (it is a signal-name string in amlr_annex_factors._ESCALATION_TO_SIGNAL), while the key agents actually stamp — entity_criminal_investigation, written into a finding's details by escalate_entity_criminal_investigation — was absent. Reproduced by execution against the shipped kyb_onboarding_v1.yaml: a subject under EPPO criminal investigation, with a clean sanctions screen and a divergent low score, matched auto_approve. Nine of the ten enforcement escalators were in fact unlisted. BLOCKING_SIGNAL_FIELDS is now bound to compliance_verdict.ESCALATOR_TRACE_CATALOG — the canonical enforcement registry, itself contract-tested against eba_risk_matrix/risk_matrix_service — by a standing test checked in both directions, so the chain runs veto ← catalog ← scorer and a new escalator fails CI until the veto covers it. Names with no producer are kept (an entry nothing emits can never match, and pruning one can only lose scrutiny) but segregated into DEFENSIVE_SIGNALS, so the list never reads as evidence that those signals are being produced and checked. Separately, the scan's depth bound of 6 stopped one level short of the real producer shape (phases.<id>.investigation_results[i].findings[j].details.<flag> is depth 7) and returned an empty list indistinguishable from a clean context; the bound is now 12 and a scan that still stops at it is reported truncated and withholds the clear rather than reading as clean.

  4. Only an auto_decision action closes a review phase. A matched route whose action is human_review — or any action type the engine does not recognise, the fail-closed direction — leaves the phase waiting_for_review, carrying the matched route's id and its assignee/SLA metadata. Before this, execute_phase labelled every matched action auto_decided: on the shipped KYB schema a medium-risk subject matched simplified_review (type: human_review), and the compliance review was completed, and the workflow ran on to activation, with no officer involved. A route names who must look at the case; it is not a stand-in for their decision.

  5. resume() never completes a phase that was not awaiting a signal. current_phase is set when a phase PAUSES (waiting_for_customer/waiting_for_review), when it is BLOCKED (not_executed), and when it FAILED. Only the pause statuses are completed on resume; a blocked or failed phase is RETRIED, because the step index still points at it. Previously resume overwrote a never-executed investigation with signal_received, marked it complete and advanced — recovery silently skipping the phase it was invoked to recover.

  6. A route that can never fire is rejected at load. If an earlier route matches every value a later route could match on the same field, the later route is unreachable — declared scrutiny that never happens. periodic_review_v1.yaml routed ["high", "critical"] to a compliance_manager before routing critical to the MLRO, so the MLRO route was dead; the schema now declares mutually exclusive conditions and _unreachable_routes fails any schema that regresses. Only exactly-decidable domains (simple equals/in, same field) are compared, so a specific-before-general ordering, a compound condition, or two routes on different fields are never flagged.

  7. A matched route's declared route_to is honoured, and may only rewind. periodic_review_v1.yaml routes a critical case to the MLRO with route_to: re_investigation. Rule 6 made that route reachable; nothing then consumed route_to, so after the MLRO signal resume() advanced to the next plan step and ran review_completion — the declared re-investigation never happened, and no surface reported that an instruction had been dropped. resume() now rewinds the step index to the named phase and records routed_to/routed_by on the phase result, so a re-run carries the route that ordered it. A route_to naming an unknown phase, or one at/after the phase declaring it, is refused at load: honouring a forward jump would skip the phases in between (neither completed nor recorded as a gap), which is the suppression direction, while silently dropping the instruction is the defect this rule closes.

Two supporting corrections: phase results are now exposed under the phases.<id>.<field> dotted path the schemas actually address (previously every gate and route condition resolved against nothing), and a phase that could not run returns a typed not_executed gap that transitions the state to BLOCKED — it is recorded in state.gaps and is never appended to completed_phases, so a phase that never ran is no longer indistinguishable from one that did.

Known gap — phase-level condition is validated but never evaluated. WorkflowPhase.condition is checked at load (shape, and that any phases.<id> it names exists) but execute_phase does not consult it, so a conditional ACTION phase runs unconditionally. Observed while reproducing the rule-4 defect: on kyb_onboarding_v1.yaml a single run executed both activation (decision in [approve]) and rejection_handling (decision == reject). Related to the skipped-phase-recorded-as-completed problem in issue #954: fixing it needs a skipped outcome distinct from both completed and the fail-closed not_executed/BLOCKED, so it is deliberately not folded into #953. Until then the engine must not be wired to a live runner.


Integration Map

Where each service connects to the existing Trust Relay pipeline:

ServiceCalled FromReturns
ReferenceDataServiceEBARiskMatrix, risk engineRisk scores, membership checks
QualityScorerTemporal score_investigation_quality activityQualityScore dict → additional_data.quality_scores
ModelTiersAll agent invocations via get_model_for_agent()Model name string for PydanticAI
EBARiskMatrixTemporal compute_eba_risk activityEBARiskResult with SHA-256 hashes
DomainEventsAll Temporal activitiesDomainEventaudit_events table
EntityMatcherPost-ETL deduplication passMatchResult with confidence score
SurvivorshipResolverGraph ETL entity upsertResolved field values + ConflictRecord list
WorkflowSchemaWorkflow template compilerExecutionPlan → Temporal dynamic workflow

Competitive Context

These services address areas where the Atlas co-founder's codebase had architectural depth that Trust Relay's first implementation lacked.

Before adoption: Risk scoring used a custom 4-dimension weighted-average (ARIA). Entity conflicts were silently resolved with no provenance. Investigation quality was not measured. All agents used the same model. Event chains were flat and uncorrelated.

After adoption:

  • Risk scoring follows EBA/GL/2021/02 with a published SHA-256 determinism proof — auditors can verify any stored result
  • Entity conflicts are resolved transparently by a trust hierarchy, with protected fields that cannot be overwritten by lower-trust sources
  • Every investigation receives a quality score on 5 weighted dimensions (best-effort, surfaced for human review)
  • LLM model selection is centralised per-agent through tiered assignments, enabling deployment-time cost/accuracy trade-offs via env-var tier overrides
  • Every audit event carries a correlation_id and causation_id, enabling full chain reconstruction ("everything that happened because of this sanctions hit")
  • New compliance workflow types are created from YAML without code changes

None of these capabilities require changes to the core investigaton loop, the Temporal workflow state machine, or the customer portal. They integrate as services that the existing pipeline calls.

Trust Relay's architectural strengths that Atlas does not have — multi-tenancy, customer portal, Belgian regulatory depth (5 sources), EVOI decision theory, GovernanceEngine, supervised autonomy, compliance memory (Letta), and the Network Intelligence Hub — are preserved and unaffected by the adoption.