Story 1414: R12 Baggage Posture Gate + MeterProvider Ban
Why this exists (the catastrophe it prevents). W3C
baggage is a propagation channel that rides an HTTP egress header
(baggage:) all the way out of the process. If a developer ever
puts firm_id into baggage, the tenant id leaves PRAMAAN on the
very request that calls a third-party LLM provider (OpenRouter / Bedrock) —
a cross-tenant identity leak to an outside vendor, the dominant risk for a
legal-tech product. pramaan-docs ADR-124 §D10 (R12) settles the posture:
functions is baggage-free on
OTEL_PROPAGATORS=xray,tracecontext. run_id /
form_id already propagate explicitly via the
agent_invoke.v1 meta envelope and SQS message-attribute /
payload carriers — never baggage. This Story does not change
that posture; it makes the posture a tested CI boundary so a
future agent or human cannot quietly re-introduce baggage.
OTEL_PROPAGATORS=...,baggage) is
not wanted and is not present here. Instrumentation-exempt per
ADR-124 — the deliverable is the guardrail.The Change: Extend the One Gate, Do Not Add a Second
FN-HELP #1431 created the canonical functions observability gate
(.github/scripts/observability-guardrail-check.sh) for the
raw-tracer / TracerProvider rules. Per the #1414 directive, this
Story extends that same script with the R12 baggage rules
and the MeterProvider ban — the existing tracer/provider rules are kept
verbatim. The companion test module
(tests/unit/test_baggage_meter_guardrail.py) drives the real
shipped script through OBSERVABILITY_GUARDRAIL_FILES_OVERRIDE, so
every assertion is against the enforcement that actually runs in CI.
The Rules Added
| Rule | Forbidden pattern | Where it bites |
|---|---|---|
| A firm_id never in baggage | set_baggage("firm_id"…) / setBaggage / baggage.set of firm_id | everywhere — including the approved helper |
| B processor location | BaggageSpanProcessor( | outside src/shared/observability/** |
| C allowlist literal | ALLOWED_BAGGAGE_KEYS not exactly {run_id, form_id}; FORBIDDEN_BAGGAGE_KEYS not rejecting firm_id | wherever a baggage allowlist is declared (none today) |
| D propagator | OTEL_PROPAGATORS containing baggage with no approved egress stripper | workflow YAML + source; bans baggage adoption without a stripper |
| E meter provider | MeterProvider( / set_meter_provider( | outside src/shared/observability/** |
All patterns are paren/quote anchored so prose and comments
(e.g. the existing “firm_id is never baggage” docstrings) do not
false-positive. get_meter is deliberately not banned:
metrics.py legitimately calls it inside the helper.
Two Deliberate Subtleties
- Rule A is global, not helper-exempt. The tracer rules allow the observability helper; firm_id-in-baggage does not. The helper is scanned for firm_id; only test files (which carry planted-negative fixtures by design) are skipped. So “firm_id never in baggage, even in the helper” is genuinely enforced.
- Rule D needs a stripper escape hatch. A baggage
propagator is permissible only when an approved egress stripper
(
def strip_baggage_from_request, or an explicitOBSERVABILITY-ALLOW: baggage-egress-strippersentinel) ships alongside it. functions has no stripper today, so any baggage propagator fails — the gate enforces baggage-free by construction.
Acceptance Evidence — Planted Negatives (RED per rule)
“Tests green” is not enough. Each rule was proved by planting a forbidden pattern in a real source / workflow path, running the gate, observing exit 1, then reverting to a clean tree (exit 0).
| Rule | Planted negative → gate result |
|---|---|
| A (src) | src/app_api/_neg.py sets firm_id baggage → exit 1 |
| A (helper) | src/shared/observability/_neg.py sets firm_id baggage → exit 1 (helper not exempt) |
| B | BaggageSpanProcessor() in src/app_api → exit 1 |
| C | ALLOWED_BAGGAGE_KEYS = frozenset({"run_id","firm_id"}) → exit 1 |
| C | FORBIDDEN_BAGGAGE_KEYS = frozenset({"prompt"}) → exit 1 |
| D | workflow YAML OTEL_PROPAGATORS: 'xray,tracecontext,baggage' → exit 1 |
| E | MeterProvider() in src/app_api → exit 1 |
| E | metrics.set_meter_provider(p) in src/app_api → exit 1 |
| control | clean repo after revert → exit 0 “Observability guardrail passed.” |
Controls (allowed forms pass)
run_id/form_idbaggage in the helper — allowed.BaggageSpanProcessorinsidesrc/shared/observability/**— allowed.ALLOWED_BAGGAGE_KEYS = frozenset({"run_id","form_id"})— allowed.OTEL_PROPAGATORS: 'xray,tracecontext'(the shipped value) — allowed.- baggage propagator with
strip_baggage_from_requeststripper — allowed (escape hatch). get_meterinside the helper — allowed (not banned).
TracerProvider / set_tracer_provider /
AwsXRayIdGenerator / raw-tracer rules are kept intact and
re-asserted by test_observability_guardrail_check.py and a
regression test in the new module.Acceptance Criteria → Evidence
| Acceptance criterion | Test / check |
|---|---|
| reject firm_id in baggage anywhere | test_rejects_firm_id_set_into_baggage, test_rejects_firm_id_baggage_even_inside_helper |
| reject BaggageSpanProcessor outside helper | test_rejects_baggage_span_processor_outside_helper (+ helper control) |
| allowlist literal exactly {run_id, form_id}; forbidden rejects firm_id | test_rejects_allowlist_literal_with_firm_id, _with_extra_key, test_rejects_forbidden_keys_literal_missing_firm_id (+ controls) |
| baggage propagator banned without stripper | test_rejects_baggage_propagator_without_stripper (+ stripper / clean controls) |
| ban MeterProvider / set_meter_provider outside helper; keep get_meter | test_rejects_meter_provider_construction_outside_helper, test_rejects_set_meter_provider_outside_helper, test_allows_get_meter_in_helper |
| existing tracer/provider gate intact | test_existing_raw_tracer_gate_still_enforced |
One-sentence recall: functions stays
baggage-free — the gate fails CI the moment firm_id touches baggage, a
baggage propagator/processor appears without an approved stripper, a baggage
allowlist drifts from {run_id, form_id}, or a meter provider is
built outside the shared observability helper.