--- phase: 02-acceptance-criteria-test-taxonomy-observability-spine plan: "07" subsystem: observability tags: [pydantic, boundary-log, structlog, unit-test, regression-gate, ac-traceability] dependency_graph: requires: [02-06-structlog-spine, 02-05-ci-pipeline, 02-04-ac-traceability] provides: [obs-boundary-schemas, ac-coverage-AC-1.4, ac-coverage-AC-4.3, ac-coverage-AC-2.1b, ac-coverage-AC-6.3, phase2-final-gate] affects: [Phase 3 AnchorVerifier wiring, Phase 5 MAVLink bridge wiring, Phase 6 REST middleware] tech_stack: added: [pydantic-v2-boundary-log-schemas] patterns: [Literal-field-validation, ConfigDict-extra-forbid-frozen, model_dump-mode-json] key_files: created: - src/gps_denied/obs/log_schemas.py - tests/test_log_schemas.py modified: - src/gps_denied/obs/__init__.py - .planning/AC-TRACEABILITY.md decisions: - "Boundary schemas use Literal not Enum — JSON-native, same validation speed, simpler migration path" - "capture_logs() skips processor chain by design; frame_id propagation verified via merge_contextvars directly" - "AC-2.1b remains DEFERRED (pending-phase-4) despite gaining 6 new test nodeids — schema contract tests do not satisfy the full AnchorVerifier integration requirement" metrics: duration: ~15 minutes completed: "2026-05-11" tasks_completed: 4 files_created: 2 files_modified: 2 --- # Phase 2 Plan 07: Pydantic Boundary-Log Schemas + Final Regression Gate — Summary **One-liner:** Pydantic v2 boundary-log schemas (MavlinkGpsInputEmitted, ApiRequestCompleted, AnchorDecision) with frozen+extra=forbid contracts, 20 unit tests, and Phase 2 final regression gate: 236 tests pass, AC --check exit 0. ## What Was Built ### 1. log_schemas.py — 3 Pydantic v2 Boundary Models **File:** `src/gps_denied/obs/log_schemas.py` | Model | Fields | AC Link | Phase Wiring | |-------|--------|---------|--------------| | `MavlinkGpsInputEmitted` | lat_deg, lon_deg, alt_m, fix_type (ge=0 le=6), horiz_accuracy_m, source_label (Literal[3 values]), anchor_age_ms, cov_semi_major_m | AC-4.3, AC-1.4, AC-1.3 | Phase 5 / MAVOUT-01 | | `ApiRequestCompleted` | path, method (Literal[5 values]), status_code (ge=100 lt=600), duration_ms | AC-6.3 | Phase 6 REST middleware | | `AnchorDecision` | decision (accept/reject), reason (AnchorRejectReason Literal[5]), n_inliers, mre_px | VERIFY-02 | Phase 3 AnchorVerifier | All models extend `_BoundaryLogRecord(BaseModel)` with `ConfigDict(extra="forbid", frozen=True)`: - `extra="forbid"`: producer-side field drift fails validation fast - `frozen=True`: records are immutable facts, not state Type aliases exported: `SourceLabel`, `AnchorRejectReason` for producer code reuse. ### 2. tests/test_log_schemas.py — 20 Unit Tests, 17 AC-Tagged | Test Group | Count | AC Tags | |------------|-------|---------| | `MavlinkGpsInputEmitted` round-trip + source_label 3 parametrize + rejects_unknown + fix_type 3 parametrize + extra_rejected + frozen | 10 | AC-4.3, AC-1.4 | | `ApiRequestCompleted` round-trip + unknown_method + status_bounds (2 assertions) | 3 | AC-6.3 | | `AnchorDecision` accept round-trip + 5 vocab parametrize + rejects_unknown_reason | 7 | AC-2.1b | Module-level: `pytestmark = [pytest.mark.unit]` Per-test: `@pytest.mark.ac("AC-N.n")` on relevant tests. All 20 tests pass in 0.04 seconds (no I/O, pure Pydantic validation). ### 3. AC-TRACEABILITY.md Delta | AC | Before (tests) | After (tests) | Status | |----|---------------|---------------|--------| | AC-1.4 | 2 | 7 | OK | | AC-4.3 | 20 | 26 | OK | | AC-2.1b | 0 | 6 | DEFERRED (pending-phase-4)* | | AC-6.3 | 6 | 7 | OK | | **ACs covered total** | **14** | **15** | — | *AC-2.1b: schema contract tests added, but full AnchorVerifier integration requirement remains deferred to Phase 4. ### 4. End-to-End Frame-ID + Boundary-Record Smoke `structlog.contextvars.merge_contextvars` (first processor in the configured chain) injects bound contextvars into every log record before the renderer fires. Verified directly: ``` merge_contextvars propagation OK: {'event': 'test', 'correlation_id': 999, 'source_label': 'satellite_anchored', 'fix_type': 3} E2E spine + boundary-record smoke OK: {'event': 'mavlink_emit_gps_input', 'correlation_id': 999, 'source_label': 'satellite_anchored', 'fix_type': 3} ``` Note: `structlog.testing.capture_logs()` intentionally replaces the full processor chain with a minimal capture, so `merge_contextvars` does not run inside `capture_logs()` blocks. This is expected structlog behavior. The plan's Task 4 inline script was adapted to verify `merge_contextvars` directly rather than through `capture_logs()`. ## Phase 2 Definition of Done — verified by Plan 02-07 - [x] All 7 PLAN.md files have SUMMARY.md siblings (this one being the last) - [x] pytest tests/ -q --ignore=tests/e2e passes at >= 216 (+ test_log_schemas.py count): **236 passed, 8 skipped** - [x] pytest -m unit / -m integration / -m blackbox each pass with zero failures (210 / 69 / 12) - [x] scripts/gen_ac_traceability.py --check exit 0 - [x] git diff --exit-code .planning/AC-TRACEABILITY.md exit 0 (matrix committed clean) - [x] from gps_denied.obs.logging_config import configure_logging works - [x] from gps_denied.obs.log_schemas import MavlinkGpsInputEmitted, ApiRequestCompleted, AnchorDecision works - [x] correlation_id=frame_id propagates from orchestrator binding via merge_contextvars to hot-path log calls - [x] git diff --name-only tests/ shows only pytestmark + @pytest.mark.ac additions + new test_log_schemas.py (no logic edits) - [ ] ROADMAP.md Phase 2 row updated to "Plans Complete: 7/7" + "Status: Done" — **REQUESTED FOR HUMAN REVIEWER after merge** ## ROADMAP.md Update Request After merging Phase 2 branch into main: 1. In `ROADMAP.md`, update Phase 2 row: Plans Complete → `7/7`, Status → `Done` 2. Optionally update the AC-2.1b row from "DEFERRED (pending-phase-4)" to add a note that schema contract tests now exist (6 nodeids), but the integration requirement is still pending Phase 4. ## Deviations from Plan ### Auto-adapted: capture_logs() assertion **Found during:** Task 4 final regression gate **Issue:** The plan's Task 4 inline script asserts `e["correlation_id"] == 999` inside a `capture_logs()` block. `structlog.testing.capture_logs()` replaces all processors with a bare capture — `merge_contextvars` does NOT run, so context vars are absent from captured events. This is documented structlog behavior, not a bug. **Fix:** Verified `merge_contextvars` propagation directly by calling the processor function against an event dict with bound context. The production chain (Plan 02-06's `configure_logging`) wires `merge_contextvars` as the first processor — the invariant holds in production. **Impact:** No code change needed; assertion strategy adapted in smoke test verification. ## Self-Check Files created: - [x] `src/gps_denied/obs/log_schemas.py` — exists - [x] `tests/test_log_schemas.py` — exists, 20 tests pass Commits: - [x] `94c1b76` — feat(02-07): add Pydantic v2 boundary-log schemas (OBS-01) - [x] `e87fb37` — test(02-07): add unit tests for boundary-log schemas (AC-02, OBS-01) - [x] `14717c5` — chore(02-07): regenerate AC-TRACEABILITY.md with test_log_schemas nodeids Gate results: - [x] 236 total tests pass (baseline 216 + 20 new) - [x] -m unit: 210 passed; -m integration: 69 passed; -m blackbox: 12 passed - [x] AC --check exit 0; matrix diff clean ## Self-Check: PASSED