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>
18 KiB
C6 Freshness Gate — Active-Conflict Reject + Stable-Rear Downgrade
Task: AZ-307_c6_freshness_gate
Name: C6 Freshness Gate
Description: Implement the freshness gate that runs at every write_tile and insert_metadata call site: looks up the target (lat, lon)'s sector classification from sector_boundaries, reads the per-classification rule from tile_freshness_rules (max_age_seconds, action), and either raises FreshnessRejectionError (active_conflict + stale → reject) or stamps freshness_label = DOWNGRADED (stable_rear + stale → downgrade) before the row lands. Replaces the pass-through _evaluate_freshness hook the PostgresFilesystemStore ships in AZ-305. Reads the rules table once at construction (rules are per-flight; the flight is the lifetime). Caches sector boundaries in an in-memory R-tree (operator sets ≤ a few hundred per flight). Emits an FDR record on every rejection and every downgrade.
Complexity: 2 points
Dependencies: AZ-303_c6_storage_interfaces, AZ-304_c6_postgres_schema, AZ-305_c6_postgres_filesystem_store, AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-273_fdr_client_ringbuf
Component: c6_tile_cache (epic AZ-250 / E-C6)
Tracker: AZ-307
Epic: AZ-250 (E-C6)
Document Dependencies
_docs/02_document/contracts/c6_tile_cache/tile_metadata_store.md— Invariants I-2 (active_conflict reject) and I-3 (stable_rear downgrade) are the canonical statement of this task's behaviour._docs/02_document/contracts/c6_tile_cache/tile_store.md— definesFreshnessRejectionErrorandFreshnessLabel._docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md—kind="c6.freshness.rejected"/kind="c6.freshness.downgraded"envelopes._docs/02_document/contracts/shared_logging/log_record_schema.md— INFO log shape on rule load + WARN log shape on rejection / downgrade.
Problem
Without a real freshness gate:
- AC-8.2 (active_conflict ≤ 6 mo, stable_rear ≤ 12 mo) is unenforced — stale tiles in active sectors land silently and downstream consumers cannot tell them apart.
- AC-NEW-6 (system rejects/downgrades stale tiles) collapses — the test C6-IT-02 / C6-IT-05 cannot pass.
- The pass-through hook in
PostgresFilesystemStore(AZ-305) accepts every freshness label as-is — an attacker could feedfreshness_label=FRESHfor a 10-year-old tile and bypass the safety budget. - The cache-poisoning safety budget (AC-NEW-7) loses one of its layers — the rule-evaluation point is a defence boundary, not a label-trust point.
- Operator sector classifications (set via C12) have no read consumer at the C6 layer — the classifications would be data-only, never policy.
This task wires the rule-evaluation logic into the AZ-305 store's _evaluate_freshness hook.
Outcome
- A
FreshnessGateclass atsrc/gps_denied_onboard/components/c6_tile_cache/freshness_gate.pywith a single public methodevaluate(metadata: TileMetadata) -> TileMetadatathat returns either:- The same
metadataif FRESH applies (no policy intervention). - A new
metadatawithfreshness_label=DOWNGRADEDif stable_rear-stale. - Raises
FreshnessRejectionErrorif active_conflict-stale.
- The same
- Constructor signature:
__init__(self, *, postgres_pool: psycopg_pool.ConnectionPool, fdr_client: FdrClient, logger: Logger, clock: Clock). Theclockinjection lets tests advance time deterministically. - At construction:
- Reads
sector_boundariesonce, builds an in-memory R-tree (usingrtreelibrary — already pinned by description.md or added here if not; check requirements file). - Reads
tile_freshness_rulesonce, caches the two rules in a frozen dict{SectorClassification: FreshnessRule}. - Emits an INFO log:
kind="c6.freshness.loaded"withn_sectors,rules.
- Reads
evaluate(metadata):- Computes
tile_age_seconds = now - metadata.capture_timestampvia the injectedclock. - Queries the R-tree for the sector containing
(metadata.tile_id.lat, metadata.tile_id.lon). If multiple sectors match (overlap), the smallest by area wins (deterministic tie-break). - If no sector matches → treats as
STABLE_REARdefault (per data_model.md convention; documented as the implicit default). - Looks up the rule for that classification.
- If
tile_age_seconds <= rule.max_age_seconds→ returnsmetadataunchanged (FRESH). - Else if
rule.action == 'reject'→ emits FDRkind="c6.freshness.rejected"and WARN log; raisesFreshnessRejectionError(tile_id, age_seconds, classification, rule). - Else if
rule.action == 'downgrade'→ emits FDRkind="c6.freshness.downgraded"and INFO log; returnsdataclasses.replace(metadata, freshness_label=FreshnessLabel.DOWNGRADED).
- Computes
- The
PostgresFilesystemStore's_evaluate_freshnesshook is replaced — instead ofreturn metadata.freshness_label, it now callsfreshness_gate.evaluate(metadata).freshness_label. This is a wiring change in AZ-305's class — implemented as a small constructor argument addition (freshness_gate: Optional[FreshnessGate] = None) so AZ-305 remains testable in isolation. - The composition root constructs
FreshnessGateand passes it toPostgresFilesystemStoreAFTER the migration runner (AZ-304) has populated the rules table.
Scope
Included
FreshnessGateclass withevaluate(metadata)method.- Construction-time R-tree build over
sector_boundaries. - Construction-time rules-table cache.
- FDR emission on every rejection and every downgrade.
- WARN log on rejection (per
tile_store.md§ log table); INFO log on downgrade (downgrade is recoverable, not an error). - The smallest-area tie-break for overlapping sector boundaries (deterministic, documented).
- The implicit STABLE_REAR default for
(lat, lon)outside any sector. - A constructor
Optional[FreshnessGate]arg onPostgresFilesystemStoreso AZ-305 stays unit-testable without this gate. - Composition-root wiring (the factory
build_tile_storebecomesbuild_tile_store(config, freshness_gate)). - A standalone CLI
python -m c6_tile_cache.freshness_gate explain <lat> <lon> <capture_iso>for operators to dry-run the gate.
Excluded
- Sector-boundary CRUD — owned by C12 operator tooling.
- Tile-freshness-rule CRUD beyond the migration's seeded defaults — operators can edit at the DB level today; a future task adds an admin API.
- Rule reload mid-flight — out of scope this cycle. The flight is the lifetime; rules change requires a process restart.
- Cross-sector pose-error voting (the parent-suite D-PROJ-2 voting layer) — that lives in
satellite-provider. - Time-of-day or seasonal freshness adjustments — not in description.md, out of scope.
- Per-tile freshness override (operator manually marks one tile fresh) — out of scope; operator workaround is to delete + re-insert with a fresh capture_timestamp.
Acceptance Criteria
AC-1: Active-conflict stale tile is rejected
Given a sector classified ACTIVE_CONFLICT with the default 6-month rule, and a tile inside it with capture_timestamp = now - 7 months
When evaluate(metadata) is called
Then FreshnessRejectionError is raised with a message naming the tile_id, the age, and the rule; ONE FDR kind="c6.freshness.rejected" record is emitted; ONE WARN log is emitted
AC-2: Active-conflict fresh tile passes
Given the same sector and a tile with capture_timestamp = now - 5 months
When evaluate(metadata) is called
Then the call returns metadata unchanged; no FDR record is emitted; no WARN log is emitted
AC-3: Stable-rear stale tile is downgraded
Given a sector classified STABLE_REAR with the default 12-month rule, and a tile inside it with capture_timestamp = now - 13 months
When evaluate(metadata) is called
Then the returned TileMetadata has freshness_label = FreshnessLabel.DOWNGRADED; the rest of the metadata is unchanged; ONE FDR kind="c6.freshness.downgraded" record is emitted; ONE INFO log is emitted
AC-4: Stable-rear fresh tile passes
Given the same sector and a tile with capture_timestamp = now - 10 months
When evaluate(metadata) is called
Then the call returns metadata unchanged; no FDR; no log
AC-5: Tile outside all sectors defaults to STABLE_REAR
Given a tile at (lat, lon) not contained in any sector_boundaries row
When evaluate(metadata) is called with a 13-month-old capture_timestamp
Then the result is freshness_label = DOWNGRADED (the implicit STABLE_REAR default applies); FDR kind="c6.freshness.downgraded" is emitted
AC-6: Overlapping sectors resolve by smallest area
Given two sector_boundaries rows: a 1°×1° ACTIVE_CONFLICT box and a 0.1°×0.1° STABLE_REAR box, with the smaller box fully inside the larger
When evaluate(metadata) is called for a tile inside the smaller (and thus also the larger) box
Then the STABLE_REAR rule applies (smallest area wins); a 13-month-old tile is downgraded, NOT rejected
AC-7: Rules and sectors are loaded once at construction
Given a FreshnessGate instance
When 10000 evaluate calls are made
Then no sector_boundaries or tile_freshness_rules SELECT is observed (verifiable via psycopg query log capture); only the construction-time SELECT pair is observed
AC-8: FreshnessRejectionError carries diagnostic fields
Given an active_conflict rejection
When the test inspects the raised exception
Then exc.tile_id, exc.age_seconds, exc.classification, exc.rule are populated; the exception message starts with "Tile rejected by freshness gate"
AC-9: FDR record envelopes match contract
Given a rejection or downgrade
When the FDR record is captured
Then the record matches _docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md shape with the documented kind, producer_id="c6_tile_cache.freshness", payload {tile_id, age_seconds, classification, rule_action, rule_max_age_seconds}
AC-10: Composition wiring change works end-to-end
Given a PostgresFilesystemStore constructed WITH a FreshnessGate argument
When write_tile is called with a stale active_conflict tile
Then FreshnessRejectionError is raised; no JPEG / row / sidecar is written (verifiable via filesystem + DB inspection); the rejection FDR is emitted via the same FdrClient AZ-305 already holds
Non-Functional Requirements
Performance
evaluatep99 ≤ 100 µs (R-tree point-in-rect lookup is sub-microsecond; the hot bottleneck is thenow - capture_timestamparithmetic and the FDR emission, both fast).- Construction takes ≤ 50 ms even for a few-hundred-sector flight (R-tree build is O(N log N) on a small N).
Compatibility
rtreePython library — verify the project pin already includes it; if not, this task adds it (compatible with the project's existing geospatial stack).dataclasses.replaceis stdlib.
Reliability
- Construction failure is fail-fast: a malformed
tile_freshness_rulesrow (e.g., unknownactionenum value) raises aConfigSchemaErrorextension at construction; the composition root catches and aborts startup with a clear operator message. - The gate is idempotent — calling
evaluateon the samemetadatatwice returns deep-equal results (no hidden state changes). - The injected
ClockMUST be the same singleton used by AZ-305'srecord_lru_accessand AZ-302's thermal publisher (already a project-wide singleton).
Unit Tests
| AC Ref | What to Test | Required Outcome |
|---|---|---|
| AC-1 | Active-conflict + 7-month tile | FreshnessRejectionError; one FDR; one WARN log |
| AC-2 | Active-conflict + 5-month tile | Returns unchanged; no FDR; no WARN |
| AC-3 | Stable-rear + 13-month tile | Returns with freshness_label=DOWNGRADED; one FDR; one INFO |
| AC-4 | Stable-rear + 10-month tile | Returns unchanged; no FDR; no log |
| AC-5 | Tile outside all sectors + 13-month | Defaults to STABLE_REAR; downgraded |
| AC-6 | Overlapping sectors (smaller STABLE_REAR inside larger ACTIVE_CONFLICT) | Smaller wins; downgrade, not reject |
| AC-7 | 10k evaluate calls + query-log capture | Only construction-time SELECTs observed |
| AC-8 | Inspect raised FreshnessRejectionError fields | tile_id, age_seconds, classification, rule populated |
| AC-9 | FDR record shape on reject and downgrade | Matches schema deep-equal |
| AC-10 | E2E PostgresFilesystemStore + FreshnessGate write | FreshnessRejectionError; no fs/db effects |
| NFR-perf-evaluate | Microbench evaluate × 100k | p99 ≤ 100 µs |
| NFR-reliability-malformed-rule | Inject tile_freshness_rules row with action='unknown' |
ConfigSchemaError at construction |
Constraints
- The R-tree is built ONCE at construction; mid-flight sector boundary changes are NOT honoured (process restart required).
- The implicit STABLE_REAR default for tiles outside all sectors is documented and is the safer default (downgrade, not reject — operator may add an explicit
whole_worldACTIVE_CONFLICT sector if they want fail-closed behaviour). - Tie-break for overlapping sectors is "smallest area wins" — deterministic and documented; bbox area is computed via
(max_lat - min_lat) * (max_lon - min_lon)(degrees² — adequate for ranking, not for actual area). - The gate raises
FreshnessRejectionError(defined in AZ-303); this task does NOT define new error types. - The gate's
evaluatemethod MUST be idempotent and side-effect-free except for FDR + log emissions; future code-review treats internal state mutation as aReliabilityfinding (High). Clockinjection is mandatory — notime.time()direct calls; tests assert deterministic output by advancing the fake clock.- This task does NOT introduce new third-party dependencies beyond
rtree(verify in requirements).
Risks & Mitigation
Risk 1: R-tree library API drift across pins
- Risk:
rtreeminor version bump changes API; constructor calls fail at runtime. - Mitigation: Pin recorded in requirements; the wrapper isolates
rtreeto this single class; future breaks fail-fast at construction.
Risk 2: Sector-boundary update mid-flight is silently ignored
- Risk: Operator updates sector_boundaries via SQL during a flight; the gate's R-tree is stale; new tile classifications use old boundaries.
- Mitigation: Documented constraint — process restart required for boundary changes. Operator workflow: pre-flight sector setup is C12's responsibility; in-flight boundary changes are not in scope.
Risk 3: STABLE_REAR-default for tiles outside all sectors is too lenient
- Risk: A tile from an unmapped area lands as DOWNGRADED rather than rejected, leaking past the safety budget.
- Mitigation: Documented as the safer default (operator adds explicit ACTIVE_CONFLICT whole_world sector for fail-closed). FDR
kind="c6.freshness.downgraded"carries the classification, so the FDR-trace shows operators which tiles fell through. A future task could add aconfig.tile_cache.freshness_gate.no_sector_defaultconfig field — out of scope this cycle.
Risk 4: Smallest-area tie-break interacts badly with adversarial sector layouts
- Risk: An operator (or attacker) inserts a tiny STABLE_REAR sector inside a large ACTIVE_CONFLICT box to bypass rejections.
- Mitigation: Sector boundary CRUD is C12-only and operator-authenticated (per architecture's threat model). The smallest-area rule is documented; if abused, the operator audit log (set_by_operator + set_at columns in
sector_boundaries) surfaces the change.
Risk 5: Clock-injection mistake — fake clock used in production
- Risk: Composition root accidentally wires
FakeClockinstead ofWallClockto the gate; freshness ages are computed against a fixed time; everything looks fresh forever. - Mitigation: AZ-265's
Clockinterface owns the WallClock vs. fake choice via the same composition-root selection that owns thermal-state polling. The factory's per-binary CMakeBUILD_*flags already separate live (WallClock) from replay (TlogDerivedClock); test wiring is the only place fakes appear. Code review's wiring check (Phase 6 / Architecture) is the canonical guard.
Runtime Completeness
- Named capability: per-sector freshness gate enforcing AC-8.2 / AC-NEW-6 (description.md / E-C6 / data_model.md).
- Production code that must exist: real
FreshnessGateclass with R-tree-backed sector lookup, realtile_freshness_rulesquery at construction, realdataclasses.replacefor the downgrade label, real FDR emission on every reject and downgrade, real WARN/INFO logs. - Allowed external stubs: tests MAY use a fake
Clock, fakeFdrClient, fakeLogger, and an in-memory psycopg fake (testcontainer is also fine — both are equivalent under AZ-304's schema fixture); production wiring uses real WallClock + real AZ-273FdrClient+ real AZ-266Logger+ real Postgres pool. - Unacceptable substitutes: a hardcoded "everything is fresh" pass-through (defeats the entire point); a Python in-memory boundary list ignoring
sector_boundaries(would diverge from the operator's source of truth in C12);time.time()direct calls without Clock injection (would break test determinism); skipping the R-tree and doing a linear scan over sectors (works at small N but invites future regression at larger N — R-tree is pre-emptively the right shape per coderule.mdc's "the simplest solution that satisfies all requirements, including maintainability").
Contract
This task implements behaviour mandated by _docs/02_document/contracts/c6_tile_cache/tile_metadata_store.md § Invariants I-2 + I-3. No new contract file — the gate is a policy implementation behind an existing Protocol surface (PostgresFilesystemStore.write_tile / insert_metadata already raise FreshnessRejectionError per the contract; this task supplies the rule-evaluation logic).