D79 — DLP V2 egress detection: a central dlp_egress_events log + three config-driven egress rules, safe-default disabled (RESOLVED, mechanism-only, extends D44/D57/D53/D54)
Architecture decision record, relocated verbatim from the retired single-file
docs/tenancy/decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.
The docs/tracking/dlp-v2-scope.md egress-rules deferral is resolved as a MECHANISM-ONLY landing (the
D77/D78 idiom): the machinery ships, but no threshold values ship — every egress rule is disabled by
default, so nothing fires until product/compliance signs off the exact thresholds. It is the egress
analogue of the V1 access-rule pipeline, built as a self-contained parallel substrate (dlp reads only its
OWN logs — D57 — never the raw vendor/download/export surfaces).
- Central egress log —
dlp_egress_events. An append-only CENTRAL side-effect log on the unmanagedpgsql_logsconnection (thedata_access_logsprecedent, D54): a plain biginttenant_idwith NO FK, never RLS-policied (registered inCentralTableInverseLeakTest, exempt inSchemaConventionTest::PUBLIC_ID_EXEMPT+ModelConventionsTest), so no isolation test (it is not tenant-scoped —tenants:verify-isolationstill shows the same 31 tenant tables). Redaction-safe: anegress_kind(download/export), avolume+volume_unit(bytesfor a download,rowsfor an export — a common measure so the volume/spike rules aggregate; both stored because they are genuinely different units), a resource descriptor + optional PUBLIC uuid,message_class,correlation_id,occurred_at. No uuid/soft-delete/blamable. - Declare-driven capture —
Modules\Foundation\Bus\Contracts\EgressesData(theReadsClassifiedDatasibling). A query/command that egresses data declares its kind/resource/unit + aegressVolume($result): intthat reads the volume off the handler RESULT (the true magnitude — the streamed byte size / the exported row count — is only known post-handler), so the bus telemetry eventsCommandExecuted/QueryExecutednow also carry the handler$result.StreamDocumentQuery(download, bytes) +BuildTransactionsExportQuery(export, rows) are the first two sources.Listeners\RecordEgress(auto-discovered off the telemetry events, theRecordClassifiedAccessprecedent) →Bus::chain([PersistEgressEventJob, EvaluateEgressRulesForEventJob])on thedlpqueue: persist is a pure append viaServices\AppendEgressEventonpgsql_logs(survives a rolled-back tx; sanctioned by exact class inCqrsBoundaryTest), and evaluate runs only after persist commits, wrapped intenancy()->central()so the central read + central incident write never depend on the RLS role's grants. - Three config-driven rules over
Services\EgressLogReader, registered inRules\EgressRuleRegistry:ExportThresholdRule(a single export's rows > threshold),AbnormalVolumeRule(current-window volume > rolling-baseline-average × multiplier),EgressSpikeRule(short-window burst > recent-baseline × multiplier). Each is strict (<= threshold ⇒ null) and safe-default DISABLED (anullthreshold/multiplier ⇒ the rule short-circuits before any DB read).Rules\EgressEvaluator(theDlpRuleEvaluatormirror) runs event-driven + scheduled (dlp:evaluate-egress-rules, every 15 min); a hit REUSES the existingOpenOrRaiseIncidentCommand→DlpIncident+IncidentAlerter(no new incident machinery — a pure-egress incident opens with the rule/score/summary + correlation id and a 0-record enumeration).
Proven by EgressRulesTest (each rule above/below threshold + disabled-by-default), EgressCaptureTest
(declare-driven capture writes the row with the right kind/volume/unit — uuids only, no raw data — + the
real download/export queries' declarations + not-captured-when-undeclared) and EgressEvaluatorTest (a hit
opens an incident via OpenOrRaiseIncidentCommand, none when disabled, and the full capture → persist →
evaluate chain). Prerequisite to ENABLING (config, not code): product/compliance sign-off of the exact
thresholds; optional per-unit/per-kind normalisation of the volume/spike baselines.