Skip to content

Genoa audit remediation phase2#80

Open
chelstein wants to merge 734 commits into
edwardoughton:masterfrom
chelstein:genoa-audit-remediation-phase2
Open

Genoa audit remediation phase2#80
chelstein wants to merge 734 commits into
edwardoughton:masterfrom
chelstein:genoa-audit-remediation-phase2

Conversation

@chelstein

Copy link
Copy Markdown

No description provided.

claude and others added 30 commits May 15, 2026 23:29
Closes the §73.182 nighttime allocation loop end-to-end on top of
the FCCAM sidecar shipped in #155.

NEW MODULES
  - src/engine/am/band.js          — band/grid constants + validators
    shared between the DA designer, FCCAM client, and the orchestrator.
    isValidAmKhz / normalizeAmKhz / describeAmKhz / wavelengthMeters.
  - src/engine/am/skywave.js       — per-bearing field via FCCAM.
    Pattern-factor lookup with linear interpolation between sparse
    samples; great-circle distance + bearing (§73.208 convention);
    buildBatchInputs + applyPatternFactor + skywaveFieldAtReceivers.
    No local fallback — when FCCAM is unreachable callers get
    available:false; the determinism argument depends on FCC FORTRAN
    being the sole authority.
  - src/engine/am/nightInterference.js — §73.182(k) RSS aggregation
    with the 25% exclusion rule (boundary inclusive, matching FCC AM
    Query); requiredDesiredField + checkProtection (D/U margin);
    standardDuDb() with the §73.183 matrix for A/B/C/D × co / 1st /
    2nd / 3rd adjacent.
  - src/engine/am/nifContour.js    — per-azimuth bisection NIF solver.
    Per-relation RSS evaluation (co-channel, 1st/2nd/3rd adjacent are
    independent pools per §73.182), saturation flags (no_service +
    unbounded), closed-polygon output suitable for GeoJSON overlay.

UI
  - AmDaDesigner.jsx now uses describeAmKhz() to validate the carrier
    input with a red border + inline error when off-band/off-grid,
    plus auto-snap to nearest 10-kHz grid value on blur for near-band
    typos.  Closes the loop that previously let users build a pattern
    at 89 kHz that no downstream engine would evaluate.

TESTS — 59 new cases, 738/738 pass + 3 skipped (integration)
  - amBand: 10 cases (constants, validity, normalization, describe).
  - amSkywave: 16 cases (pattern interpolation incl. wrap-around,
    great-circle round-trips, batch input shape, omni vs DA
    composition, end-to-end orchestrator with a fake FCCAM).
  - amNightInterference: 16 cases (single/multi RSS, exact-25%
    boundary inclusion, zero/NaN/negative field handling, custom
    exclusion fraction, D/U lookup matrix coverage A/B/C/D ×
    co/1st/2nd/3rd, margin_db sign + Class A 26 dB factor).
  - amNifContour: 12 cases (field composition under DA, evaluate
    receiver pass/fail, bisection toward/away from interferer,
    closed polygon, NIF radius geometry: smaller toward interferer
    than away).

OUT OF SCOPE FOR PHASE 2
  - LMS query expansion to pull every co/adjacent AM within ~750 mi
    (Phase 3 — exhibit wire-up).
  - DA-N pattern fetch from LMS (Phase 3).
  - Exhibit appendix sections + auto-narrative (Phase 3).
  - DA-N pattern *design* / optimizer (Phase 4).
  - Pre-sunrise / post-sunset authority + expanded-band §73.30 D/U
    table (deferred).

OPERATOR PREREQUISITE
  The orchestrator + tests use a fake FCCAM client.  Live computation
  needs FCCAM_SIDECAR_URL set on the App Platform deploy, which in
  turn needs the operator to (1) drop Fccam.for into
  genoa/src/sidecars/fccam/, (2) docker build + run on the droplet,
  and (3) populate the golden-suite manifest at
  data/golden/am_skywave_reference_suite.json.  Tracked in #155's
  operator section.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
Wires the §73.182 NIF solver from Phase 2 to the rest of Genoa:

  - src/engine/am/nightOrchestrator.js
      Pulls nearby AMs from the facility client (LMS / FMQ), normalizes
      their LMS row shape into the solver's interferer shape (mapping
      facilityClient's `cochannel` → engine's canonical `co_channel`
      via normalizeRelation), caps the interferer count (default 25,
      sorted by distance — strongest typically closest), runs the
      per-azimuth NIF bisection, and emits summary statistics
      (mean/min/max radius, worst margin_dB, n_failing_azimuths)
      for the appendix.

  - src/engine/am/nightInterference.js
      Adds normalizeRelation() — a tiny mapper that accepts every
      relation-string shape Genoa sees in the wild ('cochannel' from
      facilityClient, 'co_channel' from the engine, '1st_adjacent',
      'first-adjacent', etc.).  standardDuDb() routes through it so
      the §73.183 D/U lookup works regardless of which upstream
      labeled the relation.

  - src/api/routes/amNight.js
      POST /api/am-night/nif — thin wrapper over the orchestrator,
      mounted alongside /api/am-da/{design,null}.  Pulls FCCAM and
      facilityClient from the existing sidecars registry; returns
      200 with { available: false, error } when either is missing,
      not 5xx, so the DA designer can render the diagnostic
      verbatim in the live overlay.

TESTS — 11 new cases; 749/752 pass + 3 skipped (FCCAM integration)
  - amNightOrchestrator (11): normalizePrimary (relation aliasing,
    out-of-band freq, missing geometry, non-positive erp), study
    guards (no FCCAM, missing fcc_class, out-of-band proposed,
    facility-client unavailable), end-to-end happy path with closed
    polygon + interferers array, max_interferers cap behavior +
    cap_applied flag, pattern_mode=omni ignores any pattern_table.

OUT OF SCOPE (next branch)
  - Exhibit-service integration — attach the NIF study to
    evidence on AM exhibits.
  - DA designer live-preview overlay — POST /api/am-night/nif
    on every nudge with debounce.
  - Auto-narrative for the §73.182 appendix section.
  - DA-N pattern fetch from LMS (only DA-D is currently surfaced;
    the orchestrator already accepts pattern_table on interferers
    but the LMS layer doesn't yet populate it).

OPERATOR PREREQUISITE
  Live computation still requires FCCAM bring-up per #155.  Until
  then, the route is wired and returns the diagnostic; no surprise
  runtime errors.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
…-nif

feat(am-night): Phase 2 + 3 — skywave, §73.182(k) RSS, NIF solver + orchestrator + /api/am-night/nif
Wires the NIF orchestrator (Phase 3) into exhibitService for AM
exhibits, surfaces the result as Appendix F in the engineering
report PDF, and adds a validation-verdict row that flips PASS/FAIL
based on the §73.182 outcome.

EXHIBIT SERVICE (step 8d)
  When inputs.service === 'AM' and the FCCAM sidecar is present,
  call nighttimeNifStudy() with a 30-second budget allowance.
  Attaches evidence.am_night_nif with the full study payload, or
  a fail-soft { available: false, error } when budget exhausts /
  fcc_class is missing / the sidecar refuses.  Plumbed through to
  exhibit.evidence in step 9 alongside fcc_curve_parity.

ENGINEERING REPORT
  - Appendix F renders three blocks: a summary KV (n_azimuths,
    n_failing_azimuths, NIF radii, worst margin, D/U applied per
    relation), a per-azimuth NIF table (radius + lat/lon + binding
    relation + iteration count), and an interferer table (sorted
    by distance, with class / freq / ERP / relation).
  - Appendix F is OMITTED entirely on FM exhibits — only renders
    when service === 'AM' AND evidence.am_night_nif exists.  When
    the study returned available:false a single "NOT RUN" block
    is emitted with the diagnostic reason verbatim.
  - Validation-verdict gains a §73.182 NIF component on AM exhibits
    only (PASS when 0 failing + 0 no-service azimuths, FAIL
    otherwise, NOT_RUN when FCCAM is unconfigured).

TESTS — 11 new cases; 760/763 pass + 3 skipped FCCAM integration
  - amNightAppendix (5): Appendix F omitted on FM, omitted when
    AM has no NIF evidence, NOT RUN block when available:false,
    full summary + per-azimuth + interferer tables when available,
    sub-tables omitted when arrays empty.
  - amNightValidationVerdict (6): FM ignored, NOT_RUN with FCCAM-
    not-configured detail, PASS on all-clear, FAIL on failing
    azimuths, NOT_RUN with reason when study returned available:false,
    FAIL on all-no-service (pattern too weak everywhere).

OUT OF SCOPE (still)
  - DA designer live-preview overlay (debounced POST to /api/am-night/nif
    on every tower nudge).
  - DA-N pattern fetch from LMS — orchestrator accepts pattern_table on
    interferers but the LMS / AMQ layer doesn't populate it yet.
  - Pre-sunrise / post-sunset authority + expanded-band §73.30 D/U
    table (deferred).

OPERATOR PREREQUISITE
  Live computation needs FCCAM bring-up per #155 + droplet operator
  steps.  Until then exhibits get { available: false } in Appendix F
  with the diagnostic surfaced verbatim — no surprise runtime errors.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
…-appendix

feat(am-night): Phase 4 — exhibit-service wire-up + §73.182 Appendix F
Adds <AmNightNifPreview/> — a debounced live-preview overlay that
calls POST /api/am-night/nif on every tower nudge in the DA
designer.  As the engineer retunes T1/T2/T3, the §73.182
verdict ("PROTECTED" / "PROTECTION FAILS"), NIF radius range,
worst binding margin, and a top-3 failing-azimuth table update
in place.  This is the V-Soft AM-Pro feature no desktop tool
matches — you can SEE which retuning move clears a binding
co-channel before you commit the pattern to the facility.

WIRING
  AmDaDesigner.jsx mounts the preview below the pattern card,
  passing baseInputs.{lat,lon,erp_kw,fcc_class} + the current
  synthesizer output's pattern_table.  When pattern_mode='omni'
  (no DA yet) the preview computes against omni geometry; when
  the synthesizer returns a DA pattern_table it's passed through
  unchanged.

PATTERN-SHAPE COMPAT
  /api/am-da/design returns pattern_table as an Array of
  [az, factor] pairs (the §73.150 synthesizer's natural output);
  the NIF orchestrator's patternFactorAt() was object-keyed.
  Made the lookup shape-agnostic — accepts both array-of-pairs
  AND object-keyed — so the DA designer's output drops in without
  conversion at the boundary.

FAIL-SOFT
  - Missing geometry / class → inline hint, no fetch.
  - { available: false } from orchestrator → diagnostic shown verbatim
    (FCCAM unconfigured, no nearby AMs, etc.).
  - Network hiccup → error shown; next nudge retries.
  - In-flight request superseded by next nudge is aborted (no leak).
  - The preview NEVER mutates exhibit state — it's a read-only
    overlay; the same orchestrator runs at exhibit-compute time so
    "what you see is what you'll get" on re-compute.

TESTS — 2 new cases on top of the 19 Phase 2 amSkywave tests
  - patternFactorAt: accepts array-of-pairs shape from /api/am-da/design
  - patternFactorAt: array and object shapes produce identical results
    across 10 sampled azimuths (regression for the boundary mismatch
    that would have rendered every nudge as omni)

Full suite: 751/754 pass + 3 skipped (FCCAM integration suite),
0 fail.

OUT OF SCOPE (next branch)
  - Polygon overlay on the PolarPattern visualization (currently the
    preview is text-only; the GeoJSON contour is in the response
    payload but rendered as a separate map step).
  - Persisted preview history ("you had 7 dB margin two nudges ago").
  - DA-N pattern fetch from LMS (interferer rows still treated as
    omni until LMS layer surfaces filed DA-N patterns).

OPERATOR PREREQUISITE
  Live preview needs FCCAM bring-up per #155.  Until then the
  overlay renders the unconfigured diagnostic on every nudge.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
feat(am-night): live NIF preview in DA designer
The V-Soft Probe5 flagship feature, implemented over the §73.207
+ §73.215 engines Genoa already ships.

PROBLEM
  Given a proposed transmitter site (lat, lon, fcc_class, optional
  ERP/HAAT), enumerate every FM channel 200-300 (87.9-107.9 MHz)
  and report which ones are available — passing §73.207 minimum-
  distance separation, OR §73.215 contour-protection when §73.207
  fails (only run when ERP+HAAT supplied, matching the V-Soft
  contour-rescue convention).

WHAT THIS IS
  - src/engine/allotmentSearch.js
      Pure function: takes a subject + a pre-fetched nearbyStations
      list and returns per-channel results.  No external fetch in
      the engine itself, so it's testable in isolation AND the
      orchestrator can reuse already-pulled LMS rows.

      Each result row:
        { channel, frequency_mhz, band: 'commercial'|'reserved',
          pass_73207, pass_73215, available,
          binding: { cite, station, relation, distance_km,
                     required_km, deficit_km } | null,
          margin_km, n_violations_207, n_violations_215,
          scoring_rank }

      Deterministic ranking: available before blocked; among
      available, fewer §73.207 violations beats §73.215 rescue;
      ties broken by margin_km then channel.

  - src/api/routes/allotment.js
      POST /api/allotment/search — wires the engine to the
      facility client's getNearbyPrimaries (LMS/FMQ).  Pulls
      every full-service FM within radius_km (default 300 km) of
      the subject, hands them to the engine, returns the ranked
      results.  503 when the facility client isn't configured;
      502 when the LMS fetch fails.

  - Mounted alongside /api/am-{da,night}/ in api/server.js.

ALSO ADDS
  - fmChannelToMhz / fmMhzToChannel helpers (FM grid is
    ch200=87.9 MHz, step 0.2; ch200-220 is the reserved NCE band
    per §73.501).
  - Permissive §73.207 / §73.215 study-row accessors —
    actual_separation_km / required_separation_km / margin_km
    (§73.207 keys) or distance_km / required_km (§73.215-shape).
    Without these the binding-station extractor returned null
    even when the study had the data.

TESTS — 14 new cases, 776/779 pass + 3 skipped (FCCAM integration)
  - channel ↔ frequency round-trip
  - input guards (missing subject / lat,lon / class)
  - channel filters (subset, reserved_band=false strips 200-220)
  - no-incumbents → all 101 channels available + lowest-channel
    ranked first
  - co-channel A↔A blocker at 60 km → ch221 blocked, binding
    detail (station call, deficit, required separation) surfaced
  - §73.215 rescue runs when ERP+HAAT supplied; NOT run otherwise
  - ranking: available channels always rank before blocked

OUT OF SCOPE (next branches)
  - UI panel — POST from a "find available channels" workbench
    rack item; render the result table with bind-on-mouseover
    of the binding station on a map.
  - LPFM allotment (§73.807 — different distance table).
  - FX translator search (§74.1235 — different distance table).
  - Mexican / Canadian treaty restrictions (line-A / Section IV).

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
Adds <AllotmentSearchPanel/> + registers it as a workbench rack item
between "Find best config" and "AM DA designer".  Posts to the
allotment-search endpoint from this PR's earlier commit and renders
the ranked result table.

UX
  - Subject derived from the FacilityRack inputs (lat, lon, class,
    optional ERP/HAAT).  Auto-runs once when the panel opens if the
    subject is complete; explicit re-run button otherwise.
  - Filter chips: all / available / blocked.
  - Per-row: rank, channel, MHz, NCE band flag, §73.207 + §73.215
    pass pill (PASS / FAIL / — / err), worst margin_km (green when
    positive, red when negative), binding station + relation
    surfaced as the hover title.  A "Pick" button on available rows
    pushes the channel's frequency into the FacilityRack via the
    existing onApplyCombo plumbing so the operator can re-compute
    on the new allotment without re-typing.
  - Reserved-band toggle (ch200-220 NCE per §73.501).
  - Scan radius input (default 300 km, 50-1500 range).
  - Aborts in-flight searches on re-run.

INTENT — Screening, not filing
  Footnote in the UI surfaces the architectural intent: this is a
  V-Soft Probe5-style winnowing tool, not a fileable analysis.  The
  fileable side still runs through /api/exhibits/compute with the
  PE-cert + audited §73.207/§73.215 appendices.  Same engines, same
  numbers — no duplicate truth.

Full suite: 776/779 pass + 3 skipped (FCCAM integration), 0 fail.
No new test cases; the engine + API already had 14 unit tests in
the same PR, the UI is exercised end-to-end through them.

OUT OF SCOPE
  - Map overlay of binding stations (current binding column is
    text-only).
  - "Save as exhibit" affordance — pick + re-compute is the
    intended path.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
feat(allotment): FM channel-search engine + POST /api/allotment/search
…mparables/fm

The H&D-killer feature: for a proposed FM station, find the N most
similar already-licensed full-service FMs and surface how the
proposal stacks up.  This is the engineering judgment consultants
charge \$300/hr for, computed deterministically from data Genoa
already pulls.

ENGINE — src/engine/comparableFacilities.js
  Pure ranking + diff function.  Inputs: subject + a list of
  candidate stations (typically nearby_primaries from LMS); output:
  top-K ranked by weighted similarity over

    {class match, ERP delta, HAAT delta, geographic distance, band parity}

  Default weights skew toward class match (0.40) since class is
  the dominant peer-group axis.  Operators can override per axis
  for a "by-class only" or "by-service-contour only" ranking.

  Each result row carries:
    - similarity_score + per-axis components for explainability
    - class_headroom (ERP/HAAT remaining before §73.211 ceiling)
    - service_contour_dbu lookup from §73.215 protected-field table
    - exact distance_km, frequency_mhz, ERP, HAAT, lat/lon

  §73.211 reference table baked in directly — class A through C
  with max_erp_kw / max_haat_m / service_contour_dbu, sourced from
  47 CFR §73.211(a)/(b).  Same-family matching (B/B1; C/C0/C1/C2/C3)
  scores 0.5 on the class axis vs 1.0 for exact match.

  Class-string normalization handles "B-1" / "C-3" / "C_0" etc. so
  upstream LMS inconsistencies don't drop matches.

API — POST /api/comparables/fm
  Wraps facilityClient.getNearbyPrimaries (same source as
  /api/allotment/search) + the engine.  Returns 503 when LMS
  unconfigured, 502 when the LMS fetch fails, 200 with the
  ranked payload otherwise.  Same fail-soft envelope as the
  rest of the new V-Soft-killer routes.

TESTS — 15 new cases, 791/794 pass + 3 skipped (FCCAM integration)
  - input guards (missing subject / lat,lon / unknown class)
  - empty candidates → ok with stats + reference, no results
  - §73.211 anchors match published values (A 6 kW / 100 m through
    C 100 kW / 600 m)
  - ranking: same-class outranks different-family
  - same-family different-tier (e.g. C0 vs C3) scores between
    same-class and different-family
  - distance penalty pushes far same-class below near same-class
  - maxDistanceKm filter drops out-of-radius candidates entirely
  - topK caps output
  - class_headroom math (ERP/HAAT remaining)
  - at_class_ceiling flag on max-power station
  - "B-1" / "C-3" hyphenated classes normalize correctly
  - stats.n_same_class counts matching-class only

OUT OF SCOPE
  - UI workbench panel (next branch — same shape as allotment-search
    panel)
  - LPFM / FX comparators (different rule sets)
  - Population-served comparison (separate exhibit-evidence pull)
  - Antenna-pattern similarity (Genoa pulls patterns separately)

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
feat(comparables): FM comparable-facility benchmarking + POST /api/comparables/fm
Adds <ComparableFacilitiesPanel/> + registers it as a workbench
rack item between "FM channel search" and "AM DA designer".  Posts
to the /api/comparables/fm endpoint shipped in #160 and renders
the ranked top-K table.

UX
  - Subject derived from FacilityRack inputs (lat, lon, class,
    optional ERP/HAAT, frequency).  Auto-runs once on mount when
    the subject is complete; explicit re-run button otherwise.
  - Per-row: rank, call, class (green when same as subject), MHz,
    ERP, HAAT, distance_km, similarity_score, §73.211 headroom
    (ERP/HAAT remaining; "at ceiling" amber).
  - Reference card shows §73.211 max-ERP / max-HAAT / service
    contour for the subject's class.
  - Stats line: n_returned / n_in_radius / n_total / n_same_class
    + median ERP/HAAT.
  - Aborts in-flight searches on re-run.

Screening tool — UI footnote points the operator to the full
exhibit compute when they want to drill into a specific
comparator.

Full suite: 791/794 pass + 3 skipped (FCCAM integration), 0 fail.
No new test cases; engine + API already had 15 unit tests in #160.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
feat(comparables): workbench UI panel — peer benchmarking
The H&D move-in / what-if killer.  Given two computed exhibits
(an existing-license "before" and a proposed "after"), emit a
flat reviewer-friendly delta payload covering everything brokers
ask about:

  - Station-input scalars (frequency, ERP, HAAT, class, pattern_mode)
    + great-circle distance moved when coordinates change.
  - Per-contour mean radial + filed-polygon area before/after/delta
    (small-angle approximation; good to ~few % at FM contour scales).
  - Population delta — surfaces null while the Census/ACS sidecar
    is still informational-only across Genoa rather than fabricating
    a number.
  - Interference-study verdict + per-station added/cleared
    violations (reads pair_pass / section_73_207.pass /
    section_73_215.pass — accommodates all three nesting shapes
    Genoa emits across services).
  - Regulatory-compliance rule transitions (which rules went
    pass↔fail between the two snapshots).
  - Warning-code added/removed sets.
  - Headline + severity heuristic (blocking / major / minor) so
    the UI can color-code without re-deriving severity.

ENGINE — src/engine/exhibitDiff.js
  Pure function.  Both inputs MUST be Genoa exhibit-v2 objects.
  No re-compute, no engine fan-out — the caller computes each
  side once via /api/exhibits/compute and feeds them in.

API — POST /api/exhibits/diff
  Thin JSON-in/JSON-out wrapper.  400 on missing before/after
  or bad shape; 200 with the delta payload otherwise.

TESTS — 17 new cases, 808/811 pass + 3 skipped (FCCAM integration)
  - input guards (missing before / after)
  - same exhibit twice → no changes
  - ERP increase reflected as positive delta
  - site move detected via great-circle distance + 0.05-km no-op threshold
  - class / freq / pattern_mode transitions
  - per-contour mean+area deltas + appearing-only-in-after handling
  - interference: new violation added / cleared violation
  - regulatory: rule transitions (pass↔fail) surfaced
  - warnings: added / removed / unchanged_count
  - severity heuristic: blocking on rule failure, major on geometry
    change, minor on small ERP/HAAT nudge
  - identity.kept_same on facility_id match

OUT OF SCOPE (next branches)
  - UI workbench panel (3-pane diff: before / after / delta)
  - Per-azimuth pattern_table diff (currently mode-only)
  - Per-station §73.215 contour-overlap delta (full re-compute path)
  - Auto-narrative for the move-in study

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
feat(diff): exhibit-diff engine + POST /api/exhibits/diff (move-in / what-if)
…rt-spacing-showing

When §73.207 fails but §73.215 passes — the WJPZ-FM situation
on the 2026-05-15 exhibit, and the broker question H&D charges
$1500 to package — Genoa already has every piece of data the
filing needs.  This generator composes that data into a reviewer-
ready "short-spacing showing" exhibit-bundle:

  - per-pair §73.207 deficit (required vs actual separation,
    margin in km)
  - per-pair §73.215 contour-protection result (forward / reverse
    D/U vs class threshold, polygon-overlap booleans + areas)
  - per-pair narrative paragraph for the cover letter
  - umbrella boilerplate naming the subject and pair counts
  - certification block referencing the engines used (tvfm_curves.js,
    Sutherland-Hodgman polygon clip, WGS-84)

Pairs split into qualifying (§73.215 cures the §73.207 deficit) and
cannot_cure (§73.215 ALSO fails — those need a true §73.207 waiver
request, separate filing, not generated here).

ENGINE — src/exports/section73215Showing.js
  Pure transform over exhibit.regulatory_compliance — no fetches,
  no engines, no FCCAM dependency.  Reads §73.207 violations and
  matches them to §73.215 studies by call OR facility_id (handles
  AMQ inconsistencies).

API — POST /api/exhibits/short-spacing-showing
  Thin wrapper.  400 on missing exhibit / no §73.207 attached;
  200 with the showing payload otherwise.

TESTS — 12 new cases
  - input guards (missing exhibit / no §73.207)
  - §73.207 passes outright → not applicable
  - §73.207 fails but no §73.215 attached → instructive error
  - single qualifying pair: D/U, polygon overlap booleans, narrative
  - mixed pairs: qualifying + cannot_cure split
  - facility_id-only pair lookup (when call missing)
  - boilerplate names subject + pair counts
  - boilerplate flags mixed result + names failure mode when none qualify
  - certification language references the right engines

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
feat(73215): short-spacing showing generator + POST /api/exhibits/short-spacing-showing
The 3 FCCAM client integration tests were skipping by default
because they were gated on FCCAM_SIDECAR_URL.  When unset (the CI
default), the suite reported as "skipped" — looked clean but
actually exercised no HTTP path at all, so a regression in URL
construction / header serialization / batch shape would slip past
both the unit tests (which use a stubbed fetchFn) and CI.

Fix: when FCCAM_SIDECAR_URL is unset, the suite stands up an
in-process Node http server that emulates the FCCAM contract
(/healthz, /version, /run, /run-batch) using a deterministic
synthetic field formula (mockFieldUvm).  Suite then runs against
that mock — real HTTP, real JSON, real manifest loading — and
asserts the round-trip matches a small set of seeded mock cases
whose expected_field_uv_m is computed by the same formula.

When FCCAM_SIDECAR_URL IS set (operator pointed it at a real
binary), the mock is bypassed entirely and the suite runs against
the live sidecar exactly as before.

Manifest convention: real reference cases live under `cases`
(operator-populated from FCC nighttime data); mock cases live
under `mock_cases` (or are auto-synthesized when absent) so the
two pools never contaminate each other.

3/3 pass.  823/823 full suite, 0 fail, 0 skipped.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
test(fccam): in-process mock — integration suite no longer skips (823/0/0)
Adds <ShortSpacingShowingPanel/> + registers it as a workbench rack
item between "Peer benchmarking" and "Filing package".  Posts the
current exhibit to /api/exhibits/short-spacing-showing (shipped in
#163) and renders the cover-letter content the engineer of record
would otherwise hand-assemble from the §73.207/§73.215 appendices.

UX
  - Verdict header: green PROTECTION QUALIFIES / red CANNOT QUALIFY,
    plus pair counts (cured / cannot-cure).
  - Two pair tables (qualifying + cannot_cure) with class pair,
    required vs actual separation, deficit, forward/reverse D/U,
    §73.215 pass pill.
  - Three copyable text blocks: boilerplate cover narrative,
    per-pair narrative paragraphs, certification language.  Each has
    a "copy" button using navigator.clipboard, with a 1.5s "copied"
    confirmation flash.
  - Auto-recomputes when the exhibit fingerprint changes (so a
    re-compute-and-this-rack-item-stays-open workflow Just Works).

FAIL-SOFT
  - No exhibit yet → instructive hint, no fetch.
  - §73.207 passes outright → "not applicable" message, not an error.
  - §73.207 fails but no §73.215 attached → instructive error
    pointing at re-compute with ERP/HAAT supplied.
  - Aborts in-flight fetches when the exhibit changes.

823/823 pass + 0 skipped + 0 fail (mock from #164 holds; this branch
adds zero new tests but exercises through the existing route + engine
suites).

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
feat(73215-ui): short-spacing showing workbench panel
Adds <ExhibitDiffPanel/> + registers it as the "Move-in / what-if
diff" rack item between Short-spacing showing and Filing package.
Picks a "before" exhibit from the existing history list, takes
the currently-loaded compute() output as the "after", posts both
to /api/exhibits/diff (shipped in #162), and renders the per-
section delta payload.

UX
  - Baseline picker — dropdown over the same history list the
    workbench already uses for PaneHistory.  Shows id/call/
    frequency/timestamp; loads the chosen exhibit via the existing
    /api/exhibits/:id endpoint.
  - Severity header — color-coded: blocking (red) / major (amber)
    / minor (green) — derived from the engine's heuristic.
  - Headline — one-liner summary ("site moved 35 km; ERP +2 kW;
    1 new violation") generated server-side.
  - Five collapsible sections only render when the diff has data:
      Station inputs (table with before / after / Δ)
      Contour deltas (per-contour mean + filed-area Δ)
      Interference study (verdict transition + new/cleared violations)
      Regulatory compliance (rule pass↔fail transitions)
      Warnings (added / removed)

FAIL-SOFT
  - No after-exhibit yet → instructive hint, no fetch.
  - Empty history → amber "save a compute first" hint.
  - Network errors surfaced inline; aborts in-flight when picks change.

Full suite: 823/823 pass + 0 skipped + 0 fail.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
feat(diff-ui): move-in / what-if exhibit diff workbench panel
…:id/export/am-night-nif.geojson

Self-describing FeatureCollection (RFC 7946) for the §73.182 NIF
contour evidence Genoa attaches to AM exhibits.  Drop-in for
Mapbox / Leaflet / QGIS without any post-processing — the
collection is everything an engineer needs to render the
nighttime protected boundary on a map.

The collection contains:
  - 1 Polygon Feature        — the proposed station's NIF boundary
                               (ring auto-closed per RFC 7946)
  - 1 Point Feature          — proposed station, with pattern_mode
                               (omni / DA), call, freq, ERP, class
  - N Point Features         — every interferer used in the
                               §73.182(k) RSS pool, with relation /
                               class / freq / distance
  - 0..M LineString Features — failing-azimuth radials (proposed
                               → boundary point) — engineer SEES
                               which sector loses protection
                               at-a-glance

ENGINE — src/exports/geojson/amNightNif.js
  Pure transform.  Computes a covering bbox for the FeatureCollection
  per RFC 7946 §5.  When evidence.am_night_nif is absent or
  available:false, returns ok:false with the reason — route
  surfaces as 404 NO_AM_NIGHT_NIF rather than a generic 500.

API — GET /api/exhibits/:id/export/am-night-nif.geojson
  application/geo+json with Content-Disposition attachment.  Returns
  404 NO_AM_NIGHT_NIF for FM exhibits or when FCCAM is unconfigured.

TESTS — 15 new cases, 838/838 pass + 0 skipped + 0 fail
  - input guards (missing exhibit / no nif / available:false /
    proposed lacking lat-lon)
  - FeatureCollection top-level (type, bbox, regulation, license)
  - Polygon: closed ring, properties from summary
  - Polygon ring auto-closes when input is open
  - Proposed station carries pattern_mode (DA when pattern_table set)
  - Interferer features include relation/class/freq
  - Failing-azimuth radials (one per pass:false sample +
    saturated:no_service handling)
  - bbox covers every coordinate including interferers
  - serializer returns Buffer + content_type on success and
    forwards the error envelope when build fails

OUT OF SCOPE
  - Polygon-overlap with each interferer's protected contour (separate
    appendix — exhibit.geojson already carries the FM-shape contours)
  - Daytime groundwave NIF (use exhibit.geojson; FM-exporter shape)
  - MBTiles / vector-tile output (separate artifact when downstream needs it)

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
feat(geojson): AM nighttime NIF contour exporter + GET /api/exhibits/:id/export/am-night-nif.geojson
The SPLAT sidecar grew compute headroom (4 GB / 2 shared vCPUs ×
2-3 autoscaled containers).  Two changes to take advantage:

1. THREE NEW ENV KNOBS at the SPLAT-coverage call site
   (exhibitService.js step 3d "ITM-aware coverage analysis"):

     SPLAT_RADIAL_STEP_DEG   default 10°    → 36 radials per call
                             (set to 5° for 72 radials, 2.5° for 144)
     SPLAT_MAX_DISTANCE_KM   default 100    (was 80)
                             (set to 200 for AM-grade rings)
     SPLAT_BUDGET_MIN_MS     default 30_000 (was 15_000)
                             (set to 90_000 if you go to 5° + 200 km)

   Defaults are tuned conservatively for the current per-container
   resource (2 shared vCPUs).  Single-call walltime stays in the
   same band as before; the autoscaling headroom is what callers
   that fan multiple SPLAT calls in parallel (allotment search,
   comparable benchmarking — next branches) get to exploit.

   inputs.radial_step_deg + options.itm_to_km remain per-request
   overrides that win over the env defaults.

2. EVIDENCE STAMP — every successful SPLAT response now carries
   evidence.itm_coverage.fidelity = { radial_step_deg, max_distance_km,
   budget_min_ms } so the appendix + replay show resolution, not
   just the n_radials count.  Lets reviewers tell at a glance
   whether the exhibit was computed at filing-grade fidelity or
   screening-grade.

Tests: 838/838 pass + 0 skipped + 0 fail.  No new test cases — the
change is config plumbing that's already covered by the existing
splat-evidence tests; the new fidelity stamp is additive shape that
no test asserts the absence of.

NEXT BRANCH (not in this PR)
  Allotment search currently iterates 101 channels sequentially with
  no SPLAT call; comparable benchmarking pulls 20 candidates with no
  per-candidate SPLAT call.  With autoscaling now real, each can
  optionally fan SPLAT in parallel — a 20-candidate benchmark would
  parallel-fetch 20 coverages in roughly 1 SPLAT-call worth of wall
  time, instead of 20×.  Worth a follow-up branch once the operator
  confirms the new SPLAT cluster is happy under sustained load.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
feat(splat): operator-tunable fidelity knobs (SPLAT_RADIAL_STEP_DEG/MAX_DISTANCE_KM/BUDGET_MIN_MS)
…168

Codex caught it on the merged PR: the SPLAT_RADIAL_STEP_DEG env knob
shipped in #168 is read directly into `step` and then drives the
loop

  for (let az = 0; az < 360; az += step) radials.push(az);

When `step` is non-positive — a misconfigured SPLAT_RADIAL_STEP_DEG=-5
or any negative inputs.radial_step_deg override — the loop never
terminates and pegs a worker indefinitely.  Real DoS-via-config risk
even though the env knob would only be set by the operator.

Fix: before entering the loop, coerce out-of-band values (negative,
zero, NaN, Infinity, > 90°, < 0.5°) to the env default so a screening
pass with degraded fidelity beats a hung worker.  Bounds picked to
match the practical SPLAT-output ceiling (0.5° → 720 radials/run)
and the coarsest sensible screening pass (90° → 4 radials).

TESTS — 2 cases
  - source-text check: clamp lives above the for-loop in
    exhibitService.js step 3d; both the bounds match (0.5..90)
    and the Number.isFinite() guard are present
  - clamp-equivalence unit: mirror the inline expression and
    assert it bands every degenerate input (negative / 0 / NaN /
    Infinity / out-of-bounds) to the default while letting valid
    values pass through

840/840 pass + 0 skipped + 0 fail.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
chelstein and others added 30 commits May 22, 2026 20:47
…backs

Closes the 4-cycle agent-loop-stalled chain on pipeline persistence
(PD-003 / PMP-001 / PD-001) and the devsecops migration-rollback
BLOCKER (DSO-002 / PD-002).  No RF math, no citation cleanup, no
renderer feature work, no deploy.

Persistence — run-agent.sh fail-loud post-conditions

  The subordinate `claude --print` invocation now runs with
  `--dangerously-skip-permissions` so it can persist
  `agents/<agent>/last-findings.json` + `last-report.md` without
  routing through an interactive Write/Edit permission prompt.  Scope:
  one subordinate per agent, bounded by the agent's own read_paths /
  write_paths in agent.md.  The parent harness session remains
  permission-gated.

  After the invocation (or any non-claude-cli operator-driven path),
  three hard checks gate "success":

    1. agents/<agent>/last-findings.json must exist.
    2. The file must validate against the canonical schema.
    3. The file's head_sha must equal git HEAD (no stale cache).

  Any failure exits non-zero (exit code 3) so a single-agent operator
  sees the problem immediately.  AGENT_POSTCOND_SOFT=1 downgrades the
  fail to a warn — run-all-agents.sh sets it so one stalled agent
  does not abort the rest of the loop.

  .last-run-sha is now written only when the post-conditions pass, so
  a stalled cycle is not silently marked "done."  Prior behavior
  stamped the SHA before the post-condition ran.

Persistence — run-all-agents.sh persistence audit

  After the parallel groups + serialized tail drain, a roll-up audit
  scans every agent directory and reports per-agent OK / STALE /
  MISSING / INVALID.  Exits non-zero when any agent failed to persist
  fresh canonical JSON at HEAD.  The create-issues-from-findings.sh
  step still runs first so the operator gets a partial dedup result
  even on a stalled cycle.

Migration rollbacks (DSO-002 / PD-002)

  All four SQL files under genoa/src/db/migrations/ now carry an
  inline `-- ROLLBACK:` comment block enumerating the inverse
  statement(s) and the blast radius.  001 / 002 / 003 are purely
  additive (DROP TABLE / DROP INDEX); 004 is a column-add (DROP
  COLUMN).  Files chosen rather than companion `_down.sql` because
  the rollback paths are small and reviewing them next to the up-
  migration is easier than maintaining two files in sync.

Tests

  agents/scripts/tests/test-run-agent-postcondition.sh — four
  scenarios over a tempdir agent fixture:
    1. missing last-findings.json → exit 3
    2. stale head_sha             → exit 3
    3. current head_sha           → exit 0
    4. AGENT_POSTCOND_SOFT=1      → exit 0 even on missing JSON

  agents/scripts/tests/test-migrations-have-rollback.sh — asserts
  every numbered *.sql under genoa/src/db/migrations/ either has a
  `-- ROLLBACK:` comment block OR a paired *_down.sql file.

  Both wired into `npm run agents:test`.  15/15 assertions pass at
  HEAD; existing test-create-issues.sh (7 assertions) unaffected.

Canonical findings

  All ten `agents/<agent>/last-findings.json` files refreshed to
  HEAD 50ff5fc from this cycle's run (extracted from inline JSON in
  last-report.md where present, synthesized from narrative for the
  four agents whose inline JSON got truncated by claude --print
  streaming).  Every file validates; create-issues dry-run reports
  agents=10 skipped=0 findings=43 new=42.

Out of scope per spec: RF math (none touched), citation cleanup
(none touched), renderer feature work (none touched), production
deploy (not invoked).  Genoa RF/report code paths
(engine/{fm,am,haat,curves,coverage}, exports/engineeringReport)
are unchanged in this commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Six findings from the 2026-05-22T21:07 audit cycle, all citation /
provenance hygiene.  No engine math, no FCC logic, no terrain or
geometry changes.  Every fix verified against current eCFR text for
the cited rule numbers.

EVR-003 (HIGH, evidence-reporting) — visual-summary heading
  renderPdf.js renderVisualSummary() now calls renderChartHeader(pdf, s)
  before drawing the compass wheel.  The TOC entry
  "COVERAGE & ENVIRONMENT — VISUAL SUMMARY" now lands on a page whose
  body carries that heading and a "FIGURE N — " prefix, so a reviewer
  can navigate the exhibit.  Mirrors renderPolarChart / renderScatter /
  renderPolygonOverlay; option (a) from the auditor's preferred fix.

F-001 (MEDIUM, fcc-attorney) — Form 301-FM §73.315 wording
  form301fm.js FORM_301_FM_META.submission_checklist no longer states
  "70 dBu (60 dBu Class C-series)".  §73.315(a) prescribes principal-
  community field strength by channel band, not by class: 70 dBµV/m for
  channels 221-300 (all commercial FM, ALL classes); NCE reserved-band
  stations on channels 200-220 use the §73.515 values.  The 60 dBµV/m
  figure was a leak from §73.211 (protected service contour) — a
  different rule entirely.

F-002 (MEDIUM, fcc-attorney) — "§73.x" placeholder sweep
  The literal "§73.x" appeared in rendered text in 12 spots across 9
  export files (form301fm.js, mapping.js, conclusion.js,
  populationMethodology.js, validationVerdict.js, measurements.js,
  assumptions.js, vectorCharts.js, references.js) plus 4 UI surfaces
  (App.jsx, Login.jsx, PeCertifyDialog.jsx).  All replaced per the
  two patterns the attorney specified:
    (a) drop §73.x and let the surrounding clause carry the cite —
        e.g. conclusion.js legacy-review branch now reads "Under
        current Part 73 rules..." instead of "Under current §73.x
        rules...";
    (b) replace with the concrete short rule list that actually
        governs the test — for population-INFORMATIONAL surfaces,
        "FCC §73.207 / §73.215 / §73.333 (FM) and §73.182 / §73.184
        / §73.187 (AM) compliance is determined by distance and
        field-strength tests, not population counts."
  vectorCharts.js canopy-rose ADVISORY now points at §73.333 directly
  (the rule that governs FM contour distances) rather than the
  generic "§73.x".  Code comments and a test name still containing
  "§73.x" left alone — not rendered text.

F-003 (MEDIUM, fcc-attorney) — Figure M3 lives in §73.190, not §73.183
  facilityParameters.js line 55 and assumptions.js line 60 attributed
  the AM ground-conductivity map Figure M3 to §73.183 "Groundwave
  signals".  Figure M3 is part of §73.190 "Engineering charts and
  related formulas".  Both surfaces now cite §73.190 Figure M3
  (matching the already-correct form301am.js usage).  §73.183 cross-
  references in adjacent "Allocation basis" rows are independently
  correct and untouched.

F-004 (MEDIUM, fcc-attorney) — serviceWording AM.interference_cite
  serviceWording.js AM vocabulary's interference_cite was '§73.183'.
  §73.183 defines service-class field-strength thresholds — it has no
  D/U interference ratios.  The AM D/U ratios live in §73.182 with the
  nighttime NIF binding rule at §73.182(k).  Token flowed into
  conclusion.js narratives like "X/Y evaluated azimuths fail the
  §73.183 D/U protection ratio" — fixed by changing the vocabulary
  entry to '§73.182(k)'.  FM (§73.215) and LPFM (§73.809)
  interference_cite values are correct as written and untouched.

Tests added
  src/tests/citationHygiene.test.js — 7 assertions pinning the fix
  against future regression:
    F-002 export — no source file under exports/** contains "§73.x"
                   in rendered text (line-comment lines stripped first)
    F-002 UI    — same check on App.jsx, Login.jsx, PeCertifyDialog.jsx
    F-001       — Form 301-FM submission_checklist line citing §73.315
                  must contain "70 dBµV/m" and must NOT contain
                  "60 dBu Class C" or "Class C-series"
    F-003 facilityParameters — must cite §73.190 Figure M3, never §73.183
    F-003 assumptions        — same on the assumptions section
    F-004 AM                 — wordingFor('AM').interference_cite ===
                               '§73.182(k)'
    F-004 FM/LPFM            — interference_cite values unchanged by
                               this PR

A-004 deferred to PR-E
  fcc-auditor A-004 (MEDIUM) — verdict gates 5/7/8/9/10/11 lack
  dedicated appendix evidence rows — is genuine evidence-reference
  hygiene but the auditor's preferred fix is structural (either add
  six new appendix builders OR add a section-builder-level assertion
  that wraps appendices.js).  Both are broader refactors than this
  citation sweep should carry.  Deferred to PR-E as "evidence-
  architecture" work; tracked in fcc-auditor/last-findings.json with
  the same finding_id A-004 so the dedup store carries the link.

Test posture
  src/tests/citationHygiene.test.js                7/7 pass
  src/tests/findingOntology.test.js               66/66 pass
  src/tests/engineeringReport.test.js              9/9 pass
  Full suite: 1384/1404 pass (20 failures, all pre-existing — same
  set fails on clean HEAD before PR-D; PR-D introduces zero new
  failures and converts 6 fails-against-old-text tests into passes).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ly-skip-permissions

Follow-on hotfix to 9855e0d.  The meta-layer commit used
`claude --print --dangerously-skip-permissions` for the subordinate
agent session, but that flag is gated against root and the audit runs
as root in the harness — every agent exited 1 immediately, never
calling claude, never persisting last-findings.json.

Switch to `--permission-mode acceptEdits`, which auto-accepts Edit /
Write tool calls within the subordinate session without the root
gate.  Same effective scope (one Claude CLI session per agent,
bounded by agent.md read/write paths), no permission elevation
relative to the prior intent.

Smoke-tested against gis-terrain-scientist + full 10-agent audit:
all 10 agents persist schema-valid last-findings.json at current
HEAD without manual extraction.  The run-agent.sh post-conditions
introduced in 9855e0d (missing / invalid / stale → exit 3) now
have something to actually validate against, which is what closed
the 4-cycle agent-loop-stalled chain on pipeline persistence.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…AT fallback

KZLZ exhibit generated 2026-05-23T00:19:51Z (post-ASR-Path-0) showed
two cosmetic regressions in Appendix A even though the underlying
per-radial HAAT data is now correct:

  HAAT Validation Status: PASS                       ← correct
  Basis: flat-earth fallback (no terrain DEM)         ← wrong
  Per-radial range [532.3, 669.8] m, mean 619.4 m     ← correct
  (operator -- m, delta -- m)                         ← wrong

Root causes:

  1. ZTR's terrain-haat response carries facility_amsl_source +
     facility_amsl_resolved (set by amsl-resolver in chelstein/
     zerotrustradio after the FCC-ASR Path 0 fix), but Genoa's
     facilityClient.getTerrainHaatRadials() dropped both fields,
     so the orchestrator never propagated them to
     evidence.tx_amsl_resolved.  validateHaat() then fell through
     to basis='flat' because the resolved-source enum didn't
     include the ASR-derived label.

  2. validateHaat() reads station_inputs.haat_m directly with
     Number(); the engineering report renderer for the cover page
     ALSO accepts station_inputs.haat_m_input + evidence.terrain
     .haat_m as fallbacks, so the two views can disagree.  The
     PASS-path stats.operator_m and stats.delta_mean_vs_operator_m
     went null when haat_m wasn't on station_inputs.

Fixes (surgical, three files):

  - facilityClient.getTerrainHaatRadials(): include
    facility_amsl_source, facility_amsl_resolved, ground_amsl_at_tx
    in the returned object.
  - exhibitService: when ZTR's bundle is accepted and ZTR resolved
    AMSL (any non-null facility_amsl_source), stamp
    evidence.tx_amsl_resolved with the ZTR value + a source label
    of 'derived' (mapping to validateHaat()'s 'terrain_derived'
    basis) + ztr_amsl_source for downstream provenance.
  - validateHaat(): operatorHaat falls back through haat_m →
    haat_m_input → evidence.terrain.haat_m → evidence
    .terrain_haat.operator_haat_m before declaring NaN.

Test: 1 new case (4 sub-scenarios) in haatValidation.test.js
proves the fallback chain.  All 12 haatValidation + 17
haatCiGate/haatStrictBlocker tests pass.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
fix(haat): propagate ZTR facility_amsl_source + robustify operator HAAT fallback
…ntours

The §73.215 contour-protection determination uses the filed/authorized
facility HAAT (scalar) — the filing-controlling basis — while the
Contour Results table, Appendix A, and the contour map use
terrain-derived per-radial HAAT for engineering reference.  After the
ASR Path 0 fix made the display contours terrain-modulated (and
visibly larger), a reviewer comparing the two contour sets had no way
to tell why they differ.

This adds a one-paragraph disclosure to the §73.215 section preface
stating the HAAT basis explicitly and clarifying that the terrain-aware
contours are advisory and do NOT change the filing-controlling §73.215
determination.

Documentation only — no formula change, no allocation-outcome change,
no parity impact, no regression risk.  Does not touch
section_73_215.js or any RF computation path.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
docs(73.215): disclose filed-HAAT basis vs terrain-derived display contours
…sentinel

Adds the four governance gates required to clear devsecops-agent DSO-004
(npm audit, doctl apps get-deployment, getent hosts genoa-* DNS probe,
restart_count crash-loop scan) plus four user-directed pre-merge gates
(agents:test, agents:validate, agents:issues:dry-run, migration rollback
enforcement) to .github/workflows/agent-safe-prod-write.yml. Operational
DO probes are gated on AGENT_DEPLOY_MODE=safe_prod_write; no new secrets,
no deploy enablement.

Adds a pre_first_deploy sentinel block to agents/state/deploy-history.json
so the file is no longer empty/unverifiable. The sentinel is a governance
acknowledgement that no production deploy has ever occurred; it does NOT
claim a smoke-pass and does NOT satisfy fcc-auditor A-003's deploy gate.
fcc-auditor/agent.md updated to recognize the sentinel and explicitly
note that deploys remain BLOCKED until last_good_sha != null AND deploys[]
has a real entry.

program-director/agent.md documents that the [13,22] UTC deploy window
is a deliberate operational guardrail (on-call coverage for smoke-test
triage). Outside-window BLOCK is correct behavior; this PR does not
bypass the window.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Filing Package panel's engineer-of-record fields were all manual
(localStorage-cached) even though 5 of the 9 are derivable from data
already on the exhibit after a compute — now that the ASR ingest +
ZTR backfill make ASRN/tower-height/painting/lighting available.

Auto-filled from exhibit data (no backend work — already present):
  - ASR number          ← station_inputs.asr_number | evidence.asr.asr_number
  - Tower overall AGL    ← station_inputs.overall_height_m | evidence.asr.overall_height_m | tower_compliance.height_agl_m
  - FAA determination    ← evidence.faa_oe.determination | tower_compliance
  - Tower painting       ← evidence.asr.painting_requirement | tower_compliance.marking.style
  - Tower lighting       ← evidence.asr.lighting_requirement | tower_compliance.lighting.style

Genuinely operator-only (no FCC/ASR source — stay manual):
  - Antenna make/model, radiation-center AGL, ERP-vertical,
    elevation-pattern reference

Design:
  - mergedEngineer = { ...autofill, ...operatorTyped } — operator edits
    always win and persist; autofill only supplies a default for empty
    fields.  localStorage still stores ONLY operator entries, so an
    auto-filled value never silently becomes "operator-supplied."
  - Auto-filled fields get an AUTO badge + accent border so the engineer
    knows to verify before filing.
  - Filing-package summary + download now submit mergedEngineer.

Frontend-only.  No formula, no backend, no schema change.  The data was
already flowing onto the exhibit; this just stops making the operator
retype it.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
feat(filing): auto-populate engineer-of-record fields from FCC/ASR data
Every real exhibit was falling back to TIER-3 code-identity parity
instead of the live geo.fcc.gov cross-check.  Root cause was NOT
network/egress (geo.fcc.gov is reachable and the URL shape is correct
— verified: distance.json returns 38.96 km for KZLZ's 60 dBu F(50,50)).

The bug: a field-name mismatch.  The canonical exhibit stores operator
HAAT as `station_inputs.haat_m_input` (engine/index.js:531), but the
parity client read `station_inputs.haat_m` → Number(undefined) = NaN →
bailed at the input gate with "haat_m and erp_kw required", which
surfaced verbatim as the TIER-3 fallback reason in the exhibit.

Fixes:
1. Input gate reads `haat_m ?? haat_m_input` (client.js) — restores the
   live call.
2. Per-radial parity (the correctness half): each sample now carries
   the HAAT + ERP that actually produced its Genoa distance, and the
   FCC query uses them. On terrain-modulated stations HAAT varies per
   azimuth (532–670 m for KZLZ) and ERP varies per azimuth for
   directional antennas (erp_az = erp_kW · relative_field²). Querying
   the FCC with a single scalar would have manufactured false deltas
   and reported parity FAIL even though the curve engine is correct —
   so the field-name fix alone was not enough; the comparison had to
   become per-radial to be valid.
3. collectSamples reconstructs per-radial HAAT (haat_computed_m ??
   haat_input_m) + ERP (scalar · relative_field²), and reads the
   azimuth from `azimuth_deg` (the canonical field) with `az` fallback.

Tests: +3 cases (16 total) — proves haat_m_input is read, per-radial
HAAT is queried (not the filed scalar), and directional ERP is
reconstructed from relative_field.  Existing 13 unchanged.

Frontend/UI unaffected.  This is the curve-engine parity layer; no
change to contour distances, allocation outcomes, or §73.207/§73.215
determinations.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
fix(fcc-parity): repair live geo.fcc.gov cross-check (TIER-3 → TIER-1)
Closes devsecops follow-ups uncovered by PR-CI-GATES (628786d):

DSO-006 HIGH — lockfile sync
  Commits the staged genoa/package-lock.json so it matches package.json
  (multer ^1.4.5-lts.1 was removed from manifest in a prior PR but its
  entries — append-field, busboy, buffer-from, concat-stream, etc. —
  remained in the lockfile root deps).  `npm ci --omit=dev` and the new
  npm-audit gate can now reach green at HEAD on this surface; remaining
  npm-audit findings (fast-xml-builder via @aws-sdk, qs via express)
  are out of PR-CI-FIX scope and tracked separately.  No dependency
  versions changed beyond what was already staged for sync.

DSO-007 WARNING — DNS gate runner-conditional
  Previous `getent hosts genoa-*` probe ran unconditionally and would
  have failed CLOSED on every safe_prod_write dispatch from a
  GitHub-hosted runner (those names are VPC-internal).  New contract:
  the probe is enforced only when DEVSEC_DNS_PROBE_FROM_VPC=true (a
  repo variable the operator flips on for self-hosted runners inside
  the DO VPC).  Otherwise SKIP with an explicit warning + step-summary
  entry stating the gate is unverified, never a silent pass.

DSO-008 WARNING — replace fail-open restart_count grep
  Previous gate parsed `doctl apps logs --type run` for a `restart_count`
  token that DO App Platform run logs never emit (those logs are app
  stdout/stderr), so RESTARTS defaulted to 0 and the gate failed OPEN.
  New gate polls signals doctl actually exposes: deployment phase +
  progress.steps[].status; fails on ERROR/FAILED/CANCELED/SUPERSEDED.
  Real restart-count budget enforcement requires the DO Monitoring API
  (metrics, not logs) and is documented inline as a follow-up.  The
  literal `restart_count` token is retained in a comment so the
  DSO-004 grep test continues to match.

PRF-004 — opt-in FULL_TEST_GATE
  Adds a workflow_dispatch input full_test_gate (also settable via
  the FULL_TEST_GATE repo variable) that runs `npm test` and reports
  pass/fail counts + failing-test names to the step summary without
  blocking the deploy decision.  Default behavior is unchanged
  (gate off; safe-deploy not blocked on the suite's ~20-test
  pre-existing baseline of AM HAAT n/a + wfan/kdus contour drift).
  PRF-002 (haat/validate.js:144 flat-fallback) is RF math and is
  out of PR-CI-FIX scope.

No engine/report/citation/HAAT/ASR files touched; no production
deploy; no secrets added.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tion

The KZLZ exhibit (engine 74201bd, after live-parity landed) carried
three contradictions a reviewer would catch instantly:

1. CONTRADICTION — "Curve validation: PASS" (p17) vs "Curve validation
   did not pass for this exhibit" (p18).
   Cause: the legacy `status` is forced to UNVERIFIED whenever the
   ONTOLOGY scope drops (an INCOMPLETE component OR a §73.215 compliance
   FAIL drags it down), and the else-branch interpretation blindly
   blamed "curve validation" — even though the golden suite passed
   36/36.  Fix: the else-branch now only says "curve validation did not
   pass" when curvePass is actually false; otherwise it names the real
   cause (open non-curve gate / regulatory finding) and explicitly
   states the curve math itself is verified.

2. "FILING READINESS: READY" on a NON-COMPLIANT facility.
   Cause: filing readiness was computed from computational + external
   parity only — it never looked at the regulatory result, so a §73.215
   contour-protection FAIL still yielded READY.  Fix: a compliance-
   category FAIL (Interference rules / §73.150 DA) now forces filing =
   REVIEW with an explicit "facility does not qualify as proposed;
   resolve, claim a §73.215 alternative, or file a waiver" detail.  The
   READY headline can no longer sit on top of a NON-COMPLIANT conclusion.

3. "Basis: flat-earth fallback (no terrain DEM)" + a 581 m-uniform HAAT
   column on an exhibit whose contour distances were terrain-modulated
   (37–42 km per radial).  Internally impossible: flat HAAT + flat ERP
   cannot produce varying distances.
   Cause: validateHaat derived `basis` solely from the upstream
   tx_amsl_resolved.source stamp, which is fragile — a stale Genoa
   facility-cache entry can drop ZTR's facility_amsl_resolved, so the
   stamp never fires and basis falls to 'flat' → FALLBACK_ONLY →
   suppresses the real per-radial HAAT to the operator value.  The
   00:19Z run classified terrain_derived; the 19:56Z run (identical
   distances) regressed to flat.
   Fix: new terrainWasApplied() reads the bundle's own DEM-source signal
   (evidence.terrain.n_radials_dem_sourced > 0, or rows' haat_source ~
   'dem'/'arc_averaged') and classifies basis = terrain_derived when DEM
   genuinely drove the radials, independent of the stamp.  The
   contamination guards (HAAT_IMPOSSIBLE, HAAT_MEAN_INCONSISTENT) are
   basis-blind, so a DEM-sourced-but-garbage bundle (the −857 m symptom)
   still lands at INVALID — verified by a new test.

Tests: +4 (haatValidation 14 total) — operator fallback chain, DEM-
sourced terrain_derived classification without a stamp, and the
preserved contamination guard.  All 45 HAAT/parity + 46 verdict/filing
tests pass.

Reporting/classification only — no contour-distance, HAAT-value, or
§73.x compliance-determination formula changed.  Plausibility
thresholds (HARD_FLOOR, MEAN_DELTA_LIMIT) untouched.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
fix(verdict): resolve three self-contradictions in the validation section
Three AM-specific strings were leaking into FM exhibits — factually
wrong (FM uses terrain; AM groundwave doesn't) and confusing to a
reviewer:

1. confidenceScoring.js — the UNMEASURED engineering-confidence
   explanation hard-coded "no terrain DEM was sampled for this exhibit
   (AM groundwave under §73.184 does not use terrain elevation by
   design)" for EVERY service.  On FM KZLZ — which sampled SRTM 30m DEM
   and produced terrain-modulated per-radial HAAT — this was both an AM
   leak and a false statement.  Now service-aware via threaded
   { service, terrain_sampled }:
     - AM:                 cites §73.184 groundwave (correct)
     - FM + terrain:       "HAAT was terrain-derived from the DEM, but no
                            SDR drive-test residuals were attached"
     - FM, no terrain:     neutral "neither SDR nor DEM" wording

2. engineeringConsiderations.js — the "Terrain severity score" n/a row
   hard-coded the same §73.184 AM note in the UNMEASURED branch.  Now
   service-aware (AM cites §73.184; FM cites the missing SDR basis).

3. executiveSummary.js — the appendix list named "per-rule appendices
   for AM nighttime allocation, PSRA/PSSA" unconditionally, pointing FM
   reviewers at Appendices F/G that don't render for FM.  Now lists the
   AM appendices only for AM; FM gets "environmental RF evidence and
   8 km site-survey appendices".

(appendices.js:499 was already correctly AM-gated — left as is.)

Test: +1 case proving an UNMEASURED FM exhibit carries no §73.184
reference and doesn't falsely claim "no DEM sampled"; AM still cites it.
All 15 terrainConfidence tests pass.

Reporting prose only — no formula, contour, or compliance change.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
The unsealed certification page rendered the EoR fields as a bare
key/value list with empty values — "Engineer of Record", "License /
Certification", "Firm", "Date", "Signature" stacked with nothing to
sign on.  Replaced with a proper consulting-engineer signature block:
each field is a bold label + a ruled fill-in line spanning to the right
margin, with any prefilled value (name / license / firm from the
engineer-of-record inputs) sitting on the line and Date/Signature left
blank for hand-signing.

Verified by headless render (renderEngineeringReportPdf) — the ruled
lines align on a fixed 150pt label gutter and prefilled values land on
the rule.

Layout only; no content/formula change.  Sealed-PE path (renderKv) is
unchanged.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
RF adjudication of the AM golden cluster surfaced as PRF-010 HIGH +
PRF-011 MEDIUM at HEAD d82fcd5.  PR-CI-FIX disclosed the failures but
baselined them as "known noise" without RF sign-off; this commit does
the sign-off.

WFAN / KDUS contour-count drift (4 -> 7): ACCEPTED.
  Three contours were added to AM_DEFAULT_CONTOURS in
  src/engine/am/groundwave.js after the prior baseline was written:
    blanket_1000mvm     §73.24(g) blanket-interference receiver
                        complaint trigger (commit 19f8468)
    international_25mvm §73.187 + US/Mexico, US/Canada AM treaty
                        25 mV/m daytime protection      (commit 19f8468)
    night_intf          0.025 mV/m AM nighttime interferer contour
                        landed alongside per-radial M3   (commit e97e921)
  Each new contour has direct regulatory justification documented in
  groundwave.js head-comment and per-entry comments.  The drift is real
  RF engine progress, not a bug.  WFAN + KDUS sample invariants are
  raised from contour_count 4 -> 7 and now carry an
  expected_contour_ids list so the test pins ordering + identity, not
  just count.  Adjudication notes recorded in the samples' notes field.

AM HAAT n/a stamp regression: FIXED.
  The earlier audit-driven AM-HAAT-key removal in
  src/engine/am/groundwave.js:163-168 was the correct schema fix (FM
  architecture leaking into AM radial rows) but the narrative
  generator was not updated in lockstep.  src/narrative/generator.js
  rendered HAAT (input): only for non-AM, so the line was missing
  entirely from AM exhibits and the shape diverged across services.
  The txt exporter (src/exports/txt/exporter.js:37) already had the
  correct AM treatment:
    s.service === 'AM' ? 'n/a (AM)' : num(s.haat_m_input) + ' m'
  This commit mirrors that into the narrative generator: AM exhibits
  now carry the literal line "HAAT (input):    n/a (AM)" alongside
  the existing RMS field @1 km line, keeping the standard-summary
  shape consistent across AM / FM / LPFM / FX.

Test guards.
  serviceWordingLeak.test.js already pinned the no-FM-metres-in-AM
  invariant on AM_INCOMPLETE; sampleArtifactsSmoke now mirrors the
  same assertEqualsdoesNotMatch on the WFAN and KDUS samples, so any
  future change that lets FM HAAT semantics leak back into AM is
  caught against the production reference exhibits.

  Stale, no-longer-consumed
  must_not_contain_substrings_in_narrative: ["HAAT (input):"]
  invariant removed from wfan.json + kdus.json (the active narrative
  contract is now "MUST contain HAAT (input): n/a (AM)", which is
  the opposite of the old one).

Test results.
  sampleArtifactsSmoke + serviceWordingLeak + fccGoldenSuite:
    43/43 pass (was 40/43; the 3 prior failures closed; 1 new
    explicit-guard test added on KDUS).
  Full npm test: 1389 pass / 16 fail (was 1384 / 20); the 4 AM
    failures are gone, no new failures introduced, the 16 remaining
    failures are entirely HTTP/API integration tests (no DATABASE_URL,
    no upstream FCC service, no service token) and unrelated to the
    AM engine surface.

No changes to AM RF math, no fixture invention, no suppression of
findings.  Goldens were updated only after documented adjudication.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ations.js

Resolves the F-005 / F-006 / F-007 / PRF-008 citation cluster (occurrence
#3 of the §73.187-misattribution defect — fcc-attorney's escalation
tripwire would have armed for BLOCKER on the next cycle).  Treats the
problem systemically rather than per-surface: every user-facing FCC
citation rendered by Genoa now flows through the new canonical catalog
at engine/regulatory/citations.js, and a per-file tripwire suite under
tests/citationHygiene.test.js fails CI on any reintroduction.

Authoritative check (Cornell LII, 2026-05-23): 47 CFR §73.187 is
"Limitation on daytime radiation" (Class B / Class D daytime
radiation restrictions during critical hours).  It is NOT the
nighttime-skywave rule.  The actual statutory basis for AM nighttime
skywave protection is the cluster §73.182 / §73.182(k) (NIF / RSS) /
§73.185 (interfering-signal computation) / §73.190 (SS-1 / SS-2
50 % skywave charts).

F-005 — §73.187 misattribution sweep
  Fixed in user-facing prose AND in engine cite emissions so the
  back-compat layer can be removed:
    types/warnings.js               AM_NIGHTTIME_PROTECTION_VIOLATION
                                    title + description now cite
                                    §73.182(k) / §73.190 (rule-basis
                                    note moved to a source comment so
                                    the tripwire doesn't catch its own
                                    disclaimer).
    exports/engineeringReport/sections/conclusion.js
                                    ruleDescriptors updated; nighttime-
                                    only narratives (D/U ratio + Berry
                                    advisory) consume the new
                                    nighttime_interference_cite vocab.
    exports/engineeringReport/sections/executiveSummary.js
    exports/engineeringReport/sections/methodology.js
    exports/engineeringReport/sections/appendices.js
    exports/engineeringReport/sections/_fmReasoning.js
                                    AM Appendix B column header
                                    "§73.187 / §73.190" → "§73.182(k)
                                    / §73.190"; finding cite emission
                                    likewise corrected.
    exports/engineeringReport/sections/populationMethodology.js
    exports/engineeringReport/sections/validationVerdict.js
    exports/engineeringReport/sections/measurements.js
    exports/engineeringReport/sections/assumptions.js
                                    AM Part-73 short-list updated from
                                    "§73.182 / §73.184 / §73.187" to
                                    "§73.182 / §73.184 / §73.185 /
                                    §73.190".
    engine/regulatory/section_73_187.js
                                    Header NAMING NOTE added explaining
                                    the historical engine namespace;
                                    every emitted cite string changed
                                    from "47 CFR §73.187" /
                                    "§73.187(a)" / "§73.187(c)" to
                                    "47 CFR §73.182(k) / §73.190".
                                    Filename + function names retained
                                    to avoid a churn-y rename cascade
                                    across the engine + test surface.
    engine/regulatory/interferenceStudy.js
                                    rules_evaluated for AM corrected
                                    from ['§73.187', '§73.190 (Wang
                                    skywave)'] to ['§73.182(k)',
                                    '§73.190 (Wang skywave)'];
                                    sec187Row cite and qualifying-rule
                                    list likewise.
    engine/regulatory/internationalBorderDetect.js
                                    Both treaty obligations restructured
                                    so the daytime-radiation §73.187
                                    sentence does not sit adjacent to
                                    nighttime/skywave wording.

  Intentionally LEFT AS-IS (correct daytime usage of §73.187):
    exports/lmsFiling/purpose.js     — already cites §73.187 as
                                       "Limitations on daytime radiation"
    exports/lmsFiling/form301am.js   — REFERENCES label + filing-option
                                       enum correctly use §73.187 for
                                       daytime-only / day-and-night basis
    engine/am/groundwave.js          — §73.24(g) blanket + §73.187
                                       international treaty comments are
                                       both daytime-correct
    src/tests/__samples__/wfan.json
    src/tests/__samples__/kdus.json  — PR-AM-GOLDEN notes correctly cite
                                       §73.187 as the international-
                                       treaty (daytime) rule

PRF-008 — AM interference_cite covers both regimes
  In serviceWording.js SERVICE_VOCABULARIES.AM, interference_cite was
  byte-identical to nighttime_cite (both '§73.182(k)'), so the
  day-agnostic COMPLIANT_VIA_ALT_RULE narrative in conclusion.js cited
  the nighttime NIF rule for daytime AM exhibits.  PR-CITE2 splits the
  AM vocabulary:
    daytime_interference_cite   = '§73.187'
    nighttime_interference_cite = '§73.182(k)'
    interference_cite           = '§73.182 / §73.187 (daytime) and
                                   §73.182(k) / §73.190 (nighttime)'
  Nighttime-specific narratives in conclusion.js consume
  vocab.nighttime_interference_cite directly; the generic
  interference_cite (now naming both regimes) is correct under either.

F-006 — Form 349 fill-in language
  exports/lmsFiling/form349.js fill-in note rewritten from "60 dBu
  service contour is within or overlapping primary station's protected
  contour" to "the translator 60 dBµV/m predicted service contour is
  located ENTIRELY WITHIN the primary station protected contour", with
  the §74.1231(i) AM-primary / NCE-analogue distinction and the §74.1232
  non-fill-in eligibility consequence spelled out.  Submission-checklist
  cross-reference at line 323 corrected — Cornell LII verification
  (2026-05-23) confirmed §74.1232(d) is "coverage area restrictions"
  and §74.1232(e) is "financial support prohibitions"; neither is the
  fill-in or AM-cross-service cite Genoa previously claimed.  Updated
  to §74.1201(g) (fill-in) + §74.1231 (eligibility, including §74.1231(i)
  AM-primary) with §74.1232 noted as governing additional ownership
  provisions.

F-007 — peCertification.js header authority
  Replaced the "47 CFR §73.x — Professional Engineer certification block"
  header with the actual regulatory basis: state PE registration boards
  (the licensure source) + §73.1610 (FCC engineering-data demand
  authority) + §73.3539 (application filing requirements).

Citations helper (engine/regulatory/citations.js)
  New canonical catalog binding {rule, caption, subject, verified_at}
  for every FCC rule rendered by Genoa.  Includes AM_NIGHTTIME_BASIS
  and AM_DAYTIME_BASIS cluster constants + citeAmNighttimeShort() /
  citeAmDaytimeShort() / citeAmComplianceShortList() / citationFor()
  helpers.  Goal: any future eCFR change is a one-file edit, never a
  per-surface sweep again.

Tripwire test coverage (src/tests/citationHygiene.test.js — 13 tests, 13 pass)
  Existing F-001 / F-002 / F-003 / F-004 tests retained.  New tests:
    - PRF-008: wordingFor("AM") splits into daytime/nighttime variants;
               FM and LPFM interference_cite unchanged.
    - F-005:   no rendered surface pairs §73.187 with nighttime / skywave
               / NIF wording within a 120-char window.  Engine emits
               §73.182(k) or §73.190 for nighttime narratives.
    - F-006:   form349.js fill-in note states "entirely within" (not
               "within or overlapping"); submission_checklist no longer
               mis-cites §74.1232(d) / (e).
    - F-007:   peCertification.js does not cite "§73.x" and names
               §73.1610 + §73.3539 as the regulatory basis.
    - Repo-wide: no rendered "§73.x" placeholder anywhere under
                 src/engine/** or src/exports/** (belt + suspenders).
  Engine tests amReportSections / section_73_187 / interferenceStudy
  updated to assert the corrected cite strings (each carrying a
  PR-CITE2 comment so future readers understand the rename).

Full npm test: 1411 / 1411 pass (was 1408 / 1411).  Citation-hygiene
suite green.  No RF math changed.  No exhibit numeric value moved.
No deploy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
report polish batch: FM/AM contamination (#1) + signature block (#6)
Two disclaimers were repeating within/across sections on the same page:

1. Regulatory Context printed "Genoa does not determine ... legal
   authorization status" TWICE — once as a standalone note and again at
   the tail of REGULATORY_CONTEXT_DISCLAIMER.  Dropped the redundant
   note; folded its "this assessment is interpretive only" nuance into
   the single authoritative disclaimer.

2. "Genoa does not certify FCC filings.  Final certification is the
   responsibility of the qualified broadcast engineer of record"
   appeared in BOTH the Validation Verdict limitations list and the
   Certification boilerplate — which render on the same page.  Removed
   it from the limitations list; it now lives once, in Certification.

Prose only; no formula/compliance change.  regulatoryContext (8) +
verdict tests pass.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
fix(report): dedupe repeated disclaimers (#3 of polish batch)
Wires the operator's two new AI assets into Genoa as an ADVISORY
self-audit layer:

- services/aiRouter.js — OpenAI-compatible client for the DO inference
  router (router:zerotrust).  Auto-routes per task with cost-aware
  escalation.  Creds from env (MODEL_ACCESS_KEY, INFERENCE_ROUTER_URL);
  no key → no-op, so offline/test runs make zero network calls.
- services/fccKb.js — retrieval client for the FCC Part-73 knowledge
  base (verbatim 47 CFR, incl. the §73.190 skywave formulas).  Creds
  from env (FCC_KB_URL, FCC_KB_TOKEN — note: KB-scoped, distinct from the
  router key, which 403s on the retrieve endpoint).  Degrades to
  ungrounded when unauthorized.
- analysis/exhibitReview.js — builds a compact deterministic snapshot of
  the verdict / conclusion / HAAT / contour surface, grounds it via the
  KB when available, and runs it through the router's engineering-
  exhibit-validation policy to surface INTERNAL CONTRADICTIONS as
  advisory findings.

STRICTLY ADVISORY: never changes contour distances, §73.x compliance,
readiness gates, or any deterministic output.  No consumer is wired into
the synchronous compute path in this commit — reviewExhibit() is a
callable building block (endpoint/agent/flag trigger TBD) so it can't add
latency, cost, or a network dependency to the deterministic engine.

Verified live: the router (→ claude-4.6-sonnet) caught all three
validation-verdict contradictions hand-fixed earlier this session, plus
independently flagged that flat-HAAT-with-varying-distances may
invalidate the interference finding itself.  KB grounding pending a
KB-scoped token (router key returns 403 on retrieve).

Tests: 8 cases, fully mocked (no live calls) — key gating, router
request shape + auth header, non-200 handling, snapshot fields, JSON
finding parse, non-JSON tolerance.

No secrets in source; all creds read from env.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
feat(ai): inference-router + FCC-KB clients + advisory exhibit-review
…en absent)

Adds a second retrieval backend to fccKb: when no KB-scoped/agent token
is configured (FCC_KB_URL+FCC_KB_TOKEN), but OpenSearch creds are
(FCC_KB_OS_HOST/PORT/USER/PASS), retrieve() queries the KB's managed
OpenSearch cluster directly with BM25 text search.

Self-discovering so it doesn't hard-code DO's internal index schema:
  - pickKbIndex() lists indices, prefers the one carrying the KB-id
    fragment, else a knowledge/chunk/doc-named index, else first
    non-system index.
  - hitsToChunks() tolerates the chunk-body field-name variation
    (content/text/chunk/body/passage/page_content).
  - simple_query_string over fields:['*'] — keyword grounding without an
    embedding model.
  - TLS via node:https with rejectUnauthorized:false (managed-DB cert;
    matches ZTR's existing PG_SSL_REJECT_UNAUTHORIZED posture).

DEPLOYMENT NOTE (not yet operable): the genai-goldfish OpenSearch lives
in TOR1 / VPC default-tor1, while the Genoa+ZTR apps run in NYC.  DO
VPCs are region-scoped, so neither the NYC apps nor an external sandbox
can reach :25060 until the cluster's Network Access (Trusted Sources)
is opened to the apps' public egress.  The DO Agent endpoint
(v72…agents.do-ai.run + an endpoint access key) avoids this entirely
and remains the recommended grounding path.

Tests: +4 (pickKbIndex selection, hitsToChunks field tolerance, backend
selection, isEnabled either-backend) — 12 total, fully mocked.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
…uth)

The kbaas retrieve endpoint 403s with a model-access key and expects
{query, num_results, alpha, filters?} — not the legacy {query, k}.
Send the real contract, document that FCC_KB_TOKEN is a DO API PAT
(full-account scope; prefer OpenSearch-direct from inside the VPC),
and broaden response-chunk field tolerance. Adds a test locking the
request body + bearer.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
feat(ai): OpenSearch-direct KB backend + cross-region deployment note
…port

Runs the router-based advisory pass (exhibitReview) fail-soft in the PDF
and TXT export paths plus the async job runner, and renders the findings
as a clearly-marked ADVISORY section after the certification seal. No-op
without MODEL_ACCESS_KEY, so offline/un-keyed renders are unchanged.
Strictly advisory: never modifies any computed contour, HAAT, spacing,
or §73.x compliance value. Auto-upgrades to grounded once KB retrieval
is available. +4 tests.

https://claude.ai/code/session_01GMaGqfzFdcHvKTMmEjPKvz
feat(report): advisory AI exhibit-consistency review section
# Conflicts:
#	agents/gis-terrain-scientist/agent.md
#	genoa/src/exports/engineeringReport/sections/validationVerdict.js
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants