- {TIMELINE_EVENT_TYPES.map((type) => (
+ {POLICY_LIFECYCLE_TIMELINE_EVENT_TYPES.map((type) => (
{EVENT_TYPE_CONFIG[type].label}
diff --git a/src/lib/data-service.test.ts b/src/lib/data-service.test.ts
index d45cf96..ede6106 100644
--- a/src/lib/data-service.test.ts
+++ b/src/lib/data-service.test.ts
@@ -364,6 +364,77 @@ describe("data-service file store", () => {
);
});
+ it("limits register timeline reads to lifecycle events linked to visible policies", async () => {
+ const policy = buildPolicy({ id: "registered-policy" });
+ const linkedLifecycleEvent = buildTimelineEvent({
+ id: "linked-lifecycle-event",
+ type: "policy_amended",
+ relatedPolicyId: policy.id,
+ });
+ const linkedMilestone = buildTimelineEvent({
+ id: "linked-milestone",
+ type: "milestone",
+ relatedPolicyId: policy.id,
+ });
+ const unlinkedAnnouncement = buildTimelineEvent({
+ id: "unlinked-announcement",
+ relatedPolicyId: undefined,
+ });
+
+ readJsonFile.mockImplementation(
+ async (filePath: string, fallback: unknown) => {
+ if (filePath.endsWith("policies.json")) return [policy];
+ if (filePath.endsWith("timeline.json")) {
+ return [
+ linkedLifecycleEvent,
+ linkedMilestone,
+ unlinkedAnnouncement,
+ ];
+ }
+ if (filePath.endsWith("source-reviews.json")) return [];
+ return fallback;
+ },
+ );
+
+ const { getTimelineEvents } = await loadDataServiceModule();
+ const result = await getTimelineEvents(undefined, {
+ scope: "policy-register",
+ });
+
+ expect(result).toEqual([linkedLifecycleEvent]);
+ });
+
+ it("generates a policy lifecycle event when a linked milestone moves to developments", async () => {
+ const policy = buildPolicy({ id: "registered-policy" });
+ const linkedMilestone = buildTimelineEvent({
+ id: "linked-milestone",
+ type: "milestone",
+ relatedPolicyId: policy.id,
+ });
+
+ readJsonFile.mockImplementation(
+ async (filePath: string, fallback: unknown) => {
+ if (filePath.endsWith("policies.json")) return [policy];
+ if (filePath.endsWith("timeline.json")) return [linkedMilestone];
+ if (filePath.endsWith("source-reviews.json")) return [];
+ return fallback;
+ },
+ );
+
+ const { getTimelineEvents } = await loadDataServiceModule();
+ const result = await getTimelineEvents(undefined, {
+ scope: "policy-register",
+ });
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ id: `policy-timeline-${policy.id}`,
+ relatedPolicyId: policy.id,
+ type: "policy_introduced",
+ }),
+ ]);
+ });
+
it("derives generated timeline types from structured dates and lifecycle status", async () => {
const amendedPolicy = buildPolicy({
id: "amended-policy",
@@ -1041,6 +1112,126 @@ describe("data-service file store", () => {
expect(result[0].id).toBe("dev-newer");
});
+ it("projects non-register editorial timeline records into developments without duplicates", async () => {
+ const policy = buildPolicy({ id: "registered-policy" });
+ const announcement = buildTimelineEvent({
+ id: "timeline-announcement",
+ title: "Government announces AI review",
+ relatedPolicyId: undefined,
+ });
+ const alreadyMigrated = buildTimelineEvent({
+ id: "timeline-existing-development",
+ title: "Existing development",
+ sourceUrl: "https://example.gov.au/timeline/existing-development",
+ relatedPolicyId: undefined,
+ });
+ const existingDevelopment = {
+ id: "dev-existing",
+ title: alreadyMigrated.title,
+ url: alreadyMigrated.sourceUrl,
+ sourceId: "test-source",
+ sourceName: "Test source",
+ jurisdiction: "federal" as const,
+ publishedAt: "2025-02-01",
+ publishedAtPrecision: "day" as const,
+ detectedAt: "2026-07-10T00:00:00.000Z",
+ summary: alreadyMigrated.description,
+ relevanceScore: 1,
+ classification: "curated" as const,
+ assessment: {
+ method: "editorial" as const,
+ assessedAt: "2026-07-10T00:00:00.000Z",
+ promptVersion: "test",
+ },
+ verification: alreadyMigrated.verification,
+ status: "promoted" as const,
+ relatedTimelineEventId: alreadyMigrated.id,
+ };
+
+ readJsonFile.mockImplementation(
+ async (filePath: string, fallback: unknown) => {
+ if (filePath.endsWith("policies.json")) return [policy];
+ if (filePath.endsWith("timeline.json")) {
+ return [announcement, alreadyMigrated];
+ }
+ if (filePath.endsWith("developments.json")) {
+ return [existingDevelopment];
+ }
+ if (filePath.endsWith("source-reviews.json")) return [];
+ return fallback;
+ },
+ );
+
+ const { getDevelopments } = await loadDataServiceModule();
+ const result = await getDevelopments();
+
+ expect(result).toHaveLength(2);
+ expect(result).toEqual(
+ expect.arrayContaining([
+ existingDevelopment,
+ expect.objectContaining({
+ id: "dev-timeline-announcement",
+ classification: "curated",
+ status: "promoted",
+ relatedTimelineEventId: announcement.id,
+ }),
+ ]),
+ );
+ });
+
+ it("projects an approved timeline development over a dismissed machine detection", async () => {
+ const announcement = buildTimelineEvent({
+ id: "approved-announcement",
+ title: "Approved AI announcement",
+ relatedPolicyId: undefined,
+ });
+ const dismissedDetection = {
+ id: "dismissed-detection",
+ title: announcement.title,
+ url: announcement.sourceUrl,
+ sourceId: "test-source",
+ sourceName: "Test source",
+ jurisdiction: "federal" as const,
+ detectedAt: "2026-07-10T00:00:00.000Z",
+ relevanceScore: 0.65,
+ classification: "heuristic" as const,
+ assessment: {
+ method: "heuristic" as const,
+ assessedAt: "2026-07-10T00:00:00.000Z",
+ promptVersion: "test",
+ },
+ verification: {
+ status: "needs_review" as const,
+ source: { url: announcement.sourceUrl },
+ },
+ status: "dismissed" as const,
+ dismissalReason: "Not a policy instrument; retain as a development.",
+ };
+
+ readJsonFile.mockImplementation(
+ async (filePath: string, fallback: unknown) => {
+ if (filePath.endsWith("policies.json")) return [];
+ if (filePath.endsWith("timeline.json")) return [announcement];
+ if (filePath.endsWith("developments.json")) {
+ return [dismissedDetection];
+ }
+ if (filePath.endsWith("source-reviews.json")) return [];
+ return fallback;
+ },
+ );
+
+ const { getDevelopments } = await loadDataServiceModule();
+ const result = await getDevelopments();
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ id: "dev-approved-announcement",
+ verification: announcement.verification,
+ relatedTimelineEventId: announcement.id,
+ }),
+ ]);
+ });
+
it("exposes radar leads but withholds dismissed developments publicly", async () => {
const base = {
title: "Development",
diff --git a/src/lib/data-service.ts b/src/lib/data-service.ts
index 2b077d7..5ca1977 100644
--- a/src/lib/data-service.ts
+++ b/src/lib/data-service.ts
@@ -41,10 +41,16 @@ import {
type TimelineEvent,
type TimelineEventDraft,
type TimelineEventType,
+ POLICY_LIFECYCLE_TIMELINE_EVENT_TYPES,
+ TIMELINE_EVENT_TYPES,
} from "@/types";
type DataAccess = "public" | "admin";
+const POLICY_LIFECYCLE_TIMELINE_TYPES = new Set(
+ POLICY_LIFECYCLE_TIMELINE_EVENT_TYPES,
+);
+
interface DataServiceOptions {
access?: DataAccess;
now?: Date;
@@ -844,11 +850,13 @@ export async function getTimelineEvents(
},
options: {
includeGenerated?: boolean;
+ scope?: "all" | "policy-register";
access?: DataAccess;
now?: Date;
} = {},
): Promise {
const includeGenerated = options.includeGenerated ?? true;
+ const scope = options.scope ?? "all";
const access = options.access ?? "public";
const now = options.now ?? new Date();
// Generate timeline events from policies + merge with manual curated events
@@ -864,7 +872,7 @@ export async function getTimelineEvents(
access === "public"
? await getWithheldTimelineEventIds(allManualEvents)
: new Set();
- const manualEvents = allManualEvents
+ let manualEvents = allManualEvents
.filter(
(event) =>
access === "admin" ||
@@ -878,6 +886,14 @@ export async function getTimelineEvents(
? { ...event, relatedPolicyId: undefined }
: event,
);
+ if (scope === "policy-register") {
+ manualEvents = manualEvents.filter(
+ (event) =>
+ Boolean(event.relatedPolicyId) &&
+ publicPolicyIds.has(event.relatedPolicyId!) &&
+ POLICY_LIFECYCLE_TIMELINE_TYPES.has(event.type),
+ );
+ }
// Build a set of relatedPolicyIds from manual events for dedup
const manualPolicyIds = new Set(
@@ -924,6 +940,67 @@ export async function getTimelineEvents(
// Developments feed + collection metadata
// ---------------------------------------------------------------------------
+function timelineEventAsDevelopment(event: TimelineEvent): Development {
+ const date =
+ typeof event.date === "string"
+ ? event.date.slice(0, 10)
+ : event.date.toISOString().slice(0, 10);
+ const assessedAt =
+ event.verification.checkedAt ??
+ event.verification.source.retrievedAt ??
+ `${date}T00:00:00.000Z`;
+ const sourceName =
+ event.verification.source.publisher?.trim() ||
+ new URL(event.sourceUrl).hostname.replace(/^www\./, "");
+
+ return {
+ id: `dev-${event.id.replace(/^tl-/, "")}`,
+ title: event.title,
+ url: event.sourceUrl,
+ sourceId: "editorial-timeline",
+ sourceName,
+ jurisdiction: event.jurisdiction,
+ publishedAt: date,
+ publishedAtPrecision: event.datePrecision ?? "day",
+ detectedAt: assessedAt,
+ summary: event.description,
+ relevanceScore: 1,
+ classification: "curated",
+ assessment: {
+ method: "editorial",
+ assessedAt,
+ promptVersion: "editorial-timeline-projection-v1",
+ },
+ verification: event.verification,
+ status: "promoted",
+ ...(event.relatedPolicyId
+ ? { relatedPolicyId: event.relatedPolicyId }
+ : {}),
+ relatedTimelineEventId: event.id,
+ };
+}
+
+async function getLegacyTimelineDevelopments(now: Date): Promise {
+ const [events, policies] = await Promise.all([
+ getTimelineEvents(undefined, { includeGenerated: false, now }),
+ getPolicies(undefined, { now }),
+ ]);
+ const publicPolicyIds = new Set(policies.map((policy) => policy.id));
+ const timelineTypes = new Set(TIMELINE_EVENT_TYPES);
+
+ return events
+ .filter(
+ (event) =>
+ timelineTypes.has(event.type) &&
+ !(
+ event.relatedPolicyId &&
+ publicPolicyIds.has(event.relatedPolicyId) &&
+ POLICY_LIFECYCLE_TIMELINE_TYPES.has(event.type)
+ ),
+ )
+ .map(timelineEventAsDevelopment);
+}
+
export async function getDevelopments(filters?: {
jurisdiction?: string;
status?: string;
@@ -933,6 +1010,20 @@ export async function getDevelopments(filters?: {
const now = options.now ?? new Date();
let developments = await readJsonFile(DEVELOPMENTS_FILE, []);
if (access === "public") {
+ const legacyTimelineDevelopments = await getLegacyTimelineDevelopments(now);
+ for (const development of legacyTimelineDevelopments) {
+ if (
+ !developments.some(
+ (existing) =>
+ existing.status !== "dismissed" &&
+ (existing.relatedTimelineEventId ===
+ development.relatedTimelineEventId ||
+ sourceUrlsEqual(existing.url, development.url)),
+ )
+ ) {
+ developments.push(development);
+ }
+ }
// Terminal review state is authoritative even if a recoverable development
// side-effect write failed. Derive the safe public projection from both
// files so an editorial rejection can never remain visible.
diff --git a/src/types/index.ts b/src/types/index.ts
index ee90a13..71bc097 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -143,6 +143,13 @@ export const TIMELINE_EVENT_TYPES = [
export type TimelineEventType = (typeof TIMELINE_EVENT_TYPES)[number];
+export const POLICY_LIFECYCLE_TIMELINE_EVENT_TYPES = [
+ "policy_introduced",
+ "policy_amended",
+ "policy_repealed",
+ "policy_superseded",
+] as const satisfies readonly TimelineEventType[];
+
export type AgencyLevel = "federal" | "state";
export const POLICY_DATE_TYPES = [