[AZ-307] c6 FreshnessGate: active-conflict reject + stable-rear downgrade

Replaces the AZ-305 pass-through _evaluate_freshness hook with the
production FreshnessGate. Loads tile_freshness_rules + sector
classifications once at construction, builds an rtree index, and on
every evaluate() either returns metadata unchanged (FRESH), stamps
freshness_label=DOWNGRADED (stable_rear + stale), or raises
FreshnessRejectionError carrying tile_id / age_seconds /
classification / rule diagnostics (active_conflict + stale).

Constructed inside PostgresFilesystemStore.from_config; the public
storage_factory signature is preserved so AZ-305 unit tests still
build the store with freshness_gate=None for the pass-through path.

FDR schema bumped to v1.2.0: adds c6.freshness.rejected and
c6.freshness.downgraded kinds (non-breaking; v1.1 readers route them
opaquely). Operator CLI `python -m c6_tile_cache.freshness_gate
explain` dry-runs the decision for a (lat, lon, capture_ts).

Adjacent hygiene: c6_tile_cache.tools._dump_tile now passes
os.environ to load_config (AZ-305 regression — load_config requires
the env mapping).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-12 19:29:11 +03:00
parent d1c1cd9ab4
commit 39ff47087f
12 changed files with 1622 additions and 17 deletions
@@ -127,6 +127,24 @@ KNOWN_PAYLOAD_KEYS: Final[dict[str, frozenset[str]]] = {
# `message` is the rewrapped exception's str (truncated to keep the
# record inline).
"c6.write_failed": frozenset({"tile_id", "source", "reason", "error_class", "message"}),
# AZ-307 / E-C6: emitted by the FreshnessGate on every reject decision
# (active_conflict sector + tile_age > rule.max_age_seconds).
# `tile_id` is the canonical UUIDv5; `age_seconds` is the integer-rounded
# `(now - capture_timestamp).total_seconds()`; `classification` is the
# `SectorClassification` enum value that drove the decision;
# `rule_action` ("reject") and `rule_max_age_seconds` document which
# rule fired so FDR consumers reproduce the decision without joining
# back to `tile_freshness_rules`.
"c6.freshness.rejected": frozenset(
{"tile_id", "age_seconds", "classification", "rule_action", "rule_max_age_seconds"}
),
# AZ-307 / E-C6: emitted on every downgrade decision (stable_rear sector
# — explicit or implicit-default — + tile_age > rule.max_age_seconds).
# Same payload shape as `c6.freshness.rejected` so reject/downgrade
# traces are line-for-line comparable. `rule_action` is "downgrade".
"c6.freshness.downgraded": frozenset(
{"tile_id", "age_seconds", "classification", "rule_action", "rule_max_age_seconds"}
),
}
KNOWN_KINDS: Final[frozenset[str]] = frozenset(KNOWN_PAYLOAD_KEYS.keys())