ADR-0003: Extend Phase 3 TrustScorer via open signal-kind registry; do not replace¶
Status: Accepted Date: 2026-05-12 Tags: trust · extension-by-addition · registry · phase-boundary Related: ADR-0002, production ADR-0008, production ADR-0015
Context¶
Phase 3 already ships a TrustScorer implementing the strict-AND of objective signals per ADR-0008. Phase 5 needs to score six signal kinds (build, install, tests, trace, policy, cve_delta) — three of which (trace, policy, cve_delta) Phase 3 doesn't know about. Phase 7 distroless will later add baseimage and shell_presence. Two of the three input designs proposed replacing Phase 3's scorer with a new aggregator; one proposed extending. The roadmap's "extension by addition" commitment forbids editing existing components when new task types arrive. See final-design.md §Synthesis ledger row: Phase 3 TrustScorer relationship and phase-arch-design.md §Component design — Gate + StrictAndGate.
Options considered¶
- Replace with
SignalAggregator(performance-first) — New Phase 5 scorer. Drops Phase 3's strict-AND. Forces Phase 3 callsites to migrate or wrap. - Replace with
ObjectiveSignalsmodel + new evaluator (security-first) — Type-strict, but two scorers coexist in the codebase with no clear precedence. - Closed
Literalof signal kinds in Phase 5 (best-practices) — ExtendsTrustScorerbut pins the signal set to four kinds. Phase 7 distroless can't add a kind without editing Phase 5's Literal. - Extend Phase 3's
TrustScorer; widen signal kinds via an open@register_signal_kindregistry — Reuses Phase 1's@register_probepattern.StrictAndGateis a ~40 LOC adapter that materializesTrustSignallist fromObjectiveSignalsand calls Phase 3's existingscore(...).
Decision¶
Phase 3's TrustScorer is the canonical strict-AND scorer; Phase 5 does not ship a second one. StrictAndGate.evaluate is a thin adapter that materializes list[TrustSignal] from populated ObjectiveSignals sub-models and delegates to Phase3TrustScorer.score(signals). New signal kinds register via @register_signal_kind("name") decorator; TrustSignal.kind is widened from a closed enum to an open string keyed by the registry. The widening is additive and one-shot in Phase 3; Phase 7 (and beyond) registers kinds without editing.
Tradeoffs¶
| Gain | Cost |
|---|---|
| Single source of truth for strict-AND scoring — Phase 3's logic is reused untouched | A ~40 LOC adapter must translate ObjectiveSignals → list[TrustSignal] faithfully (property test enforces equivalence) |
Phase 7 distroless adds baseimage + shell_presence collectors without touching TrustScorer or StrictAndGate |
TrustSignal.kind is now an open string — the type system no longer enumerates valid kinds (registry collision must be caught at import) |
| Honors "extension by addition" — the most-attacked commitment in final-design.md §Load-bearing commitments §2.5 | Adding a kind means an ADR amendment + a new optional field on ObjectiveSignals + a decorator registration; not a one-line change |
Property test: StrictAndGate.evaluate returns the same passed value as all(s.passed for s in signals) AND as what Phase 3's TrustScorer.score(...) returns — equivalence regression-protects both sides |
If Phase 3 ever drops strict-AND for weighted scoring, this adapter and its test loudly break |
tests/integration/test_trustscorer_widening.py is a worked example Phase 7 can copy |
Test fixtures must enumerate the cartesian product of populated/unpopulated signal kinds — ~2^6 cases |
Consequences¶
src/codegenie/gates/strict_and.pyis the only adapter — ~40 LOC, no business logic.TrustSignal.kindwidens fromLiteral[...]tostrkeyed by the@register_signal_kindregistry; Phase 3's contract-snapshot test regenerates.tests/integration/test_trustscorer_widening.pyexercises: (a) Phase 3's strict-AND still passes with only build/install/test populated; (b) the new kinds (trace, policy, cve_delta) participate in strict-AND without changing scorer logic.sandbox/signals/registry.pyraisesSignalKindAlreadyRegisteredat import on duplicate kind — open Q10.- Phase 5 does not own the threshold calibration logic (ADR-0015) — strict-AND is binary; calibration is a future-phase concern when scoring becomes weighted.
- New invariant: any new signal kind must (a) register via decorator, (b) add an optional
<kind>: SignalSubModel | None = Nonefield toObjectiveSignals, (c) be referenced by an ADR amendment. The Pydantic model widening is the only "edit" — and it is explicitly ADR-gated.
Reversibility¶
Low. Reverting means picking a winner between "open registry of signal kinds" and "closed Literal." Switching back to closed would break Phase 7's planned extension and invalidate the registry pattern reused from @register_probe. Reverting to a separate scorer (not extending Phase 3's) would duplicate strict-AND logic and force a divergence resolution in every later phase. The adapter is small enough to delete; the contract decisions it enables are not.
Evidence / sources¶
- final-design.md §Synthesis ledger — Phase 3
TrustScorerrelationship row (winner score 12) - final-design.md §Synthesis ledger — Signal kind enum shape row (winner: open registry — departure from all three)
- final-design.md §Departures §1 — open signal-kind registry
- final-design.md §Load-bearing commitments §2.5 — Extension by addition
- phase-arch-design.md §Component design — Signal collectors
- production ADR-0008 — the strict-AND contract this extends