feat(ttp): E.3.15 UKC bridge — production phase-handoff edge fires

Add BaseRepository.list_ttp_decky_phases(identity_uuid) returning
per-decky tag observations as (decky_id, tactic, created_at_ts) rows
ordered by creation time. Rewrite from_identity_row() to project
tactic → UKCPhase via tactic_to_ukc_phase and populate the four
phase-handoff maps (first/last_phase_per_decky,
first/last_seen_per_decky) so combined_campaign_weight finally lights
up on real DB rows — not just synthetic fixtures.

ConnectedComponentsCampaignClusterer.tick() pulls each active
identity's per-decky phase observations before projecting features.
Repo failures are non-fatal: a partial repo falls back to the empty
phase-handoff signal (legacy behavior) so the worker stays up.

tests/clustering/test_ttp_phase_handoff.py pins the production-row
pair clearing CAMPAIGN_EDGE_THRESHOLD on a C2 → DISCOVERY hand-off —
the trip-wire that says the whole project paid off.

commands_by_phase_on_decky itself stays empty on the production path:
it is consumed only by the synthetic-fixture similarity surface, and
the phase-handoff edge does not use it. Synthetic fixtures still
populate it directly via from_synthetic_identity.
This commit is contained in:
2026-05-01 21:01:58 -04:00
parent 101127247e
commit 403d83faba
5 changed files with 259 additions and 9 deletions

View File

@@ -1352,3 +1352,25 @@ class BaseRepository(ABC):
async def list_distinct_techniques(self) -> list[TechniqueRollupRow]:
"""Fleet-wide distinct-technique rollup."""
raise NotImplementedError
async def list_ttp_decky_phases(
self, identity_uuid: str,
) -> list[dict[str, Any]]:
"""Per-decky tag observations for the campaign-clusterer's UKC
bridge (E.3.15).
Returns every ``ttp_tag`` row for *identity_uuid* (and the IPs
rolling up to it) carrying a non-NULL ``decky_id`` and
``tactic``, projected to ``{decky_id, tactic, created_at_ts}``.
Callers project ``tactic`` → :class:`UKCPhase` via
:func:`decnet.clustering.ukc.tactic_to_ukc_phase` to populate
:class:`IdentityFeatures.first_phase_per_decky` /
``last_phase_per_decky`` / ``first_seen_per_decky`` /
``last_seen_per_decky`` so the production phase-handoff edge
can finally fire.
Default body returns ``[]`` so legacy mocks / non-SQLModel
repos remain valid; the real implementation lives on the
SQLModel TTP mixin.
"""
return []