Task · SFL-0044

config: reject org/sf_org_id/environment in sink.loki.labels under multi-org (static labels silently clobber per-org identity)

Status
To Do
Labels
followup, phase-1
Milestone
Correctness & data-integrity hardening
Updated
2026-08-14

Description

What

In multi-org mode the operator’s sink.loki.labels are merged over every entry’s own labels, so a static environment, org or sf_org_id key silently overwrites the per-org identity labels that OrgSource injects. No validation rejects it.

The path:

The misconfiguration is actively invited by the docs: the generated config reference gives {environment: prod} as the example value for sink.loki.labels (docs/config-reference.md:163, generated from examples=[{"environment": "prod"}] at src/sf2loki/config.py:964), and build_static_labels names overriding environment as a legitimate single-org use (src/sf2loki/app.py:124).

Why it matters

An operator migrating a working single-org config to orgs: keeps sink.loki.labels: {environment: prod} (the documented example). Startup succeeds with no warning. Every entry from every org — sandbox and production alike — is then pushed with environment="prod", so the two orgs land in the same Loki stream dimension and become indistinguishable. Per-org dashboards and alert rules that slice by environment aggregate across environments and under-report.

The sf_org_id and org variants are worse: all orgs collapse onto one static identity value, so org attribution of security-relevant events (login, audit, permission-set change streams) is silently wrong, and per-org checkpoint namespacing no longer matches what the stream labels claim. Because the streams are self-consistent, the corruption is invisible until someone cross-checks against Salesforce.

Single-org mode is unaffected and must stay unaffected: there environment/sf_org_id are genuinely deployment-wide values produced by build_static_labels (src/sf2loki/app.py:117), and overriding them is intentional, pinned by tests/test_static_labels.py:16.

Proposed approach

Reject the per-org identity keys at config-validation time, so sf2loki --check and sf2loki doctor catch it before any network call and before the sink is constructed.

  1. In src/sf2loki/sinks/loki/labels.py, add alongside RESERVED_STATIC_LABELS:

    # Additionally per-entry in MULTI-org mode: OrgSource injects these per org
    # (sources/org_adapter.py), so a static override collapses org separation.
    MULTI_ORG_RESERVED_STATIC_LABELS: frozenset[str] = RESERVED_STATIC_LABELS | frozenset(
        {"org", "sf_org_id", "environment"}
    )

    guard_static_labels already takes reserved as a parameter (src/sf2loki/sinks/loki/labels.py:37), so no signature change is needed.

  2. Add a model_validator(mode="after") on Config in src/sf2loki/config.py (next to _validate_org_topology at src/sf2loki/config.py:1384, ordered after it) that, when self.orgs is non-empty, calls guard_static_labels(self.sink.loki.labels, reserved=MULTI_ORG_RESERVED_STATIC_LABELS) and re-raises as a ValueError whose message names the offending keys and states the remedy (set environment per entry in orgs[].environment; org comes from orgs[].name; sf_org_id is resolved from each org’s token). Import is cycle-free: both src/sf2loki/sinks/__init__.py and src/sf2loki/sinks/loki/__init__.py are empty and labels.py imports only collections.abc.

  3. Update the error-adjacent docs: note the multi-org restriction in the sink.loki.labels field description (src/sf2loki/config.py:959), in deployment_static_labels’ docstring (src/sf2loki/app.py:660), in the label-strategy section (docs/architecture.md:250) and in src/sf2loki/sinks/CLAUDE.md’s label-guard note. Re-run just gen-config after touching the field description (drift gate: tests/test_config_artifacts_drift.py).

Alternative rejected: widening RESERVED_STATIC_LABELS unconditionally. That would break the documented and tested single-org environment override (tests/test_static_labels.py:16).


Imported from GitHub issue #128 on 2026-08-14, when this repo migrated from GitHub Issues to Backlog.md. The original issue has been deleted; its verbatim body, labels and comments are preserved in archive/issues-dump.json (jq '.[] | select(.number == 128)' archive/issues-dump.json).

Filed from the 2026-07-30 full-repo audit (11 finder lanes + adversarial verification per finding).

Acceptance Criteria

Definition of Done

References

View the source file on GitHub