From 691be39c1b466666b5a2f757b12c1d8c8bcb5226 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:38:06 -0400 Subject: [PATCH 01/13] fix(tools): stop cancelled calls before execution and discard late results --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 5 +++ src/tools/catalog-cancellation.test.mjs | 41 +++++++++++++++++++++++++ src/tools/catalog.js | 2 ++ 4 files changed, 50 insertions(+) create mode 100644 src/tools/catalog-cancellation.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acdedf6b..33ebae897 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. +- Cancelled tool calls refuse actions before execution and discard results that settle after cancellation. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c77d1d8e3..47ce15815 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5310,3 +5310,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(tools): stop cancelled calls before execution and discard late results + +Check cancellation before invoking the tool and after it resolves, including nested calls that share the same signal. This does not promise rollback of an action already completed. diff --git a/src/tools/catalog-cancellation.test.mjs b/src/tools/catalog-cancellation.test.mjs new file mode 100644 index 000000000..a374bd71e --- /dev/null +++ b/src/tools/catalog-cancellation.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { composeCatalog, defineTool } from './catalog.js'; +test('cancelled catalog calls cannot execute or return late results', async () => { + let calls = 0, + release, + block = false; + const action = defineTool({ + name: 'fixture_action', + kind: 'action', + title: 'Fixture', + description: 'Offline fixture', + inputSchema: { type: 'object' }, + run: async () => { + calls++; + return block + ? new Promise((r) => (release = r)) + : { summary: 'fixture', data: {} }; + }, + }); + const catalog = composeCatalog({ tools: [action] }); + const c = new AbortController(); + c.abort(); + await assert.rejects( + catalog.call('fixture_action', {}, { signal: c.signal }), + { name: 'AbortError' }, + ); + assert.equal(calls, 0); + block = true; + const running = new AbortController(); + const pending = catalog.call( + 'fixture_action', + {}, + { signal: running.signal }, + ); + while (!release) await Promise.resolve(); + running.abort(); + release({ summary: 'late', data: {} }); + await assert.rejects(pending, { name: 'AbortError' }); + assert.equal(calls, 1); +}); diff --git a/src/tools/catalog.js b/src/tools/catalog.js index 4d5106b6e..6faf82ce5 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -178,6 +178,7 @@ export function composeCatalog({ return chain({ tool, args, signal, ...(parent ? { parent } : {}) }); }; const execute = async ({ tool, args, signal }) => { + signal?.throwIfAborted(); const problems = validateValue(tool.inputSchema, args); if (problems.length) throw new ToolError('invalid_arguments', problems.join('; ')); @@ -194,6 +195,7 @@ export function composeCatalog({ } catch (error) { throw fromSourceError(error); } + signal?.throwIfAborted(); if (!result || typeof result.summary !== 'string' || !result.data) throw new TypeError(`${tool.name} returned no summary or data`); if ( From 0796efe603663237b8a4f2f3a7ac50d59e727d8c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:12:02 -0400 Subject: [PATCH 02/13] docs: address review scope and feature placement for #1031 --- docs/CURRENT-STATE.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 47ce15815..709613bad 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -97,6 +97,11 @@ lists at 25 rows by default. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). +Catalog calls check their cancellation signal before tool execution and after +the tool resolves, including nested calls. Cancellation discards late catalog +results without promising rollback of completed actions or invalidating correct +static-source caches. + ## Cyber HUD — September 30, 2026 Display > HUD > Layout includes Cyber, also available through the HUD voice @@ -5310,8 +5315,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(tools): stop cancelled calls before execution and discard late results - -Check cancellation before invoking the tool and after it resolves, including nested calls that share the same signal. This does not promise rollback of an action already completed. From fffbe67b6685ad37e6c31eeebdce2a41b1eab975 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:23:37 -0400 Subject: [PATCH 03/13] fix(tools): reject cancellation after async interception --- src/tools/catalog-cancellation.test.mjs | 37 +++++++++++++++++++++++++ src/tools/catalog.js | 12 ++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/tools/catalog-cancellation.test.mjs b/src/tools/catalog-cancellation.test.mjs index a374bd71e..a4a2b7c9d 100644 --- a/src/tools/catalog-cancellation.test.mjs +++ b/src/tools/catalog-cancellation.test.mjs @@ -39,3 +39,40 @@ test('cancelled catalog calls cannot execute or return late results', async () = await assert.rejects(pending, { name: 'AbortError' }); assert.equal(calls, 1); }); + +test('cancellation during an awaited interceptor cannot publish its late result', async () => { + const controller = new AbortController(); + const reason = new Error('interceptor caller canceled'); + let calls = 0; + const action = defineTool({ + name: 'fixture_intercepted_action', + kind: 'action', + title: 'Fixture', + description: 'Offline fixture', + inputSchema: { type: 'object' }, + run: async () => { + calls++; + return { summary: 'completed action', data: {} }; + }, + }); + const catalog = composeCatalog({ + tools: [action], + interceptors: [ + async (call, next) => { + const result = await next(call); + await Promise.resolve(); + controller.abort(reason); + return result; + }, + ], + }); + await assert.rejects( + catalog.call( + 'fixture_intercepted_action', + {}, + { signal: controller.signal }, + ), + (error) => error === reason, + ); + assert.equal(calls, 1, 'completed actions are not rolled back or rerun'); +}); diff --git a/src/tools/catalog.js b/src/tools/catalog.js index 6faf82ce5..ea0e37460 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -169,13 +169,21 @@ export function composeCatalog({ ); const index = new Map(available.map((tool) => [tool.name, tool])); - const invoke = (name, args, signal, parent) => { + const invoke = async (name, args, signal, parent) => { + signal?.throwIfAborted(); const tool = index.get(name); if (!tool) return Promise.reject( new ToolError('unsupported', `No tool named ${name} is available`), ); - return chain({ tool, args, signal, ...(parent ? { parent } : {}) }); + const result = await chain({ + tool, + args, + signal, + ...(parent ? { parent } : {}), + }); + signal?.throwIfAborted(); + return result; }; const execute = async ({ tool, args, signal }) => { signal?.throwIfAborted(); From aacb409fbf74a8add2c6b57ab9a2c4326f8a7376 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:32:52 -0400 Subject: [PATCH 04/13] fix(tools): limit cancellation guard to pre-execution --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 7 +- src/tools/catalog-cancellation.test.mjs | 117 +++++++++--------------- src/tools/catalog.js | 13 +-- 4 files changed, 52 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33ebae897..21b296fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ - Cancelled tool calls refuse actions before execution and discard results that settle after cancellation. +- Cancelled catalog queries refuse execution when their signal is already aborted. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 709613bad..648fa0584 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -97,10 +97,9 @@ lists at 25 rows by default. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). -Catalog calls check their cancellation signal before tool execution and after -the tool resolves, including nested calls. Cancellation discards late catalog -results without promising rollback of completed actions or invalidating correct -static-source caches. +Catalog calls check their cancellation signal immediately before executing a +tool. Already-cancelled queries do not start; cancellation after execution starts +continues to follow the existing tool and caller behavior. ## Cyber HUD — September 30, 2026 diff --git a/src/tools/catalog-cancellation.test.mjs b/src/tools/catalog-cancellation.test.mjs index a4a2b7c9d..0f666b262 100644 --- a/src/tools/catalog-cancellation.test.mjs +++ b/src/tools/catalog-cancellation.test.mjs @@ -1,78 +1,51 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { composeCatalog, defineTool } from './catalog.js'; -test('cancelled catalog calls cannot execute or return late results', async () => { - let calls = 0, - release, - block = false; - const action = defineTool({ - name: 'fixture_action', - kind: 'action', - title: 'Fixture', - description: 'Offline fixture', - inputSchema: { type: 'object' }, - run: async () => { - calls++; - return block - ? new Promise((r) => (release = r)) - : { summary: 'fixture', data: {} }; - }, - }); - const catalog = composeCatalog({ tools: [action] }); - const c = new AbortController(); - c.abort(); - await assert.rejects( - catalog.call('fixture_action', {}, { signal: c.signal }), - { name: 'AbortError' }, - ); - assert.equal(calls, 0); - block = true; - const running = new AbortController(); - const pending = catalog.call( - 'fixture_action', - {}, - { signal: running.signal }, - ); - while (!release) await Promise.resolve(); - running.abort(); - release({ summary: 'late', data: {} }); - await assert.rejects(pending, { name: 'AbortError' }); - assert.equal(calls, 1); -}); -test('cancellation during an awaited interceptor cannot publish its late result', async () => { - const controller = new AbortController(); - const reason = new Error('interceptor caller canceled'); - let calls = 0; - const action = defineTool({ - name: 'fixture_intercepted_action', - kind: 'action', - title: 'Fixture', - description: 'Offline fixture', - inputSchema: { type: 'object' }, - run: async () => { - calls++; - return { summary: 'completed action', data: {} }; - }, - }); - const catalog = composeCatalog({ - tools: [action], - interceptors: [ - async (call, next) => { - const result = await next(call); - await Promise.resolve(); - controller.abort(reason); - return result; +for (const intercept of [false, true]) { + test(`cancelled query refuses execution${intercept ? ' after an awaited interceptor' : ''}`, async () => { + let calls = 0; + const controller = new AbortController(); + const query = defineTool({ + name: 'fixture_query', + kind: 'query', + title: 'Fixture', + description: 'Offline fixture', + inputSchema: { type: 'object' }, + run: async () => { + calls++; + return { summary: 'fixture', data: {} }; }, - ], + }); + const catalog = composeCatalog({ + tools: [query], + interceptors: intercept + ? [ + async (call, next) => { + await Promise.resolve(); + controller.abort(); + return next(call); + }, + ] + : [], + }); + if (!intercept) controller.abort(); + await assert.rejects( + catalog.call('fixture_query', {}, { signal: controller.signal }), + { name: 'AbortError' }, + ); + assert.equal(calls, 0); + const fresh = composeCatalog({ tools: [query] }); + assert.equal( + ( + await fresh.call( + 'fixture_query', + {}, + { signal: new AbortController().signal }, + ) + ).summary, + 'fixture', + ); + assert.equal(calls, 1); }); - await assert.rejects( - catalog.call( - 'fixture_intercepted_action', - {}, - { signal: controller.signal }, - ), - (error) => error === reason, - ); - assert.equal(calls, 1, 'completed actions are not rolled back or rerun'); -}); +} diff --git a/src/tools/catalog.js b/src/tools/catalog.js index ea0e37460..d617a9f28 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -169,21 +169,13 @@ export function composeCatalog({ ); const index = new Map(available.map((tool) => [tool.name, tool])); - const invoke = async (name, args, signal, parent) => { - signal?.throwIfAborted(); + const invoke = (name, args, signal, parent) => { const tool = index.get(name); if (!tool) return Promise.reject( new ToolError('unsupported', `No tool named ${name} is available`), ); - const result = await chain({ - tool, - args, - signal, - ...(parent ? { parent } : {}), - }); - signal?.throwIfAborted(); - return result; + return chain({ tool, args, signal, ...(parent ? { parent } : {}) }); }; const execute = async ({ tool, args, signal }) => { signal?.throwIfAborted(); @@ -203,7 +195,6 @@ export function composeCatalog({ } catch (error) { throw fromSourceError(error); } - signal?.throwIfAborted(); if (!result || typeof result.summary !== 'string' || !result.data) throw new TypeError(`${tool.name} returned no summary or data`); if ( From 09018ac753e7edabc94eed09094267d591ee6fa4 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 15:18:15 -0400 Subject: [PATCH 05/13] docs: drop superseded CHANGELOG bullet The review-driven rewrite added a narrower entry but left the original bullet in place, so the PR carried two entries for one change. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b296fdf..0a761a798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,6 @@ the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. -- Cancelled tool calls refuse actions before execution and discard results that settle after cancellation. - - Cancelled catalog queries refuse execution when their signal is already aborted. - Treat explicitly current, complete empty vessel coverage as a successful update, From 29583c55ed045af3675dd9de2b10de7c64a4ab4c Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:12:29 -0400 Subject: [PATCH 06/13] fix(firms): reject impossible acquisition days in CSV parsing Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 2 ++ src/data/firms-calendar-validity.test.mjs | 23 +++++++++++++++++++++++ src/data/firmsCsv.js | 8 +++++++- 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/data/firms-calendar-validity.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..ca4974a0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Reject impossible FIRMS acquisition calendar days in the CSV parser before trailing-24-hour filtering. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..6a4f3061b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1429,6 +1429,8 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## FIRMS source status +The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. + The FIRMS proxy records source success after appending its rows. If aggregation throws, that source reports failure without a contradictory success entry. The existing per-record append continues to support large feeds; sequential diff --git a/src/data/firms-calendar-validity.test.mjs b/src/data/firms-calendar-validity.test.mjs new file mode 100644 index 000000000..1ccaf7435 --- /dev/null +++ b/src/data/firms-calendar-validity.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { acquisitionMsUtc } from './firmsCsv.js'; +test('FIRMS acquisition dates cannot roll into a different calendar day', () => { + for (const [date, time] of [ + ['2026-02-30', '1200'], + ['2026-04-31', '1200'], + ['2026-13-01', '1200'], + ['2026-07-16junk', '1200'], + ['2026-07-16', '2400'], + ['2026-07-16', '1260'], + ['2026-07-16', null], + ['2026-07-16', ''], + ]) { + assert.ok(Number.isNaN(acquisitionMsUtc(date, time)), `${date}:${time}`); + } + for (const [date, time, expected] of [ + ['2024-02-29', '45', Date.UTC(2024, 1, 29, 0, 45)], + ['2026-07-16', '0', Date.UTC(2026, 6, 16)], + ]) { + assert.equal(acquisitionMsUtc(date, time), expected); + } +}); diff --git a/src/data/firmsCsv.js b/src/data/firmsCsv.js index 7f9019e18..53d132d99 100644 --- a/src/data/firmsCsv.js +++ b/src/data/firmsCsv.js @@ -153,7 +153,13 @@ export function acquisitionMsUtc(acqDate, acqTime) { minutes > 59 ) return NaN; - return Date.UTC(year, month - 1, day, hours, minutes); + const ms = Date.UTC(year, month - 1, day, hours, minutes); + const parsed = new Date(ms); + return parsed.getUTCFullYear() === year && + parsed.getUTCMonth() === month - 1 && + parsed.getUTCDate() === day + ? ms + : NaN; } /** From af128bd4cd23c36bcdbd3dc30de6048d416ed645 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sun, 11 Oct 2026 09:51:14 -0400 Subject: [PATCH 07/13] fix(regional): keep impossible compact article dates unknown --- CHANGELOG.md | 1 + docs/CURRENT-STATE.md | 5 +++ src/data/regional-calendar-evidence.test.mjs | 43 ++++++++++++++++++++ src/data/regionalModel.js | 17 ++++++-- 4 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 src/data/regional-calendar-evidence.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..6212e745c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. +- Regional articles retain unknown publication times when compact source dates do not identify a real calendar instant. - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..03d98aa1c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5326,3 +5326,8 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. + + +### fix(regional): keep impossible compact article dates unknown + +Round-trip compact timestamps through a valid Date and retain the article with an unknown timestamp when its source date is impossible. Valid compact and ordinary ISO output formats are preserved. diff --git a/src/data/regional-calendar-evidence.test.mjs b/src/data/regional-calendar-evidence.test.mjs new file mode 100644 index 000000000..a442a9feb --- /dev/null +++ b/src/data/regional-calendar-evidence.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeRegionalArticles } from './regionalModel.js'; +test('compact source dates must denote an actual calendar instant', () => { + for (const date of [ + '20260230T120000Z', + '20260431T120000Z', + '20261301T120000Z', + '20260716T240000Z', + '20260716T126000Z', + '20260716T120060Z', + ]) { + const [article] = normalizeRegionalArticles({ + articles: [ + { + title: 'Fixture', + url: 'https://fixture.example/news', + seendate: date, + }, + ], + }); + assert.equal(article.publishedAt, null, date); + assert.equal(article.title, 'Fixture'); + } + for (const [input, expected] of [ + ['20240229T004500Z', '2024-02-29T00:45:00Z'], + ['20260716T000000Z', '2026-07-16T00:00:00Z'], + ['2026-07-16T12:00:00Z', '2026-07-16T12:00:00.000Z'], + ]) { + assert.equal( + normalizeRegionalArticles({ + articles: [ + { + title: 'Fixture', + url: 'https://fixture.example/news', + seendate: input, + }, + ], + })[0].publishedAt, + expected, + ); + } +}); diff --git a/src/data/regionalModel.js b/src/data/regionalModel.js index 3a6d79f14..fca473994 100644 --- a/src/data/regionalModel.js +++ b/src/data/regionalModel.js @@ -67,11 +67,20 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) { seen.add(signature); const rawDate = cleanText(row?.seendate, 32); const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate); - const publishedAt = compactDate + const candidateDate = compactDate ? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z` - : Number.isNaN(Date.parse(rawDate)) - ? null - : new Date(rawDate).toISOString(); + : rawDate; + const parsedMs = Date.parse(candidateDate); + const validDate = + Number.isFinite(parsedMs) && + (!compactDate || + new Date(parsedMs).toISOString().slice(0, 19) === + candidateDate.slice(0, 19)); + const publishedAt = validDate + ? compactDate + ? candidateDate + : new Date(parsedMs).toISOString() + : null; articles.push({ title, url, From 5cb79cd69df472db04059324e5b23673bb12b571 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:14:32 -0400 Subject: [PATCH 08/13] docs: address maintainer review for PR #1051 --- docs/CURRENT-STATE.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 03d98aa1c..a8dec3873 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1140,6 +1140,8 @@ stalled downloads are cancelled. Development and preview use the same handler. ## Remaining local service modules +GDELT compact dates that do not name a real calendar instant keep the article with an unknown publication time. + Overpass query validation, geometry simplification, disk caching and upstream transport now have separate modules; military-installation search reuses that transport. Regional briefing combines separate place, news and weather sources, @@ -5326,8 +5328,3 @@ datacenters, dams and the Nepal locator retain that shared credit while displaye Military fragments stitch by source identity. Different mapped parcels merge only when their footprints touch or contain one another, never across a seam tolerance gap. - - -### fix(regional): keep impossible compact article dates unknown - -Round-trip compact timestamps through a valid Date and retain the article with an unknown timestamp when its source date is impossible. Valid compact and ordinary ISO output formats are preserved. From 2c7d27ec6d31a4a6bcd3134c86a70f613c33623b Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:15:51 -0400 Subject: [PATCH 09/13] docs: place publication-time behavior beside regional briefing --- docs/CURRENT-STATE.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a8dec3873..066cf5e37 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1140,12 +1140,11 @@ stalled downloads are cancelled. Development and preview use the same handler. ## Remaining local service modules -GDELT compact dates that do not name a real calendar instant keep the article with an unknown publication time. - Overpass query validation, geometry simplification, disk caching and upstream transport now have separate modules; military-installation search reuses that transport. Regional briefing combines separate place, news and weather sources, -while weather effects uses only the weather source. Existing process-scoped +while weather effects uses only the weather source. +GDELT compact dates that do not name a real calendar instant keep the article with an unknown publication time. Existing process-scoped caches, rate limits, stale fallbacks and route ordering are preserved. Local voice has separate HUD-summary, debug-log and Realtime-token handlers, From fa374025892e701ee6d9572379c4260c0a6e25a7 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Mon, 12 Oct 2026 11:17:02 -0400 Subject: [PATCH 10/13] docs: wrap regional publication-time prose --- docs/CURRENT-STATE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 066cf5e37..76d65d82d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1144,7 +1144,8 @@ Overpass query validation, geometry simplification, disk caching and upstream transport now have separate modules; military-installation search reuses that transport. Regional briefing combines separate place, news and weather sources, while weather effects uses only the weather source. -GDELT compact dates that do not name a real calendar instant keep the article with an unknown publication time. Existing process-scoped +GDELT compact dates that do not name a real calendar instant keep the article +with an unknown publication time. Existing process-scoped caches, rate limits, stale fallbacks and route ordering are preserved. Local voice has separate HUD-summary, debug-log and Realtime-token handlers, From a37a94fba1a214d0c901e032d83d72622bfda8c5 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:13:38 -0400 Subject: [PATCH 11/13] fix(services): retry malformed capability probes after the existing backoff Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 + docs/CURRENT-STATE.md | 3 +- src/annotations/annotationEngine.test.mjs | 4 +- src/services/boundary-probe-recovery.test.mjs | 38 +++++++++++++++++++ src/services/requests.js | 15 +++++--- 5 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 src/services/boundary-probe-recovery.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..24ed601cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- Boundary capability checks recover after unsuccessful or malformed status responses, using the existing retry backoff. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..d8409281f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4066,7 +4066,8 @@ when its own signal aborts. Configured endpoints have per-instance cooldowns for 406/429, honoring Retry-After; absent delays back off from 30 seconds to five minutes. Empty `elements` is a valid answer. Existing byte, query, concurrency and cache caps -remain. Consumer capability misses never schedule automatic retries. +remain. Malformed capability-probe responses remain unknown and are retried after the existing 30-second backoff; valid configured decisions remain memoized. +Consumer capability misses never schedule automatic retries. Operator-configured Overpass area and focus-footprint queries use body-level `out geom` (with `center` for focus), preserving relation member outlines. diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 813163d8c..3c4b9165b 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -313,7 +313,9 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 }); globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout }; const requestTimes = []; - globalThis.fetch = async () => { + globalThis.fetch = async (url) => { + if (String(url).endsWith('/status')) + return Response.json({ configured: true }); requestTimes.push(Date.now()); return httpFailure(500); }; diff --git a/src/services/boundary-probe-recovery.test.mjs b/src/services/boundary-probe-recovery.test.mjs new file mode 100644 index 000000000..cc0c47c08 --- /dev/null +++ b/src/services/boundary-probe-recovery.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApplicationRequestServices } from './requests.js'; +test('unknown capability replies recover after backoff', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 100_000 }); + for (const first of [ + () => Response.json({ wrong: true }), + () => new Response('bad', { status: 503 }), + ]) { + let probes = 0; + let queries = 0; + const services = createApplicationRequestServices({ + boundaryProbe: { timeoutMs: 1000, retryMs: 1000 }, + fetchImpl: async (url) => { + if (url.endsWith('/status')) + return ++probes === 1 + ? first() + : Response.json({ configured: false }); + queries++; + return Response.json({ elements: [] }); + }, + }); + assert.deepEqual(await services.boundaries.query('fixture'), []); + assert.deepEqual(await services.boundaries.query('fixture'), []); + assert.equal(probes, 1, 'unknown reply is not re-probed inside backoff'); + t.mock.timers.tick(1000); + assert.equal( + (await services.boundaries.query('fixture')).code, + 'OVERPASS_NOT_CONFIGURED', + ); + assert.equal( + (await services.boundaries.query('fixture')).code, + 'OVERPASS_NOT_CONFIGURED', + ); + assert.equal(probes, 2); + assert.equal(queries, 2); + } +}); diff --git a/src/services/requests.js b/src/services/requests.js index bb0b4cefd..3e3a819eb 100644 --- a/src/services/requests.js +++ b/src/services/requests.js @@ -66,9 +66,9 @@ export function createApplicationRequestServices({ throw new Error(`${label} unavailable (${response.status})`); return response.data; } - // Learn once per page whether the server has an Overpass instance, so an + // Remember valid capability decisions for the page, so an // unconfigured server is never asked (and never answers with an error). - // Discovery is bounded; a failed or timed-out probe is forgotten and not + // Discovery is bounded; failed, malformed or timed-out probes are forgotten and not // retried for a short backoff, during which queries go to the server as // they would against an older server without the probe. let boundaryCapability = null; @@ -79,10 +79,13 @@ export function createApplicationRequestServices({ boundaryCapability = request(`${urls.boundaries}/status`, { signal: AbortSignal.timeout(boundaryProbe.timeoutMs), }).then( - (response) => - response.ok && typeof response.data?.configured === 'boolean' - ? response.data.configured - : null, // an older server: fall back to asking per query + (response) => { + if (response.ok && typeof response.data?.configured === 'boolean') + return response.data.configured; + boundaryCapability = null; + boundaryProbeRetryAt = Date.now() + boundaryProbe.retryMs; + return null; // capability unknown: let this query ask the server + }, (error) => { boundaryCapability = null; if (lifetime?.aborted) throw error; From 4fcca5b21564f28c58d94fda02d4987d7d7f8fc5 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:13:40 -0400 Subject: [PATCH 12/13] docs: separate CHANGELOG bullets after rebase Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6212e745c..098fc54a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane. - Director timeline seeks cross the dateline along the short longitude arc. + - Regional articles retain unknown publication times when compact source dates do not identify a real calendar instant. - Treat explicitly current, complete empty vessel coverage as a successful update, From 19fcc00b3ee3697cec2c814262791bbce0933220 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Wed, 14 Oct 2026 20:15:09 -0400 Subject: [PATCH 13/13] fix(terrain): reject out-of-range points at the proxy boundary Rebased onto current main with review follow-ups folded in. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ docs/CURRENT-STATE.md | 4 ++++ src/data/terrainHeightsProxy.js | 8 +++++++- src/data/terrainHeightsProxy.test.mjs | 11 +++++++++++ src/proxyErrorResponses.test.mjs | 16 ++++++++++++++++ 5 files changed, 40 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a770fbd35..2d15df2d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ - Director timeline seeks cross the dateline along the short longitude arc. +- The terrain proxy refuses out-of-range WGS84 coordinates before upstream resolution for every client. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f794a5b5..73e521e01 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1192,6 +1192,10 @@ The browser layers and their rendering remain in their existing modules. ## Terrain height cache bound +The terrain proxy parser rejects finite longitudes outside [-180, 180] and +latitudes outside [-90, 90] for every client before upstream resolution. One +out-of-range point rejects the whole batch; inclusive WGS84 bounds remain valid. + The client terrain-height resolver's in-memory cache is bounded at 20 000 entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through `createTerrainHeights({ maxCacheEntries })`). Coordinate rounding makes repeated diff --git a/src/data/terrainHeightsProxy.js b/src/data/terrainHeightsProxy.js index bfb058693..9134f9ec6 100644 --- a/src/data/terrainHeightsProxy.js +++ b/src/data/terrainHeightsProxy.js @@ -35,7 +35,13 @@ export function parseTerrainPoints(raw) { if (parts.length !== 2) return null; const lon = Number(parts[0]); const lat = Number(parts[1]); - if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null; + if ( + !Number.isFinite(lon) || + !Number.isFinite(lat) || + Math.abs(lon) > 180 || + Math.abs(lat) > 90 + ) + return null; points.push([lon, lat]); } return points; diff --git a/src/data/terrainHeightsProxy.test.mjs b/src/data/terrainHeightsProxy.test.mjs index f6b53de9a..50e45b732 100644 --- a/src/data/terrainHeightsProxy.test.mjs +++ b/src/data/terrainHeightsProxy.test.mjs @@ -3,6 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + parseTerrainPoints, fetchTerrainChunkWithRetry, resolveTerrainHeightRequest, terrainPointKey, @@ -227,3 +228,13 @@ test('malformed height objects remain refresh failures, not absent readings', as assert.equal(response.cacheChanged, false); } }); + +test('terrain parser rejects out-of-range WGS84 points and accepts inclusive bounds', () => { + for (const value of ['181,0', '-181,0', '0,91', '0,-91', '1,2;0,91']) + assert.equal(parseTerrainPoints(value), null, value); + assert.deepEqual(parseTerrainPoints('-180,-90;180,90;0,0'), [ + [-180, -90], + [180, 90], + [0, 0], + ]); +}); diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index 2b45a3b9e..c6a7269ab 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import path from 'node:path'; +import { parseTerrainPoints } from './data/terrainHeightsProxy.js'; import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js'; const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'terrain.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js'] @@ -172,3 +173,18 @@ test('ADSBDB retains validation and missing-aircraft semantics', async () => { assert.equal(absent.status, 200); assert.deepEqual(JSON.parse(absent.body), { found: false }); }); + +for (const preview of [false, true]) { + test(`terrain server rejects out-of-range points before upstream resolution in ${preview ? 'preview' : 'development'}`, async () => { + let calls = 0; + const app = fixture('terrainHeightsProxy', { parseTerrainPoints, + resolveTerrainHeightRequest: async () => { calls++; return { status: 200, body: { results: [] } }; }, + }, preview); + for (const points of ['181,0', '-181,0', '0,91', '0,-91', '1,2;0,91']) { + assert.equal((await app.request('/?points=' + encodeURIComponent(points))).status, 400); + } + assert.equal(calls, 0); + assert.equal((await app.request('/?points=-180,-90;180,90')).status, 200); + assert.equal(calls, 1); + }); +}