Skip to main content

ADR-0155: Declared-Signal Producers for Hidden-Control Detection

Date: 2026-07-28 Status: Accepted Deciders: [the engineer + Claude Opus 4.8]

Decision context:

  • Latency: negligible — pure in-process dict classification over the already-fetched investigation result, plus one @lru_cached reference-data JSON load per process. No network/LLM calls.
  • Dependency surface: no new packages; one new versioned reference-data catalog (config/reference_data/declared_signal_vocabulary.json) + one new pure module (app/services/declared_signal_classifier.py).
  • Debuggability: deterministic — same investigation result classifies identically every run; every stamped signal carries an evidence_ref (constructed as registry:{country}:{reg_no} for an arrangement party, or read through from the item's own evidence_ref/source for a nominee/concert stamp) for the EU AI Act Art. 12 audit trail; classifier call is guard-and-swallow (failure logs, never breaks the investigation).
  • Reversibility: single config flag flip, declared_signal_producer_enabled (default False). Flag-off is byte-identical to pre-change behavior (empty concert_groups/nominee_edges/case_data["arrangements"] everywhere, classifier never invoked).
  • Blast radius: additive only. The consumer engines (UBOComputationEngine._apply_concert/_apply_nominee, compute_arrangement_ubos), the Neo4j read hooks (fetch_ownership_graph, fetch_arrangements), and the ETL write hooks (_declared_signal_rel_props, _upsert_arrangements) are all unchanged — this ADR wires a producer into an existing, previously-dark pipeline.
  • Alternative considered: per-connector inline classification (stamp the 9 declared-signal keys inside each of the ~13 decoder/connector modules) — rejected, see Alternatives.

Context

ADR-0134 (control-via-other-means: acting-in-concert + first-class nominee, Art. 53(2)-(4)) and ADR-0135 (look-through legal-arrangement UBO regimes, Art. 54-61) built the full consumer half of hidden-control detection: UBOComputationEngine._apply_concert/_apply_nominee, compute_arrangement_ubos, the Neo4j read hooks that assemble OwnershipGraph.concert_groups/.nominee_edges from nine declared-signal relationship properties (is_nominee, nominee_for, nominee_for_reg, nominee_evidence_ref, formal_arrangement, source, concert_party_id, concert_basis, concert_label — the _DECLARED_SIGNAL_KEYS), and the ETL write hooks that pass those nine keys through to Neo4j (graph_etl._declared_signal_rel_props) and write LegalArrangement/ArrangementParty nodes from case_data["arrangements"] (graph_etl._upsert_arrangements).

None of it ever fired on a real investigation. Connector decoders never stamped the nine keys onto their output items, and case_data["arrangements"] was never populated — so concert_groups, nominee_edges, and every arrangement-based BO stayed permanently empty regardless of what a registry actually disclosed (e.g. an Estonian usaldusisik/trustee role, or a Dutch Stichting/foundation legal form). Issues #630 (concert + nominee) and #632 (legal arrangements) are the producer half of that pipeline.

The governing constraint, carried over unchanged from ADR-0134/0135 and the platform's never-suppress doctrine (ADR-0067): a hidden-control signal must never be inferred from correlation. Co-located minority holdings must never synthesize a concert group; a small holding must never imply a nominee relationship. Every signal the producer emits must trace to a sourced declaration — a registry role string, an explicit register nominee flag, or a disclosed acting-in-concert filing — never to arithmetic over the ownership graph. Getting this producer wrong in the inferring direction would fabricate a beneficial owner; the platform's existing false-BO defect class (ADR-0073 R9) is exactly what this guards against.

Decision

Add a versioned reference-data catalog, config/reference_data/declared_signal_vocabulary.json (version: "1.0.0"), mapping source-specific role strings and legal-form codes to classified signals: role_vocabulary (source-key → raw role string → {kind: arrangement_role|nominee|concert, role?} — nominee detection runs through a role_vocabulary entry carrying kind: "nominee", not a separate lookup), legal_forms (ISO country → legal-form code → ArrangementType), plus two placeholder keys reserved for future sources: nominee_flags (source-key → explicit register nominee-flag field) and concert_bases (source-key → disclosed acting-in-concert mapping). Both nominee_flags and concert_bases are inert at MVP — present in the catalog shape but not read by any code path yet (no automated register exposes an explicit nominee-flag field or an acting-in-concert disclosure feed today); nominee detection runs entirely through role_vocabulary, and concert detection runs entirely through a pre-stamped concert_party_id on the item (see below) — both extension points are named for when a source disclosing them is integrated. An absent vocabulary row means that role is never classified — fail-closed by construction, mirroring the ADR-0143 connector-contract and ADR-0147 regulator-register catalog pattern. Adding a jurisdiction or source is a reviewed data edit, not a code change.

A new pure, deterministic module app/services/declared_signal_classifier.py provides two entry points and a thin orchestrator:

  • classify_control_signals(items: list[dict], *, vocab: dict, country: str | None) -> list[dict] — for each ownership/UBO item dict whose role/relationship-type string resolves against role_vocabulary as kind: "nominee", stamps is_nominee/nominee_for/nominee_for_reg/formal_arrangement/nominee_evidence_ref only when the item already carries a declared nominee_for principal (nominee_evidence_ref reuses the item's own evidence_ref, falling back to its source string — there is no record-id concatenation here); an item already carrying a pre-declared concert_party_id (stamped upstream by a sourced acting-in-concert declaration, never inferred here) has its concert_basis/concert_label/concert_evidence_ref passed through. Items matching neither are returned unchanged. Note there is no source parameter on this function — the per-item evidence_ref/source fields already on each input dict are what get read and reused.
  • build_arrangements(case_data, *, vocab, country) — returns case_data["arrangements"] dicts (arrangement_id, arrangement_type, name, governing_law, parties: [{name, registration_number, role, evidence_ref, is_natural_person, source}]), triggered by a foundation/trust/CIU legal-form match on the subject entity (case_data["legal_form"] against legal_forms[country]); each director/party is classified via the same role_vocabulary lookup (falling back to the legal form's default board_role), with evidence_ref built as registry:{country}:{registration_number} (this is the one place in the module where evidence_ref is constructed from a source + record id, rather than read through from an existing item field). A corporate (non-natural-person) party is emitted as-is; the ADR-0135 look-through/not_assessed handling in compute_arrangement_ubos resolves it downstream, unchanged.
  • apply_declared_signals(case_data, investigation_result, *, country) — the orchestrator called from the wiring site; loads the cached vocabulary (_load_vocab, @lru_cache), runs both entry points, and mutates case_data/investigation_result in place.

Two fail-closed cases are load-bearing and covered as first-class tests, not edge cases:

  • Nominee flagged, no principal declared — the classifier never invents a nominee_for. It emits a nominee_unresolved coverage marker (not_assessed) and stamps no nominee edge; a NomineeRelationship without a sourced principal would itself be a fabricated BO.
  • Concert only from a sourced declarationconcert_bases is empty at MVP (no automated acting-in-concert disclosure feed exists), so co-located minority holdings never synthesize a concert group. This is honest emptiness, not accidental dormancy — the extension point (wiring a disclosure feed into concert_bases) is named in the design spec for when one becomes available.

Wiring placement: the classifier runs inside the populate_knowledge_graph Temporal activity (app/workflows/activities.py), immediately before GraphETL.process_case_completion — not inside the workflow itself, because the vocabulary catalog requires file I/O and Temporal workflow code must stay deterministic/non-blocking (an initial pass placed it in the workflow and was relocated during implementation for this reason). Every downstream UBOComputationService.compute(...) call site was audited and confirmed to thread arrangements= through: the KYB pre-approval compute in activities.py, the convenience path in graph_service.py, and the monitoring re-compute in monitoring_check_service.py.

The whole producer is dark-launched behind declared_signal_producer_enabled (app/config.py, default False). Flag-off: apply_declared_signals is never called, case_data["arrangements"] stays an empty/absent list, and no decoder item carries the nine keys — byte-identical to pre-ADR-0155 behavior.

Consequences

Positive

  • Activates the two dark-launched-but-fully-built engines (ADR-0134 acting-in-concert/nominee, ADR-0135 legal-arrangement look-through) on real investigations for the first time.
  • Extensible by data edit: a new jurisdiction's trustee/foundation role vocabulary, or a newly-observed nominee flag, is a reviewed JSON change to declared_signal_vocabulary.json — no code change, no new migration.
  • The never-infer guard lives in exactly one auditable place (the classifier + its catalog) rather than being re-implemented per connector, so it can be reviewed and tested once.
  • Every stamped signal carries evidence_ref, satisfying the EU AI Act Art. 12 chain-of-custody requirement for a signal that can raise a subject's risk tier.

Negative

  • Thin MVP real-data coverage: nominee and concert signals only fire when a source exposes an explicit flag or disclosure, which few connectors do today. concert_bases is effectively empty at ship time — the concert path is built, tested, and fail-closed-correct, but will not visibly change any live case's output until a disclosure-feed source is integrated.
  • No automated acting-in-concert disclosure source exists yet; wiring one is tracked as a follow-up, not part of this ADR.

Neutral

  • The officer-attested manual declaration path is deliberately deferred (2026-07-28 design decision, not a defect) — the read hooks and engines are source-agnostic (they read the nine Neo4j properties / arrangement nodes regardless of writer), so a future manual-declaration UI would be a second writer through the same unchanged ETL write hooks, requiring no rework of the catalog, classifier, or engines.
  • New-country registry expansion and new external-source integrations are out of scope; the producer classifies only what current connectors already return.

Alternatives Considered

Per-connector inline classification — rejected

Stamping the nine declared-signal keys inside each of the ~13 individual decoder/connector modules would scatter the never-infer guard across N independently-maintained call sites, making it impossible to audit as one policy and inviting drift (one connector correctly fail-closing on an unrecognized role, another guessing). A single classifier reading one versioned catalog keeps the guard reviewable in one place, consistent with the ADR-0143 connector-contract and ADR-0147 regulator-register precedent of centralizing declarative classification.

LLM-assisted classification — rejected

An LLM asked to classify a role string or infer a nominee/concert relationship from context would violate the deterministic + never-infer constraint at the center of this design — it would readily "infer" a nominee from surrounding text even without a sourced declaration, reintroducing exactly the false-BO defect class (ADR-0073 R9) this producer exists to avoid. The classification path stays pure dict/string matching against a reviewed catalog; no model call is in the loop.