diff --git a/tools/ci/decide-retained-gwangju-timetable-refresh.mjs b/tools/ci/decide-retained-gwangju-timetable-refresh.mjs index 186e90c81..c1c0d9590 100644 --- a/tools/ci/decide-retained-gwangju-timetable-refresh.mjs +++ b/tools/ci/decide-retained-gwangju-timetable-refresh.mjs @@ -1,11 +1,15 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { deriveFreshnessExpiresAt } from "../datapack/freshness-policy.mjs"; +import { addCadence, deriveFreshnessExpiresAt } from "../datapack/freshness-policy.mjs"; import { validateLineage } from "../datapack/source-snapshot-policy.mjs"; import { requireRetainedTimetableConfirmationPolicy } from "../datapack/prepare-retained-kric-timetable-publication.mjs"; const SOURCE_ID = "kric-nationwide-timetable-file"; +// #929 D2 / #930 F2: 일일 재확인 주기. QA 결정 D2(a)(2026-10-04)로 관측 후 P1D가 지나면 재확인한다. +// 정책 클래스 cadence(official_static_timetable_confirmation P7D)는 만료(freshnessExpiresAt)를 정하는 값이고 그대로 둔다. +// 이 값은 만료가 아니라 재확인 시작 시각이므로 cadence보다 짧아야 한다(계약 테스트로 고정). +export const RETAINED_GWANGJU_DAILY_REVERIFICATION_PERIOD = "P1D"; // Workflow와 controller는 동일한 current 입력을 읽고, 운영 시각은 호출 시 한 번 캡처한다. export async function readRetainedGwangjuTimetableRefreshDecision({ @@ -24,6 +28,7 @@ export async function readRetainedGwangjuTimetableRefreshDecision({ // 등록된 head와 발행 경로가 공유하는 정책으로 갱신 시점을 계산한다. // #903: 만료 뒤가 아니라 SLA monitoring.alertBeforePackExpiry(수도권 topology 갱신 판정과 같은 기준) 창이 시작될 때부터 DUE다. +// #929 D2: 그보다 먼저, 관측 후 하루가 지나면 DUE다(일일 재확인). // 만료 시각 자체는 바꾸지 않는다(연장 없음). 창 안에서 새로 수집해 등록해야 만료 전에 head가 이어진다. export function decideRetainedGwangjuTimetableRefresh({ inventory, snapshots, candidate, freshnessPolicy, now = new Date() } = {}) { const nowMillis = requiredDate(now, "NOW"); @@ -47,7 +52,12 @@ export function decideRetainedGwangjuTimetableRefresh({ inventory, snapshots, ca if (head.freshnessExpiresAt !== freshnessExpiresAt || head.freshUntil !== freshnessExpiresAt) { fail("FRESHNESS_EXPIRES_AT"); } - const refreshDueAt = new Date(requiredUtc(freshnessExpiresAt, "FRESHNESS_EXPIRES_AT") - alertBeforeExpiryMillis).toISOString(); + // #929 D2(QA 결정 2026-10-04): 관측 후 P1D가 지나면 매일 재확인한다. 만료 경보 창이 그보다 먼저 오면 그 시각이 우선이다. + // 만료 시각(freshnessExpiresAt) 계산은 바꾸지 않는다. + const refreshDueAt = new Date(Math.min( + addCadence(observedMillis, RETAINED_GWANGJU_DAILY_REVERIFICATION_PERIOD), + requiredUtc(freshnessExpiresAt, "FRESHNESS_EXPIRES_AT") - alertBeforeExpiryMillis, + )).toISOString(); return { state: nowMillis < Date.parse(refreshDueAt) ? "CURRENT" : "DUE", sourceId: SOURCE_ID, snapshotId: head.snapshotId, observedAt: head.observedAt, freshnessExpiresAt, refreshDueAt, diff --git a/tools/ci/decide-retained-gwangju-timetable-refresh.test.mjs b/tools/ci/decide-retained-gwangju-timetable-refresh.test.mjs index f8447cd16..d30bed188 100644 --- a/tools/ci/decide-retained-gwangju-timetable-refresh.test.mjs +++ b/tools/ci/decide-retained-gwangju-timetable-refresh.test.mjs @@ -4,7 +4,12 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { decideRetainedGwangjuTimetableRefresh, readRetainedGwangjuTimetableRefreshDecision } from "./decide-retained-gwangju-timetable-refresh.mjs"; +import { + RETAINED_GWANGJU_DAILY_REVERIFICATION_PERIOD, + decideRetainedGwangjuTimetableRefresh, + readRetainedGwangjuTimetableRefreshDecision, +} from "./decide-retained-gwangju-timetable-refresh.mjs"; +import { readFileSync } from "node:fs"; const SOURCE_ID = "kric-nationwide-timetable-file"; const OBSERVED_AT = "2026-09-07T10:50:18.169Z"; @@ -15,6 +20,8 @@ const snapshotId = `${SOURCE_ID}-${observationIdentitySha256}`; // SLA monitoring.alertBeforePackExpiry(수도권 topology 갱신 판정과 같은 기준) const ALERT_BEFORE_EXPIRY_MS = 6 * 60 * 60 * 1_000; const REFRESH_DUE_AT = new Date(Date.parse(EXPIRY) - ALERT_BEFORE_EXPIRY_MS).toISOString(); +// #929 D2(QA 결정 2026-10-04): 관측 후 P1D가 지나면 매일 재확인한다. +const DAILY_DUE_AT = new Date(Date.parse(OBSERVED_AT) + 24 * 60 * 60 * 1_000).toISOString(); const freshnessPolicy = (alertBeforePackExpiry = "PT6H") => ({ monitoring: { alertBeforePackExpiry } }); function candidate(cadence = "P7D") { @@ -64,26 +71,40 @@ test("repository decision reads the admitted head and rejects duplicate source p await writeJson("tools/datapack/release/source-snapshots.json", value.snapshots); await writeJson("tools/datapack/source-candidates.json", { candidates: [value.candidate] }); await writeJson("release/product-gates/datapack-freshness-sla.json", value.freshnessPolicy); - const result = await readRetainedGwangjuTimetableRefreshDecision({ repositoryRoot, now: new Date(REFRESH_DUE_AT) }); + const result = await readRetainedGwangjuTimetableRefreshDecision({ repositoryRoot, now: new Date(DAILY_DUE_AT) }); assert.equal(result.state, "DUE"); - assert.equal(result.refreshDueAt, REFRESH_DUE_AT); + assert.equal(result.refreshDueAt, DAILY_DUE_AT); assert.equal(result.snapshotId, snapshotId); await writeJson("tools/datapack/source-candidates.json", { candidates: [value.candidate, value.candidate] }); await assert.rejects(readRetainedGwangjuTimetableRefreshDecision({ repositoryRoot, now: new Date(EXPIRY) }), /RETAINED_GWANGJU_TIMETABLE_REFRESH_SOURCE_CANDIDATE/); }); -test("retained Gwangju timetable은 genuine head가 아직 만료 전이면 CURRENT다", () => { - const result = decideRetainedGwangjuTimetableRefresh({ ...inputs(), now: new Date("2026-09-10T00:00:00.000Z") }); +test("retained Gwangju timetable은 관측 후 하루가 지나기 전에는 CURRENT다", () => { + // #929 D2 전에는 만료 6시간 전(REFRESH_DUE_AT)까지 CURRENT였다. 매일 재확인으로 바뀌어 기준 시각을 관측 다음 날로 옮긴다. + const result = decideRetainedGwangjuTimetableRefresh({ ...inputs(), now: new Date("2026-09-08T00:00:00.000Z") }); assert.deepEqual(result, { state: "CURRENT", sourceId: SOURCE_ID, snapshotId, observedAt: OBSERVED_AT, - freshnessExpiresAt: EXPIRY, refreshDueAt: REFRESH_DUE_AT }); + freshnessExpiresAt: EXPIRY, refreshDueAt: DAILY_DUE_AT }); }); -test("retained Gwangju timetable은 만료 전 SLA 경보 창(PT6H) 시작부터 DUE다", () => { - assert.equal(decideRetainedGwangjuTimetableRefresh({ ...inputs(), now: new Date(Date.parse(REFRESH_DUE_AT) - 1) }).state, "CURRENT"); - assert.equal(decideRetainedGwangjuTimetableRefresh({ ...inputs(), now: new Date(REFRESH_DUE_AT) }).state, "DUE"); - const wider = { ...inputs(), freshnessPolicy: freshnessPolicy("PT24H") }; - assert.equal(decideRetainedGwangjuTimetableRefresh({ ...wider, now: new Date(Date.parse(EXPIRY) - 24 * 60 * 60 * 1_000) }).state, "DUE"); +test("#929 D2 retained Gwangju timetable은 관측 후 P1D부터 DUE이고 만료 계산은 그대로다", () => { + const before = decideRetainedGwangjuTimetableRefresh({ ...inputs(), now: new Date(Date.parse(DAILY_DUE_AT) - 1) }); + assert.equal(before.state, "CURRENT"); + const due = decideRetainedGwangjuTimetableRefresh({ ...inputs(), now: new Date(DAILY_DUE_AT) }); + assert.equal(due.state, "DUE"); + assert.equal(due.refreshDueAt, DAILY_DUE_AT); + assert.equal(due.freshnessExpiresAt, EXPIRY); + assert.ok(Date.parse(DAILY_DUE_AT) < Date.parse(REFRESH_DUE_AT)); +}); + +test("retained Gwangju timetable은 만료 전 SLA 경보 창(PT6H)이 하루보다 먼저 오면 그때부터 DUE다", () => { + const cutoff = new Date(Date.parse(OBSERVED_AT) + 12 * 60 * 60 * 1_000).toISOString(); + const value = inputs({ head: snapshot({ serviceEffectiveUntil: cutoff, freshnessExpiresAt: cutoff, freshUntil: cutoff }) }); + const alertDueAt = new Date(Date.parse(cutoff) - ALERT_BEFORE_EXPIRY_MS).toISOString(); + assert.equal(decideRetainedGwangjuTimetableRefresh({ ...value, now: new Date(Date.parse(alertDueAt) - 1) }).state, "CURRENT"); + const due = decideRetainedGwangjuTimetableRefresh({ ...value, now: new Date(alertDueAt) }); + assert.equal(due.state, "DUE"); + assert.equal(due.refreshDueAt, alertDueAt); }); test("SLA 경보 창이 없거나 형식이 틀리면 판정하지 않는다", () => { @@ -143,3 +164,21 @@ test("inventory evidence mismatch와 future observation을 거부한다", () => /RETAINED_GWANGJU_TIMETABLE_REFRESH_FUTURE_OBSERVATION/, ); }); + +// #930 리뷰 F2: 일일 재확인 주기(P1D)는 정책 클래스 cadence(P7D, 만료 기준)와 일부러 다르다. +// QA 결정 D2(a)(2026-10-04): 만료는 그대로 두고 재확인만 매일 한다. 두 값이 조용히 엇갈리지 않게 관계를 고정한다. +test("#930 F2 daily reverification is a named P1D period shorter than the admitted confirmation cadence", () => { + assert.equal(RETAINED_GWANGJU_DAILY_REVERIFICATION_PERIOD, "P1D"); + const candidates = JSON.parse(readFileSync(new URL("../datapack/source-candidates.json", import.meta.url), "utf8")); + const confirmation = candidates.candidates.find(({ id }) => id === SOURCE_ID).confirmationPolicy; + assert.equal(confirmation.id, "official_static_timetable_confirmation"); + const days = (period) => Number(/^P([1-9][0-9]*)D$/u.exec(period)?.[1]); + assert.ok(days(RETAINED_GWANGJU_DAILY_REVERIFICATION_PERIOD) < days(confirmation.reverificationCadence), + `daily reverification must come before the ${confirmation.reverificationCadence} expiry`); + // 정책 cadence가 바뀌어도 일일 재확인 시각은 관측 + P1D 그대로다(만료 계산과 독립). + const value = inputs(); + value.candidate = candidate("P14D"); + value.snapshots[0].freshnessExpiresAt = "2026-09-21T10:50:18.169Z"; + value.snapshots[0].freshUntil = "2026-09-21T10:50:18.169Z"; + assert.equal(decideRetainedGwangjuTimetableRefresh({ ...value, now: new Date("2026-09-07T12:00:00.000Z") }).refreshDueAt, DAILY_DUE_AT); +}); diff --git a/tools/datapack/build-current-five-region-source-fan-in.mjs b/tools/datapack/build-current-five-region-source-fan-in.mjs index 0bf2c891d..5d214340a 100644 --- a/tools/datapack/build-current-five-region-source-fan-in.mjs +++ b/tools/datapack/build-current-five-region-source-fan-in.mjs @@ -4,6 +4,8 @@ import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { deriveFreshnessExpiresAt } from "./freshness-policy.mjs"; + export const CURRENT_FIVE_REGION_SOURCE_FAN_IN_PATH = "tools/datapack/release/current-five-region-source-fan-in.json"; export const NATIVE_ADMISSION_KINDS = Object.freeze([ @@ -202,7 +204,9 @@ function headAdmissionEvidence(source, sourceId, snapshot, evaluatedAt) { const observedAt = typeof rawObservedAt === "string" && !isNaN(Date.parse(rawObservedAt)) ? new Date(Date.parse(rawObservedAt)).toISOString() : rawObservedAt; - const rawFreshUntil = evidence.freshUntil ?? snapshot.freshnessExpiresAt; + // #929 D1(QA 결정 2026-10-04): 등록 확인 창의 끝은 원천별 정책 신선도(원장 head freshnessExpiresAt)다. + // 등록 evidence의 freshUntil(등록기가 수집 +1일로 쓰던 값)은 창을 줄이지 않는다. + const rawFreshUntil = snapshot.freshnessExpiresAt; const freshUntil = typeof rawFreshUntil === "string" && !isNaN(Date.parse(rawFreshUntil)) ? new Date(Date.parse(rawFreshUntil)).toISOString() : rawFreshUntil; @@ -487,6 +491,38 @@ function selectedSources(rows, inventory, sourceSnapshots, evaluatedAt) { }); } +// #929 D1: fan-in이 고른 원천은 신선도 정책 클래스가 정확히 하나여야 한다. 없거나 둘 이상이면 정책 기간을 정할 수 없다. +// #930 F1: 등록 창의 끝(원장 head freshnessExpiresAt)은 "등록 기준 시각 + 정책 기간"을 넘을 수 없다. +// 기준 시각은 클래스 basisField 값이고, 동일 원본 재확인 클래스는 재확인 시각이 더 늦으면 그 시각이다. +// 상한은 발행 FINAL과 같은 deriveFreshnessExpiresAt으로 구한다(제공처 유효 종료일은 상한을 낮추기만 하므로 넣지 않는다). +export function assertFanInSourceFreshnessPolicy({ fanIn, freshnessPolicy, sourceSnapshots }) { + if (!Array.isArray(freshnessPolicy?.sourceClasses) + || freshnessPolicy.sourceClasses.some((entry) => !Array.isArray(entry?.sourceIds))) { + throw new Error("fan-in freshness policy shape mismatch"); + } + if (!Array.isArray(sourceSnapshots)) throw new Error("fan-in freshness policy needs the source snapshot ledger"); + for (const { sourceId, snapshotId } of fanIn?.selectedSources ?? []) { + const classes = freshnessPolicy.sourceClasses.filter(({ sourceIds }) => sourceIds.includes(sourceId)); + if (classes.length === 0) throw new Error(`fan-in freshness policy missing for ${sourceId}`); + if (classes.length > 1) throw new Error(`fan-in freshness policy ambiguous for ${sourceId}`); + const [sourceClass] = classes; + const heads = sourceSnapshots.filter((row) => row?.snapshotId === snapshotId && row.sourceId === sourceId); + if (heads.length !== 1) throw new Error(`fan-in freshness policy head mismatch for ${sourceId}`); + const [head] = heads; + const bases = [head[sourceClass.basisField], sourceClass.unchangedReverificationBasisField + ? head[sourceClass.unchangedReverificationBasisField] : undefined].filter((value) => typeof value === "string"); + if (bases.length === 0) throw new Error(`fan-in freshness policy basis missing for ${sourceId}`); + const basisAt = bases.reduce((latest, value) => (Date.parse(value) > Date.parse(latest) ? value : latest), bases[0]); + const ceiling = deriveFreshnessExpiresAt({ + policy: freshnessPolicy, sourceClassId: sourceClass.id, basisAt, evaluationAt: fanIn.evaluatedAt, + }); + const storedMillis = Date.parse(head.freshnessExpiresAt); + if (!Number.isFinite(storedMillis) || storedMillis > Date.parse(ceiling)) { + throw new Error(`fan-in freshness exceeds policy for ${sourceId}: ${head.freshnessExpiresAt} > ${ceiling}`); + } + } +} + export function canonicalCurrentFiveRegionSourceFanInJson(value) { return canonical(value); } @@ -586,7 +622,7 @@ export function buildCurrentFiveRegionSourceFanIn(input = {}) { } function argumentsFrom(argv) { - const names = ["targets", "tally", "ownership", "inventory", "source-snapshots", "evaluated-at", "output"]; + const names = ["targets", "tally", "ownership", "inventory", "source-snapshots", "freshness-policy", "evaluated-at", "output"]; if (argv.length !== names.length * 2) throw new Error("five-region fan-in arguments mismatch"); const result = {}; for (let index = 0; index < argv.length; index += 2) { @@ -620,6 +656,8 @@ async function main() { evaluatedAt: args["evaluated-at"], }; const fanIn = buildCurrentFiveRegionSourceFanIn(input); + const freshnessPolicy = JSON.parse(await readFile(path.resolve(args["freshness-policy"]), "utf8")); + assertFanInSourceFreshnessPolicy({ fanIn, freshnessPolicy, sourceSnapshots: input.sourceSnapshots }); await writeFile(path.resolve(args.output), `${canonicalCurrentFiveRegionSourceFanInJson(fanIn)}\n`, { flag: "wx", mode: 0o600, diff --git a/tools/datapack/build-current-five-region-source-fan-in.test.mjs b/tools/datapack/build-current-five-region-source-fan-in.test.mjs index 23802b6b8..f3439c491 100644 --- a/tools/datapack/build-current-five-region-source-fan-in.test.mjs +++ b/tools/datapack/build-current-five-region-source-fan-in.test.mjs @@ -7,6 +7,7 @@ import { spawnSync } from "node:child_process"; import { test } from "node:test"; import { + assertFanInSourceFreshnessPolicy, buildCurrentFiveRegionSourceFanIn, canonicalCurrentFiveRegionSourceFanInJson, validateCurrentFiveRegionSourceFanIn, @@ -211,10 +212,15 @@ test("native schedule admission binds a production materialization approval with badDigest.inputBytes.inventory = bytes(badDigest.inventory); assert.throws(() => buildCurrentFiveRegionSourceFanIn(badDigest), /admission.*digest/); + // #929 D1: 등록 evidence freshUntil(수집 +1일)은 창을 줄이지 않는다. 창은 원장 정책 신선도에서 끝난다. + const shortEvidenceWindow = copy(); + shortEvidenceWindow.inventory.sources[0].scheduleAdmissionEvidence.freshUntil = EVALUATED_AT; + shortEvidenceWindow.inputBytes.inventory = bytes(shortEvidenceWindow.inventory); + assert.doesNotThrow(() => buildCurrentFiveRegionSourceFanIn(shortEvidenceWindow)); const stale = copy(); - stale.inventory.sources[0].scheduleAdmissionEvidence.freshUntil = EVALUATED_AT; - stale.inputBytes.inventory = bytes(stale.inventory); - assert.throws(() => buildCurrentFiveRegionSourceFanIn(stale), /admission.*freshness/); + stale.sourceSnapshots[0].freshnessExpiresAt = EVALUATED_AT; + stale.inputBytes.sourceSnapshots = bytes(stale.sourceSnapshots); + assert.throws(() => buildCurrentFiveRegionSourceFanIn(stale), /snapshot freshness mismatch/); const byteDrift = copy(); byteDrift.inventory.sources[0].scheduleAdmissionEvidence.tripCount = 2; @@ -418,10 +424,15 @@ test("#687 fails closed on ambiguous, non-OCI, stale, or unbound source heads", noAffirmativeAdmission.inputBytes.inventory = bytes(noAffirmativeAdmission.inventory); assert.throws(() => buildCurrentFiveRegionSourceFanIn(noAffirmativeAdmission), /admission.*approval/); + // #929 D1: 등록 evidence freshUntil이 지나도 원장 정책 신선도 안이면 통과하고, 원장 신선도가 지나면 실패한다. + const shortEvidenceWindow = fixture(); + shortEvidenceWindow.inventory.sources[0].admissionEvidence.freshUntil = EVALUATED_AT; + shortEvidenceWindow.inputBytes.inventory = bytes(shortEvidenceWindow.inventory); + assert.doesNotThrow(() => buildCurrentFiveRegionSourceFanIn(shortEvidenceWindow)); const staleAdmission = fixture(); - staleAdmission.inventory.sources[0].admissionEvidence.freshUntil = EVALUATED_AT; - staleAdmission.inputBytes.inventory = bytes(staleAdmission.inventory); - assert.throws(() => buildCurrentFiveRegionSourceFanIn(staleAdmission), /admission.*freshness/); + staleAdmission.sourceSnapshots[0].freshnessExpiresAt = EVALUATED_AT; + staleAdmission.inputBytes.sourceSnapshots = bytes(staleAdmission.sourceSnapshots); + assert.throws(() => buildCurrentFiveRegionSourceFanIn(staleAdmission), /snapshot freshness mismatch/); const futureAdmission = fixture(); futureAdmission.inventory.sources[0].admissionEvidence.capturedAt = "2026-09-03T00:00:00.001Z"; @@ -454,8 +465,11 @@ test("#687 CLI creates one canonical output and never overwrites it", async () = paths[name] = inputPath; } const output = path.join(temporary, "fan-in.json"); + const policyPath = path.join(temporary, "freshness-policy.json"); + await writeFile(policyPath, bytes(policyFor(buildCurrentFiveRegionSourceFanIn(input)))); const args = [ path.join(ROOT, "tools/datapack/build-current-five-region-source-fan-in.mjs"), + "--freshness-policy", policyPath, "--targets", paths.targets, "--tally", paths.tally, "--ownership", paths.ownership, @@ -542,7 +556,10 @@ test("#862 provider validity cannot extend a head beyond its ledger freshness", assert.throws(() => buildCurrentFiveRegionSourceFanIn(input), /snapshot freshness mismatch for kric-subway-timetable/); }); -test("#862 an expired admission window fails for every source, without per-source exemptions", () => { +// #929 D1(QA 결정 2026-10-04): 등록 확인 창의 끝은 원천별 정책 신선도(원장 head freshnessExpiresAt)다. +// 이 테스트는 #862에서 "등록 evidence freshUntil(수집 +1일)이 지나면 실패"를 고정했다. 그 P1D 창이 접근성(P90D)· +// 지역 시간표(P30D) 원천까지 하루마다 후보를 막아 QA가 정책 기간으로 바꿨으므로, 같은 입력이 이제 통과해야 한다. +test("#929 D1 an expired registration +1 day window no longer blocks a head inside its policy freshness", () => { for (const sourceId of [ "capital-route-topology", "korail-metropolitan-timetable-file", "kric-station-convenience-standard", "seoul-metro-accessibility", @@ -552,6 +569,89 @@ test("#862 an expired admission window fails for every source, without per-sourc freshnessExpiresAt: "2026-12-01T00:00:00.000Z", admissionFreshUntil: "2026-10-01T00:00:00.000Z", }); - assert.throws(() => buildCurrentFiveRegionSourceFanIn(input), new RegExp(`admission freshness mismatch for ${sourceId}`)); + const fanIn = buildCurrentFiveRegionSourceFanIn(input); + assert.equal(fanIn.selectedSources[0].sourceId, sourceId); + assert.equal(fanIn.selectedSources[0].freshnessExpiresAt, "2026-12-01T00:00:00.000Z"); + } +}); + +test("#929 D1 the admission window still ends exactly at the policy freshness", () => { + const atExpiry = registeredHeadFixture("seoul-metro-accessibility", { + evaluatedAt: "2026-12-01T00:00:00.000Z", + freshnessExpiresAt: "2026-12-01T00:00:00.000Z", + admissionFreshUntil: "2027-01-01T00:00:00.000Z", + }); + assert.throws(() => buildCurrentFiveRegionSourceFanIn(atExpiry), /freshness mismatch for seoul-metro-accessibility/); + const futureObservation = registeredHeadFixture("seoul-metro-accessibility", { + evaluatedAt: "2026-09-29T00:00:00.000Z", + freshnessExpiresAt: "2026-12-01T00:00:00.000Z", + }); + futureObservation.sourceSnapshots[1].retrievedAt = "2026-09-28T00:00:00.000Z"; + futureObservation.inputBytes.sourceSnapshots = bytes(futureObservation.sourceSnapshots); + assert.throws(() => buildCurrentFiveRegionSourceFanIn(futureObservation), /admission future observation mismatch for seoul-metro-accessibility/); +}); + +function policyFor(fanIn, extraClasses = []) { + return { + sourceClasses: [ + // fixture 원장 head는 retrievedAt 2026-09-02 + P2D = 2026-09-04가 정책 상한이다. + { id: "fixture_class", sourceIds: fanIn.selectedSources.map(({ sourceId }) => sourceId), basisField: "retrievedAt", reverificationCadence: "P2D" }, + ...extraClasses, + ], + }; +} + +test("#929 D1 every selected source must have exactly one freshness policy class", () => { + const input = fixture(); + const fanIn = buildCurrentFiveRegionSourceFanIn(input); + const sourceId = fanIn.selectedSources[0].sourceId; + const { sourceSnapshots } = input; + assert.doesNotThrow(() => assertFanInSourceFreshnessPolicy({ fanIn, freshnessPolicy: policyFor(fanIn), sourceSnapshots })); + assert.throws(() => assertFanInSourceFreshnessPolicy({ fanIn, freshnessPolicy: { sourceClasses: [] }, sourceSnapshots }), + new RegExp(`fan-in freshness policy missing for ${sourceId}`)); + assert.throws(() => assertFanInSourceFreshnessPolicy({ + fanIn, freshnessPolicy: policyFor(fanIn, [{ id: "other", sourceIds: [sourceId] }]), sourceSnapshots, + }), new RegExp(`fan-in freshness policy ambiguous for ${sourceId}`)); + assert.throws(() => assertFanInSourceFreshnessPolicy({ fanIn, freshnessPolicy: {}, sourceSnapshots }), /fan-in freshness policy shape mismatch/); +}); + +// #930 리뷰 F1: 창의 끝으로 쓰는 원장 freshnessExpiresAt이 "등록 기준 시각 + 정책 기간"을 넘으면 창이 부풀려진 것이다. +test("#930 F1 a ledger freshness beyond the policy-derived ceiling fails the fan-in policy check", () => { + const input = fixture(); + input.sourceSnapshots[0].freshnessExpiresAt = "2026-09-04T00:00:00.001Z"; + input.inputBytes.sourceSnapshots = bytes(input.sourceSnapshots); + const fanIn = buildCurrentFiveRegionSourceFanIn(input); + const sourceId = fanIn.selectedSources[0].sourceId; + assert.throws(() => assertFanInSourceFreshnessPolicy({ fanIn, freshnessPolicy: policyFor(fanIn), sourceSnapshots: input.sourceSnapshots }), + new RegExp(`fan-in freshness exceeds policy for ${sourceId}: 2026-09-04T00:00:00\\.001Z > 2026-09-04T00:00:00\\.000Z`)); + const exact = fixture(); + const exactFanIn = buildCurrentFiveRegionSourceFanIn(exact); + assert.doesNotThrow(() => assertFanInSourceFreshnessPolicy({ fanIn: exactFanIn, freshnessPolicy: policyFor(exactFanIn), sourceSnapshots: exact.sourceSnapshots })); + assert.throws(() => assertFanInSourceFreshnessPolicy({ fanIn: exactFanIn, freshnessPolicy: policyFor(exactFanIn) }), + /fan-in freshness policy needs the source snapshot ledger/); +}); + +test("#929 D1 the fan-in CLI refuses to write without a policy class for every selected source", async () => { + const temporary = await mkdtemp(path.join(os.tmpdir(), "five-region-source-fan-in-policy-")); + const input = fixture(); + const paths = {}; + for (const [name, value] of Object.entries(input.inputBytes)) { + paths[name] = path.join(temporary, `${name}.json`); + await writeFile(paths[name], value); } + const policyPath = path.join(temporary, "freshness-policy.json"); + await writeFile(policyPath, bytes({ sourceClasses: [] })); + const output = path.join(temporary, "fan-in.json"); + const base = [ + path.join(ROOT, "tools/datapack/build-current-five-region-source-fan-in.mjs"), + "--targets", paths.targets, "--tally", paths.tally, "--ownership", paths.ownership, + "--inventory", paths.inventory, "--source-snapshots", paths.sourceSnapshots, + "--evaluated-at", EVALUATED_AT, "--output", output, + ]; + const withoutPolicy = spawnSync(process.execPath, base, { encoding: "utf8" }); + assert.notEqual(withoutPolicy.status, 0); + const missingClass = spawnSync(process.execPath, [...base, "--freshness-policy", policyPath], { encoding: "utf8" }); + assert.notEqual(missingClass.status, 0); + assert.match(missingClass.stderr, /fan-in freshness policy missing for/); + await assert.rejects(stat(output), { code: "ENOENT" }); }); diff --git a/tools/datapack/refresh-nationwide-candidate.mjs b/tools/datapack/refresh-nationwide-candidate.mjs index 7ab03e62c..35672fe2d 100644 --- a/tools/datapack/refresh-nationwide-candidate.mjs +++ b/tools/datapack/refresh-nationwide-candidate.mjs @@ -49,6 +49,7 @@ const SPEC_PATH = "tools/datapack/release/candidate-build-spec.json"; const REQUEST_PATH = "tools/datapack/release/release-request.json"; const HASH_EVIDENCE_PATH = "tools/datapack/release/hash-evidence.json"; const SOURCE_SNAPSHOTS_PATH = "tools/datapack/release/source-snapshots.json"; +const FRESHNESS_POLICY_PATH = "release/product-gates/datapack-freshness-sla.json"; const OVERRIDES_PATH = "tools/datapack/fixtures/admin-review-overrides.json"; const LEDGER_FIELDS = Object.freeze([ ["approvedAliasLedgerHash", "alias"], @@ -208,6 +209,7 @@ export async function runNationwideCandidateRefreshStep({ name, repositoryRoot, "--ownership", "tools/datapack/release/nationwide-requirement-ownership.json", "--inventory", "tools/datapack/source-inventory.json", "--source-snapshots", SOURCE_SNAPSHOTS_PATH, + "--freshness-policy", FRESHNESS_POLICY_PATH, "--evaluated-at", evaluatedAt, "--output", output, ]);