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 asregistry:{country}:{reg_no}for an arrangement party, or read through from the item's ownevidence_ref/sourcefor 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(defaultFalse). Flag-off is byte-identical to pre-change behavior (emptyconcert_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 againstrole_vocabularyaskind: "nominee", stampsis_nominee/nominee_for/nominee_for_reg/formal_arrangement/nominee_evidence_refonly when the item already carries a declarednominee_forprincipal (nominee_evidence_refreuses the item's ownevidence_ref, falling back to itssourcestring — there is no record-id concatenation here); an item already carrying a pre-declaredconcert_party_id(stamped upstream by a sourced acting-in-concert declaration, never inferred here) has itsconcert_basis/concert_label/concert_evidence_refpassed through. Items matching neither are returned unchanged. Note there is nosourceparameter on this function — the per-itemevidence_ref/sourcefields already on each input dict are what get read and reused.build_arrangements(case_data, *, vocab, country)— returnscase_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"]againstlegal_forms[country]); each director/party is classified via the samerole_vocabularylookup (falling back to the legal form's defaultboard_role), withevidence_refbuilt asregistry:{country}:{registration_number}(this is the one place in the module whereevidence_refis 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_assessedhandling incompute_arrangement_ubosresolves 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 mutatescase_data/investigation_resultin 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 anominee_unresolvedcoverage marker (not_assessed) and stamps no nominee edge; aNomineeRelationshipwithout a sourced principal would itself be a fabricated BO. - Concert only from a sourced declaration —
concert_basesis 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 intoconcert_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_basesis 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.