April 29, 2026
ANTAQ vessel-call cargo enrichment unblocked, Explorer score robustness badge, named portfolios with composite scoring, and Business Licenses dashboard.
Brazilian ANTAQ vessel-call cargo enrichment unblocked
The Brazilian ANTAQ vessel-call feed was carrying 1,902 stored rows with cargo_tons = NULL on every one — the Carga.zip enrichment pass that fills tonnage on top of the base call records had been silently failing for weeks, leaving the cargo validation pipeline with no Brazilian ground-truth to match against. Three concurrent issues were stacking on top of each other: the upsert path was set to ignoreDuplicates, so even when Carga.zip later succeeded the existing rows could never be patched with cargo tonnage; Carga.zip fetch failures were going to a console.warn instead of ingestion_logs.error, hiding the missing-cargo cause from operators and from the status page; and a scoping bug in the axiom_events emission threw a ReferenceError whenever bulkArrivals > 0, which was being swallowed by the outer try/catch.
All three are fixed in one pass — the upsert now patches existing rows, archive fetch failures surface as real ingestion errors, and the events emission collapses two .map passes into one so the scoping issue can't recur. Cargo tonnage will flow into the validation pipeline as soon as the upstream Carga.zip archive is reachable. The current web3.antaq.gov.br/ea/txt/... URL pattern is returning 404 across all years; that is being tracked separately as a source-migration ticket and any future fetch failure will surface on the status page instead of disappearing into a console warning. No action is required on your part.
Score robustness badge on the Explorer cell-detail panel
The Explorer cell-detail panel now surfaces a score robustness badge that tells you, at a glance, whether a cell's composite score survives a change in grid resolution. Each cell is classified as stable_core (score is consistent across the H3 r7/r8 grid pair — a "consensus core" cell), ambiguity_shell (score swings meaningfully between resolutions, so the read is grid-sensitive), or stable_non_signal (consistently low signal across grids), with a per-cell support_stability value behind the bucket. The classification is derived from the MAUP H3 ensemble stress test and is intended as an epistemic-uncertainty signal: a cell scoring 78 in the consensus core is a different read than a cell scoring 78 in the ambiguity shell, even when the headline composite matches. Cells that haven't been re-classified yet render no badge — no fabricated default. The classifier re-runs after every material scoring-engine change.
The same fields are exposed on the cell-detail RPC alongside the existing per-cell deltas, percentile rank, demographics, and peer-comparison payloads, so programmatic consumers can read the robustness verdict without an extra round-trip. No action is required on your part — the badge populates as the classifier walks the platform, and the score-panel layout is unchanged for cells without a classification.
score_history daily writes unblocked — /api/score-trends and cell sparklines now show real time-series
Daily snapshots written to the score_history table by the Railway scorer had been silently failing for roughly a month — every row's gentrification_stage column was being upserted into a table that never had that column, PostgREST returned 42703 on every write, and a non-blocking error handler was swallowing the rejection without surfacing it. cell_scores continued to update on the normal cadence so single-point composite scores stayed correct, but every score_history-backed surface was reading off a frozen 354-row snapshot from late March: GET /api/score-trends returned the same metro and per-cell trend payloads day after day, the Explorer cell-detail score sparkline rendered the same 90-day trajectory regardless of when it was opened, and the axiom_get_score_trends MCP tool returned stale rows.
The fix lands the missing column on score_history and replaces the silent error swallow with an explicit log so any future schema drift surfaces in the next scorer run instead of going unnoticed for weeks. Score computation itself is still not blocked on score_history failures — the daily snapshot is a side effect, not a precondition for cell_scores being current — so a future history-write regression will keep current scores flowing while operators are alerted to the broken time-series. Starting from the next nightly scorer pass, /api/score-trends, the cell-detail sparkline, and the MCP axiom_get_score_trends tool begin accumulating fresh daily rows again. Cells that were scored during the outage window are absent from the time-series for those days; the gap closes as new daily snapshots land.
If you consume /api/score-trends directly, expect trend[] (metro mode) and snapshots[] (per-cell mode) to start lengthening by one new snapshot_date per day rather than reporting the same dates indefinitely. No action is required on your part — the response shape is unchanged.
Saved searches and one-click "+ Portfolio" on /discover
The Locus discover surface now lets signed-in users persist their filter set as a named saved search and add any result row to a portfolio in a single click. A Save search button next to Export CSV captures the active multi-metro, range, signal-slider, and sort criteria as a JSON blob and writes it to the per-user saved_searches table. Each row links back to /discover?saved=<id> so re-opening a search re-hydrates its filters with no copy-paste. A new Saved Searches panel on the dashboard surfaces the six most recent searches as one-click jump points.
Every result row also gains a per-row + Portfolio action that adds the cell to your portfolio. If you don't have a portfolio yet, the action creates one inline; if you have several, you pick which one to add the cell to.
Three new endpoints back the surface, all session-authenticated:
GET /api/saved-searches— list your saved searches, most recent first.POST /api/saved-searches— create a search with a name and filter blob (8 KB cap, names unique per user, case-insensitive).DELETE /api/saved-searches?id=<id>— remove a saved search you own.
Anonymous visitors see /discover unchanged — Save search, + Portfolio, and the dashboard panel only render for signed-in accounts. Owner-only row-level security on saved_searches enforces access at the database layer; every API also re-asserts user_id on each query as defense in depth. No action is required on your part.
Named portfolios with composite scoring and CSV upload
Locus now supports named portfolios — multiple, independently-named collections of monitored locations under a single account, each with its own composite score, coverage counts, and refresh cadence. Manage them from the new /dashboard/portfolios list page (composite score, member count, above/below-70 split, and last-refresh age per row) and the per-portfolio detail page that drills into the member list. Legacy single-list monitors are unaffected — the new monitored_locations.portfolio_id foreign key is nullable, so locations created before today continue to surface on the dashboard exactly as before, and a portfolio deletion sends its members back to the unassigned monitor list rather than removing them.
The composite portfolio score is the rounded arithmetic mean of every member's most recent last_score and is exposed alongside coverage counters (member_count, scored_count, above_threshold_count for cells at or above 70, below_threshold_count for scored cells under 70) and last_refreshed_at through a new get_portfolio_summary(portfolio_id) RPC. Equal-weight is the deliberate baseline until a weighting dimension is specified — it matches how the flat-monitors dashboard already surfaces avg_score and avoids implying a precision the ingest path does not yet support.
Four new endpoints back the surface, all session-authenticated:
GET /api/portfoliosandPOST /api/portfolios— list owned portfolios with member counts, or create a new one (names are unique per user, case-insensitive, up to 120 characters).GET /api/portfolios/{id},PATCH /api/portfolios/{id},DELETE /api/portfolios/{id}— load summary + members, rename, or drop a portfolio.POST /api/portfolios/{id}/membersandDELETE /api/portfolios/{id}/members?member_id=…— add a single{name, lat, lng}location or up to 500 in one bulk request, or remove one by id. Server-sidelatLngToCellat H3 resolution 8 fillsh3_indexfor you.POST /api/portfolios/{id}/refresh— bulk-refreshes every member'slast_scoreby re-pulling the latestcell_scores.compositefor the memberh3_indexset in a singleINquery against thegeneralprofile. Cells the nightly scorer has not yet covered remain atNULLand are reported in the response asrefreshed(touched) vs.scored(had a fresh value).POST /api/portfolios/{id}/upload— bulk CSV import. Acceptstext/csvdirectly orapplication/json {csv: "..."}so the UI can post a textarea paste withoutFormData. Header detection pickslat,lng(orlatitude,longitude) when present; otherwise it expects anaddresscolumn and geocodes via the Census one-line geocoder with a 4-way concurrent worker pool and a 6-second per-row timeout. Each upload is capped at 500 rows, and the response returns a per-row{rowIndex, status: "inserted" | "geocode_failed" | "invalid", reason}array so the UI can render a result table for partial failures.
# Create a portfolio
curl -X POST https://app.axiomlocus.io/api/portfolios \
-H "Content-Type: application/json" \
--cookie "$LOCUS_SESSION" \
-d '{"name": "Sunbelt QSR Sites"}'
# Bulk CSV upload (lat/lng or address)
curl -X POST https://app.axiomlocus.io/api/portfolios/<id>/upload \
-H "Content-Type: text/csv" \
--cookie "$LOCUS_SESSION" \
--data-binary $'name,address\nDowntown Phoenix,100 N Central Ave Phoenix AZ\nMidtown Atlanta,1100 Peachtree St NE Atlanta GA'
# Refresh scores for every member in one round-trip
curl -X POST https://app.axiomlocus.io/api/portfolios/<id>/refresh \
--cookie "$LOCUS_SESSION"Row-level security on portfolios enforces owner-only access at the database layer, and every API also re-asserts user_id on each query as defense in depth. No action is required if you only consume Locus data through the public, versioned /api/v1/locus/portfolios reference — that surface is unchanged. The new dashboard endpoints are additive and complement the saved=true portfolio export shipped alongside this release.
Business Licenses dashboard with metro-spanning search and CSV export
A new /business-licenses surface lands in the Locus app today, providing a record-by-record search over the normalized business-license catalog across 15 U.S. metros (Chicago, Philadelphia, NYC, Denver, Austin, Portland, Atlanta, Los Angeles, San Francisco, Seattle, Houston, Phoenix, Las Vegas, Miami, Boston). Filter by metro, address substring, NAICS prefix, and pioneer-tier classification (Pioneer / Advanced / Mature / Dormant / None); sort by source_loaded_at, occurred_at, or metro_slug; and click the maps icon on any row with coordinates to deep-link into Google Maps. Active filters reset pagination automatically, and a one-click Reset filters link clears all active criteria.
The page is a thin client over a single new endpoint, GET /api/business-licenses/search, which paginates at 50 per page in the UI and caps at 200 per call via the API. Export CSV in the filter bar downloads the currently visible page as business-licenses.csv with Source ID, Metro, Address, NAICS, Pioneer Tier, Loaded At, Close Date columns, honoring active filters and sort. The endpoint requires an authenticated Locus session and returns 401 Unauthorized otherwise. See the Business licenses reference for the full filter, column, and API parameter table.
ZBA Decisions dashboard launches with NYC and Philadelphia coverage
A new /zba-decisions surface in the Locus app makes Zoning Board of Appeals decisions searchable across four jurisdictions: Chicago, Boston, NYC, and Philadelphia. NYC ingestion runs against the city's Socrata open-data portal and Philadelphia against OpenDataPhilly's Carto API, both as dedicated upstream collectors with date-column and field-mapping configurations matched to each portal — so refresh cadence tracks the underlying source (typically daily for NYC, several times per week for Philly). Chicago and Boston continue on the existing structured + PDF-decision parsers.
Filters cover jurisdictions (multi-select), case number / address combined search (single input matched against either column), outcome (Approved, Denied, Granted, Granted with Conditions, Pending, Withdrawn), variance-type substring, and an inclusive decision_date range. Sorting defaults to decision_date descending so the most recent rulings lead. Each row's external-link icon opens the original portal record or PDF in a new tab when a source_url is present. Export CSV writes Case Number, Jurisdiction, Address, Variance Type, Outcome, Decision Date, Conditions (conditions truncated to 200 characters) for the visible page.
The page is backed by GET /api/zba-decisions/search. The endpoint requires an authenticated Locus session and returns 401 Unauthorized otherwise. See the ZBA decisions reference for the full filter, column, and API parameter table.
COLREGS compliance scoring with counterfactual deficit
Every non-compliant pairwise encounter now lands a row in a new colregs_encounters table with a per-encounter compliance score. The principal field is compliance_deficit_nm — a counterfactual lateral distance between the give-way vessel's actual position at CPA and where it would have been under the minimum-effort compliant maneuver. A vessel that altered some-but-not-enough has a smaller deficit than one that didn't alter at all, so the score rewards attempted compliance rather than just successful outcomes. Compliant encounters (compliance_deficit_nm <= 0.05 NM) are silently dropped so the table stays a worklist of failures rather than a scoreboard of every encounter.
Each row also resolves the canonical encounter_type (head_on / overtaking / crossing / mixed), the give_way_imo and stand_on_imo (NULL when role probabilities are within a 0.10 ambiguity band), the required_starboard_deg minimum alteration the give-way vessel should have made, and both vessels' actual_starboard_deg_* so head-on (Rule 14: both alter starboard) is fully captured. The safe DCPA threshold the solver targets is contextual — 0.5 NM open sea, 0.2 NM channel/TSS/approach, 0.1 NM anchorage — and is recorded per row as safe_dcpa_threshold_nm.
A new score-colregs-compliance Edge Function runs hourly at minute :17 (ten minutes after the encounter extractor), with a primary key on encounter_id so re-runs are idempotent. A backfill driver, scripts/backfill-colregs.mjs, fills any historical window in 6-hour chunks. The schema, algorithm, query patterns, and current limitations — ghost_inference_vector is reserved for a follow-up that needs a maneuver-prediction model, and pairwise_encounter.context_tag is not yet populated, so every encounter currently falls back to the open_sea 0.5 NM threshold — are documented in the new COLREGS compliance scoring section.
The deficit is intended for analyst triage and forensic ranking — it is not a calibrated COLREGS adjudication, and a non-zero deficit is not a finding of fault. No action is required on your part — the Risk and Investigations APIs continue on the same schema; the new table is additive.
Rule 17 handoff timestamp on pairwise encounters
Every row written to pairwise_encounter by the pairwise encounter extractor now carries two new fields: rule17_handoff_ts (the timestamp at which Rule 17(a)'s "keep course and speed" obligation transitioned to Rule 17(b)/(c)'s "may / must take avoiding action") and rule17_handoff_trigger (which gate fired — giveway_inaction or extremis). Surfacing T* separately from the Rule 17 deviation flags is the operational disambiguation between premature, unnecessary deviation (the stand-on vessel broke course before authority transferred) and required avoidance (the stand-on vessel was already authorised, or compelled, to act).
Two deterministic, geometry-only triggers fire T* on the canonical give-way side (whichever vessel has the higher mean give_way_prob across the encounter epochs), with the earliest match winning: giveway_inaction requires risk_prob > 0.70 and dcpa_nm < 0.5 and the give-way vessel's max single-step COG change over the trailing 120-second action window below 5° (Rule 17(b) authority because the give-way vessel is observably failing to keep clear); extremis short-circuits when dcpa_nm < 0.1 (≈200 m) regardless of give-way behaviour (Rule 17(c) compels stand-on action because geometry has already collapsed). The thresholds are exported from @axiom/core/processing/encounter-extraction as RULE17B_RISK_PROB_THRESHOLD, RULE17B_DCPA_THRESHOLD_NM, RULE17B_ACTION_WINDOW_S, RULE17B_GIVEWAY_ACTION_TOL_DEG, and RULE17B_EXTREMIS_DCPA_NM so analyst tooling can probe with non-default values without forking the algorithm.
Combine the handoff timestamp with the deviation flags to isolate the most analytically interesting cells — rule17_handoff_ts IS NOT NULL AND rule17_deviation_* true marks required avoidance after authority transferred; rule17_handoff_ts IS NULL AND rule17_deviation_* true flags potential premature deviation. A partial index on rule17_handoff_ts DESC keeps the analyst worklist query "show me encounters where Rule 17(b)/(c) authority transferred recently" cheap at production fleet size. Both columns ship NULL when no trigger fires.
The columns ship as additive nullable defaults on pairwise_encounter, so there is no migration on your end. Pre/post-handoff deviation magnitudes, a continuous handoff confidence score, per-port and per-corridor handoff-rate rollups, nav-status priors, and TSS / narrow-channel context overrides are deliberately deferred to Phase 2. See the new Rule 17 handoff timestamp section for the full algorithm, query patterns, and current limitations. No action is required on your part — the Risk and Investigations APIs continue on the same schema; the columns populate from the next hourly run forward.
Rule 17 deviation flags on pairwise encounters
Every row written to pairwise_encounter by the pairwise encounter extractor now carries two new pairs of columns: max_course_change_{a,b}_deg (largest single-step COG delta observed for each vessel across the encounter, in degrees) and rule17_deviation_{a,b} (boolean). The boolean fires when both gates hold for that vessel: the max course change is at least 10° (a meaningful maneuver, not AIS jitter) and its mean stand-on probability across the encounter epochs is at least 0.6 (it was the stand-on side often enough that staying on course was the legal expectation). The combination is what makes the flag specifically a Rule 17 signal rather than a generic "vessel turned" indicator — Rule 17(a)(i) requires the stand-on vessel to keep her course and speed, so a stand-on vessel that maneuvers materially is typically reacting to a give-way vessel that failed to keep clear.
A partial index over start_ts DESC filtered on rule17_deviation_a OR rule17_deviation_b keeps the analyst worklist query "show me encounters where a stand-on vessel was forced into action recently" cheap at production fleet size. Both thresholds are exported from @axiom/core/processing/encounter-extraction as RULE17_MIN_COURSE_CHANGE_DEG and RULE17_MIN_MEAN_STAND_ON_PROB so analyst tooling can probe with non-defaults without forking the algorithm. The flags are deterministic and geometry-only — they prioritise which encounters to send to manual COLREGS adjudication, they don't replace it.
The columns ship as additive nullable defaults on pairwise_encounter, so there is no migration on your end. Counterfactual compliance distance and ghost-encounter back-projection — the rest of the Rule 17 evidence pipeline — are deliberately deferred to a follow-up phase that needs a maneuver-prediction model. See the new Rule 17 deviation detection section for the full algorithm, query patterns, and current limitations. No action is required on your part — the Risk and Investigations APIs continue on the same schema; the flags populate from the next hourly run forward.
Encounter epochs now carry COLREGS rule and role posteriors
Every row written to encounter_epoch by the pairwise encounter extractor is now annotated with COLREGS-aligned rule posteriors (p_head_on, p_overtaking, p_crossing) and per-vessel role posteriors (give_way_prob_a, stand_on_prob_a, give_way_prob_b, stand_on_prob_b). The rule posteriors are normalized to sum to 1 and are derived from epoch geometry alone — reciprocal-course and bow-aligned gates for head-on, parallel-course plus one-forward-one-aft bearings (Rule 13's "more than 22.5° abaft the beam") for overtaking, and the residual mass for crossing. Role posteriors then condition on the rule posteriors: head-on splits 50/50 (Rule 14, no stand-on priority), the overtaker is give-way, and crossing assigns give-way to the vessel with the other on its starboard side. Clean head-on, overtaking, and crossing geometry each push the corresponding rule posterior past 0.85; ambiguous geometry produces a soft mixture instead of a brittle vote.
The columns themselves shipped as nullable on the prior schema rollout, so there is no migration on your end — rows that previously held NULL will populate from the next hourly run forward, and any consumer that already reads them gets non-null values without code changes. p_special_context (TSS / narrow-channel / RAM context) remains NULL for now; it requires TSS polygon ingestion and a vessels.nav_status join that have not yet landed, and will populate without a schema change when they do. See the new Rule and role posterior inference section for the full geometric gates and edge cases. No action is required on your part — the Risk and Investigations APIs continue on the same schema.
Pairwise encounter extraction now runs hourly in production
Pairwise encounter extraction — the algorithm that derives vessel-to-vessel CPA, TCPA, range, closing speed, and bearing-rate geometry from raw AIS positions — now runs as a scheduled Edge Function rather than an ad-hoc invocation against @axiom/core. The new extract-encounters-hourly cron job fires at minute :07 of every hour, pulls the trailing 90 minutes of ais_positions (a 30-minute overlap with the prior run so encounters straddling the hour boundary are captured end-to-end), generates candidate vessel pairs via H3 resolution-6 spatial buckets with 1-ring neighbour expansion, and upserts results into pairwise_encounter (keyed on vessel_a_id, vessel_b_id, start_ts) and encounter_epoch (keyed on encounter_id, ts_utc). Stable upsert keys mean re-running the function over the same window is fully idempotent.
A safety cap aborts any run that generates more than 50,000 candidate pairs with a pair_explosion error rather than letting a pathological window run away. Each invocation also writes an ingestion_logs row with position counts, pairs evaluated, encounters and epochs produced, and the window — visible alongside the rest of the ingestion observability stream on the status page.
For backfills and re-pulls, a new scripts/backfill-encounters.mjs driver chunks a since → until range into fixed-length windows (default 60 minutes) and POSTs each chunk to the Edge Function in sequence; a 7-day backfill completes in roughly 10–20 minutes depending on position density. The Edge Function also accepts ad-hoc since / until overrides on POST so a single window can be re-processed directly with curl. See the new Production pipeline section for invocation examples and the spatial pair-generation strategy. No action is required on your part — encounter-derived data on the Risk and Investigations APIs continues on the same schema; the change shows up as fresher coverage and complete historical backfills.
Portfolio-level CSV/JSON export via saved=true
GET /api/export now accepts saved=true to scope the export to the caller's saved cells — the cells in their monitored_locations portfolio — instead of a full-metro or platform-wide pull. The filter joins to monitored_locations on the authenticated user, collects the h3_index list, and applies it as an IN filter against cell_scores so the result respects the existing composite-descending order and limit cap. It composes with metro (e.g. saved=true&metro=sf returns only saved cells inside SF) and with format=csv or format=json. Users with an empty portfolio get a 200 OK empty payload ({ "total": 0, "data": [] } for JSON, an empty body for CSV) rather than a full-table scan. The download filename is tagged saved (for example, axiom-locus-saved-2026-04-29.csv) so portfolio exports are easy to identify alongside metro pulls on disk.
The endpoint continues to require Pro or Team plan entitlement (bulk_export); no schema or auth changes on your end. See the updated /api/export reference for the full parameter table and example invocations.
Per-signal freshness chips on the Explorer's score panel
The cell-detail score panel in the Explorer now surfaces a last-refreshed chip on every row of the signal-contribution waterfall, plus a matching chip beside the score-panel section header. The chip renders a relative age (now, 5m, 2h, 3d, 5w) and is color-coded against the score-refresh SLA — dim under 72 hours, amber from 72 to 168 hours, red beyond a week — so a stale composite score is visually impossible to miss when you're drilling into the waterfall. Hover any chip to see the absolute refresh timestamp and its readable status (for example, Last refreshed Apr 27, 2026, 9:14 AM · aging (>3 days)).
All eight signal groups currently share a single refresh snapshot — score history updates atomically per cell, so each per-row chip on the same cell shows the same age. The chips repeat per row deliberately: it makes freshness un-missable when scanning the waterfall, and reserves the slot for a future per-signal differential refresh without re-laying-out the panel. The coarser cell-level staleness tier (prime / stale / unscored) in the score-panel footer is a separate, longer-window annotation and is unchanged. See Score freshness chips for the full color-tone table. No action is required on your part.
safetyEnvironment now tracks year-over-year 311 complaint trends
The safetyEnvironment signal group on /api/score now incorporates a new Complaint Trend YoY sub-score that tracks whether 311 complaint density in a cell is rising or falling against the same 30-day window one year ago. The sub-score is computed in-app from the service_requests_311 rows the scorer already pulls for density and resolution time — counting complaints in the trailing 30 days, counting complaints in the matching 30-day window 335–395 days prior, and emitting the YoY delta (current − prior) / max(prior, 1) capped at +5×. Negative YoY (complaints down) lifts the cell's safety score; positive YoY (complaints up) penalizes it, with values saturating once the rate triples. Rising 311 density is treated as a Pioneer Stage 1 signal — the same complaint-acceleration pattern that typically precedes pioneer-business clustering by 12–24 months and feeds the Pioneer Signal cascade.
Complaint Trend YoY contributes 5% of the safetyEnvironment group score and surfaces in API responses as a subScores entry with name: "Complaint Trend YoY" and source: "AXL-107 (311 YoY)". Cells where both the current and prior 30-day windows are empty omit the sub-score and surface AXL-107 (311 YoY) under sourcesMissing instead of emitting a misleading "improving" signal from a quiet cell. Read it alongside the existing Complaint Density and Complaint Velocity sub-scores — density is loudness now, velocity is the 30-day change, YoY is the year-on-year direction. See Complaint Trend YoY for the full normalization curve. No action is required on your part — composite and group scores remain on the same 0–100 scale.
developmentPipeline now weights permits by scope and cost tier
The developmentPipeline signal group on /api/score now incorporates a new Permit Scope Quality sub-score that weights every recent building permit by what the permit is actually for, not just whether it was issued. Each permit's LLM-extracted scope_type (new_construction, addition, demolition, renovation, repair) and estimated_cost_tier are combined into a 0–100 contribution, and the cell's sub-score is the average across the trailing 6-month window. New construction in the highest cost tier trends toward 100; cells dominated by low-cost repairs trend toward 0. The signal is designed to separate cells where permits represent genuine new development from cells where permits mostly reflect maintenance churn — two cells with identical permit counts can now diverge meaningfully on this dimension.
Permit Scope Quality contributes 10% of the developmentPipeline group score and surfaces in API responses as a subScores entry with name: "Permit Scope Quality" and source: "AXL-108". Cells where the extractor has not yet annotated any permit will see AXL-108 listed under the group's sourcesMissing instead. See Development pipeline sub-scores for the full weighting table. No action is required on your part — composite and group scores remain on the same 0–100 scale, and developmentPipeline's 0.20 weight in the general profile is unchanged.
Cell score history writes restored, sparkline and /api/score-trends flowing again
The /api/score-trends endpoint and the 90-day trajectory sparkline on the Explorer's selected-cell panel had been reading from a frozen time-series for the past month. While cell_scores continued to refresh daily, the score_history table (the snapshot store the sparkline and trends API both read from) had been stalled at the same rowset since late March — so cell-detail sparklines were quietly falling back to their synthetic flat-trend stand-in (the dashed 90-day trajectory (estimated) indicator), and /api/score-trends was returning the same rolling window rather than tracking actual day-to-day movement.
Root cause was a schema drift between cell_scores and score_history: the daily scorer was upserting a gentrification_stage column that only existed on cell_scores, so every history upsert was being rejected by PostgREST and silently swallowed by an empty error handler. The fix is two-part — a migration adds gentrification_stage to score_history so the upsert now lands, and the silent error handler is replaced with explicit logging so the next schema or payload drift surfaces immediately instead of disappearing for a month. Cell-level scoring was never affected; the daily snapshot is a side effect of the scorer rather than a precondition for cell_scores being current.
Real time-series data starts populating from the next scorer run forward for any cell visited that day. Cells that haven't been re-scored since the freeze will pick up history as the nightly job revisits them. No action is required on your part — the affected sparkline rendered its dashed (estimated) fallback during the freeze, so consumers of the /api/score-trends endpoint and the cell-detail panel will see the trajectory line transition from estimated to real measurements as new snapshots accumulate.
Orphaned ingestion_logs rows now finalize within 5 minutes
Edge Functions on Supabase write status = 'running' to ingestion_logs on entry and UPDATE the same row on exit. If a function dies mid-execution — Deno panic, OOM, the platform's 150 s / 400 s wall-clock cap, or the catch-handler UPDATE itself failing — the row is permanently stuck with status = 'running' and completed_at = NULL. Audit during the recent silent-failure verification window found a sizeable backlog of these orphans across four sources (with one upstream alone accounting for the bulk of them), some 12+ minutes old. They poison the silent-failure detector — the detector filters by completed_at > NOW() - INTERVAL '24h', so orphans never enter the window, and a function that only orphans (never writes a terminal status) appears to have zero runs at all.
A new pg_cron job, sweep-orphaned-ingestion-logs, now runs every five minutes. It calls public.sweep_orphaned_ingestion_logs(), which finds rows with status = 'running' AND started_at < NOW() - INTERVAL '5 minutes' (well beyond the Edge Functions wall-clock cap, so any older running row is by definition abnormally terminated) and finalizes them as status = 'failed' with error = 'orphaned by sweeper — function exited without writing terminal status'. Using 'failed' rather than introducing a new 'orphaned' status keeps the existing silent-failure detector and operator dashboards working without changes — orphaned runs now flow into the same failed-count path as any other terminal failure, so an Edge Function that only orphans will surface as a real outage instead of looking dormant.
If you query ingestion_logs directly to drive an external dashboard or alerting rule, expect to see the sweeper-finalized rows alongside organically-failed runs; you can identify them by the 'orphaned by sweeper' substring in error. No action is required if you only consume Overwatch data through the public APIs.
Sanctioned-aircraft seed expanded beyond Mahan Air
The curated aircraft_identities seed that drives Overwatch's aircraft tracking surface — hourly ADS-B ingestion for SDN-listed airframes, joined to high- and critical-tier dark vessel events for cross-modal evasion leads — now covers four additional sanctioned operators alongside the original Mahan Air fleet. Twelve new airframes have been added: Qeshm Fars Air (×2, IRGC-QF cargo, OFAC 2019 designation), Pouya Air (×2, the rebranded Yas Air, EO 13382 NPWMD), Air Koryo (×4, the DPRK state airline, UN 1718 + OFAC + EU), and Cham Wings / Fly Cham (×4, Syria SDN airframes that survived the July 2025 Syria revocation; the June 2025 rebrand to "Fly Cham" is itself an evasion-tracking signal). Every ICAO 24-bit hex was verified against a live Flightradar24 airframe page rather than recalled, and each row's source_uri points to the OFAC press release, Iran Watch entry, OpenSanctions record, or ARIJ investigation that establishes the SDN-to-airframe link.
If you read the public aircraft_latest_positions view or the sanctioned_aircraft_near_dark_event_ports cross-modal lead view, expect more rows once the next ADS-B poll cycle lands fixes for the new airframes — schema is unchanged, the only difference is broader coverage. The same RLS posture applies (read-all for anon and authenticated, service-role writes only). A defensive normalization also lowercases any icao_hex values that survived earlier deploys with uppercase characters, so primary-key joins against aircraft_positions no longer drift on operators that re-registered tail numbers. No action is required on your part.
Refreshed homepage stats on axiomoverwatch.io
The hero, platform, and intelligence pages on axiomoverwatch.io now report current production figures instead of last quarter's drift. The hero counter steps up to 31.5M AIS positions, 41,000+ tracked vessels, 369K port events, and 26K dark events; the /platform and /intelligence pages have been updated to match, and the page-level SEO metadata and SoftwareApplication JSON-LD now embed the same numbers so search results and social previews stop quoting stale counts. No action is required on your part.
AISHub bulk ingestion no longer drops entire batches in dense corridors
The AISHub poller that backstops the AIS coverage feed was occasionally losing whole cluster fetches in the busiest geographies — most reliably NW Europe / Channel (around 1,800 vessels per fire) and Strait of Hormuz / N Gulf under contention with the AISStream worker and APRS backfill. The ais_positions table carries 12 indexes, and a single bulk insert of that many rows ran the index-update fanout past the database's 60-second statement timeout, so the entire batch was rolled back, the status page Data pipeline indicator drifted toward degraded during peak hours, and the matching ingestion_logs row for the run showed status = 'failed' with everything fetched but nothing stored.
The poller now writes both the foreign-key-prerequisite vessels upsert and the ais_positions insert in chunks — 200 rows per upsert and 150 rows per insert — with a 50 ms breathing pause between chunks and a one-shot retry per chunk on a 200 ms backoff. Vessels and positions are sized differently for two reasons: ON CONFLICT upserts are heavier per row than plain inserts, and ais_positions carries 12 indexes versus fewer on vessels, so the index-update fanout per chunk is roughly 12× larger. ais_positions also contends continuously with the AISStream worker for the same index pages, which pushed the original 250-row positions chunk past the per-request timeout in the densest corridors when contention was elevated. The current 150-row positions chunk lands with a comfortable safety margin, and the per-chunk retry absorbs transient lock contention without operator involvement. Each individual statement now finishes well under the per-request timeout budget (the actual ceiling, ~10 s on PostgREST's authenticator role, is tighter than the 60 s database-level statement timeout an earlier pass had assumed), and concurrent writers no longer fight for a single 30+ second exclusive lock on the index pages. Per-chunk failures are non-fatal — the next minute's poll picks up the same vessels again, so any loss is bounded. The ingestion_logs.status column gained a new value, 'partial', used when at least one chunk landed but at least one other chunk failed; 'failed' is now reserved for the case where every chunk fails. Chunk-failure counts are exposed per side of the pipeline as metadata.chunks_failed (positions insert) and metadata.vessel_chunks_failed (vessels upsert), and the silent-failure detector picks up partial runs through those keys.
If you query ingestion_logs directly — for example, to drive an external dashboard or an alerting rule — handle 'partial' alongside 'success' and 'failed' (a partial run still wrote real data) and read both metadata.chunks_failed and metadata.vessel_chunks_failed if you want to count dropped chunks. No action is required if you only consume AIS data through the vessels or density APIs — the change shows up as more consistent coverage and fewer gaps in corridor clusters.