Skip to main content

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 unmanaged pgsql_logs connection (the data_access_logs precedent, D54): a plain bigint tenant_id with NO FK, never RLS-policied (registered in CentralTableInverseLeakTest, exempt in SchemaConventionTest::PUBLIC_ID_EXEMPT + ModelConventionsTest), so no isolation test (it is not tenant-scoped — tenants:verify-isolation still shows the same 31 tenant tables). Redaction-safe: an egress_kind (download/export), a volume + volume_unit (bytes for a download, rows for 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 (the ReadsClassifiedData sibling). A query/command that egresses data declares its kind/resource/unit + a egressVolume($result): int that 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 events CommandExecuted/QueryExecuted now also carry the handler $result. StreamDocumentQuery (download, bytes) + BuildTransactionsExportQuery (export, rows) are the first two sources. Listeners\RecordEgress (auto-discovered off the telemetry events, the RecordClassifiedAccess precedent) → Bus::chain([PersistEgressEventJob, EvaluateEgressRulesForEventJob]) on the dlp queue: persist is a pure append via Services\AppendEgressEvent on pgsql_logs (survives a rolled-back tx; sanctioned by exact class in CqrsBoundaryTest), and evaluate runs only after persist commits, wrapped in tenancy()->central() so the central read + central incident write never depend on the RLS role's grants.
  • Three config-driven rules over Services\EgressLogReader, registered in Rules\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 (a null threshold/multiplier ⇒ the rule short-circuits before any DB read). Rules\EgressEvaluator (the DlpRuleEvaluator mirror) runs event-driven + scheduled (dlp:evaluate-egress-rules, every 15 min); a hit REUSES the existing OpenOrRaiseIncidentCommandDlpIncident + 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.


← Decision log index