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.

Scope: CI enforcement only. No runtime emitters, no propagator changes, no baggage enablement. The abandoned PR #364 baggage-propagator flip (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

RuleForbidden patternWhere it bites
A firm_id never in baggageset_baggage("firm_id"…) / setBaggage / baggage.set of firm_ideverywhere — including the approved helper
B processor locationBaggageSpanProcessor(outside src/shared/observability/**
C allowlist literalALLOWED_BAGGAGE_KEYS not exactly {run_id, form_id}; FORBIDDEN_BAGGAGE_KEYS not rejecting firm_idwherever a baggage allowlist is declared (none today)
D propagatorOTEL_PROPAGATORS containing baggage with no approved egress stripperworkflow YAML + source; bans baggage adoption without a stripper
E meter providerMeterProvider( / 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

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).

RulePlanted 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)
BBaggageSpanProcessor() in src/app_api → exit 1
CALLOWED_BAGGAGE_KEYS = frozenset({"run_id","firm_id"}) → exit 1
CFORBIDDEN_BAGGAGE_KEYS = frozenset({"prompt"}) → exit 1
Dworkflow YAML OTEL_PROPAGATORS: 'xray,tracecontext,baggage' → exit 1
EMeterProvider() in src/app_api → exit 1
Emetrics.set_meter_provider(p) in src/app_api → exit 1
controlclean repo after revert → exit 0 “Observability guardrail passed.”

Controls (allowed forms pass)

Regression: the existing 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 criterionTest / check
reject firm_id in baggage anywheretest_rejects_firm_id_set_into_baggage, test_rejects_firm_id_baggage_even_inside_helper
reject BaggageSpanProcessor outside helpertest_rejects_baggage_span_processor_outside_helper (+ helper control)
allowlist literal exactly {run_id, form_id}; forbidden rejects firm_idtest_rejects_allowlist_literal_with_firm_id, _with_extra_key, test_rejects_forbidden_keys_literal_missing_firm_id (+ controls)
baggage propagator banned without strippertest_rejects_baggage_propagator_without_stripper (+ stripper / clean controls)
ban MeterProvider / set_meter_provider outside helper; keep get_metertest_rejects_meter_provider_construction_outside_helper, test_rejects_set_meter_provider_outside_helper, test_allows_get_meter_in_helper
existing tracer/provider gate intacttest_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.