From 7c1f4f873d53da972926a8e5c30af417501ebd1c Mon Sep 17 00:00:00 2001
From: Codex
Date: Thu, 27 Aug 2026 18:09:02 +0900
Subject: [PATCH] feat(ask): honor optional knowledge cutoff on Global Ask
Flatten the 29-commit branch onto current main and re-apply only the
net knowledge-cutoff contribution: Global Ask accepts an optional UTC
knowledge_cutoff, retrieves only posts available by that clock, cites
the retained source_post_revision, and names when a historical body was
not kept. Omitting the cutoff keeps the live-query contract (ADR 0216).
---
AGENTS.md | 3 ++
ARCHITECTURE.md | 4 +-
.../2.19.0-global-ask-knowledge-cutoff.md | 6 +++
CHANGELOG.md | 6 ++-
CLAUDE.md | 3 +-
backend/app/main.py | 3 ++
backend/app/post_chat_ingestion.py | 3 +-
backend/app/source_post_revision.py | 2 +-
frontend/src/App.test.tsx | 40 +++++++++++++++++++
frontend/src/App.tsx | 22 ++++++++--
frontend/src/AskAgentCutoff.stories.tsx | 5 ++-
frontend/src/AskAgentPanel.test.tsx | 2 +-
frontend/src/api.ts | 10 +++++
frontend/src/i18n.ts | 16 ++++++++
lineageweave/post_chat.py | 2 +
tests/test_global_ask_sources.py | 2 +
tests/test_source_post_revision.py | 16 +++++++-
17 files changed, 133 insertions(+), 12 deletions(-)
create mode 100644 CHANGELOG.d/2.19.0-global-ask-knowledge-cutoff.md
diff --git a/AGENTS.md b/AGENTS.md
index c927f9e61..a58bb01a4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -332,6 +332,9 @@ v0.88.0). Do not invent a theta.
Opening a cutoff-rewritten title shows **Body this run knew** from
`source_post_revision` beside the live rewrite (ADR 0025 / v2.1.0).
Do not invent the earlier sentence when no revision covers the cutoff.
+Global Ask uses the same revision cover when `knowledge_cutoff` is set
+(ADR 0216 / #271); omit the field to keep the live-query contract, and
+never substitute a live body for a missing historical cover.
A corporate-entity similarity result has three outcomes: unique, miss,
or tie (ADR 0026). A tie is not a miss. Keep the organization name
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1f1c68f02..df6449854 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -240,7 +240,9 @@ Each direct edge includes `interval_relation_code` /
`interval_relation_label` computed from the posts' observed windows.
Global Ask merges cited threads from one post/edge fetch pair and
caps the payload at the landing node bound, keeping cited posts first
-(ADR 0169). Open a cited post to read the focused thread.
+(ADR 0169). Optional `knowledge_cutoff` on `POST /api/ask` selects the
+covering `source_post_revision` and never substitutes a live body
+(ADR 0216). Open a cited post to read the focused thread.
`POST /api/lineage/rebuild` (`post_admin`) re-runs `reconstruct()` over
every `source_post` and atomically rewrites edges, channel signals, and
Allen interval relations. Reconstruct grouping is
diff --git a/CHANGELOG.d/2.19.0-global-ask-knowledge-cutoff.md b/CHANGELOG.d/2.19.0-global-ask-knowledge-cutoff.md
new file mode 100644
index 000000000..fe685c470
--- /dev/null
+++ b/CHANGELOG.d/2.19.0-global-ask-knowledge-cutoff.md
@@ -0,0 +1,6 @@
+# 2.19.0 Global Ask knowledge cutoff
+
+Ask Agent now accepts an optional UTC knowledge cutoff. Dated questions use
+the retained source-post revision from that clock, never the live rewrite,
+and say when a historical body was not kept. Leaving the cutoff blank keeps
+the live-query contract.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e59db82d9..950e1eed2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,6 @@ All notable changes to this project are documented here. Format follows
### Added
-
- Expanded Voice-of-X post taxonomy (ADR 0246): the governed `voc_type`
scheme adds Voice of Supplier, Employee, Business, Regulator, Investor,
Society, and Process as source-post categories. Ontology SKOS concepts and
@@ -63,6 +62,11 @@ All notable changes to this project are documented here. Format follows
deterministic application read
model (`lineageweave.worker_function_taxonomy`) exposes fail-closed
lookups; ranks are scale positions and are never used as weights.
+=======
+- Global Ask accepts an optional UTC `knowledge_cutoff`. Dated questions
+ retrieve only posts available by that clock, cite the retained
+ `source_post_revision`, and name when a historical body was not kept.
+ Omitting the cutoff keeps the live-query contract (ADR 0216 / #271).
- Persist explicit paragraph, list, table, MathML formula, and caller-parsed
conversation-turn semantic-unit kinds without inferring absent boundaries.
- Event Lineage now persists each reconstructed connection's independent
diff --git a/CLAUDE.md b/CLAUDE.md
index eb9e85eab..06f90d0ad 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -42,7 +42,8 @@ Opening a cutoff-rewritten title shows **Body this run knew** from
`source_post_revision` beside the live rewrite, with both clocks named.
Compare those two texts before treating the live body as reconstructed
evidence; do not invent an earlier sentence when no revision covers the
-cutoff.
+cutoff. Global Ask optional `knowledge_cutoff` uses the same cover
+(ADR 0216).
## Where the rest lives
diff --git a/backend/app/main.py b/backend/app/main.py
index c16ca2ec7..91517e9eb 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -3161,6 +3161,9 @@ async def ask_agent(
polls ``GET /api/ask/jobs/{id}`` for the settled answer. Submission
still fails fast on the states that cannot ever succeed (blank
question, missing permission, unconfigured orchestrator).
+
+ Optional ``knowledge_cutoff`` selects retained evidence available at
+ that clock. Omitting it keeps the live-query contract (ADR 0216).
"""
return await submit_global_ask(
pool=pool,
diff --git a/backend/app/post_chat_ingestion.py b/backend/app/post_chat_ingestion.py
index 4a208c13c..5e17c9696 100644
--- a/backend/app/post_chat_ingestion.py
+++ b/backend/app/post_chat_ingestion.py
@@ -216,7 +216,6 @@ def _seoul_today() -> date:
_POST_CHAT_CANDIDATE_LIMIT = 32
-
def _source_hint_facts(row: Any) -> tuple[str, ...]:
"""Render raw source fields as explicitly weak, column-level evidence."""
facts: list[str] = []
@@ -834,6 +833,8 @@ async def gather_global_chat_sources(
# `find_linked_post_ids`'s `.direct` set used by the post-scoped chat
# flow. Only the top match is expanded so lower-ranked semantic candidates
# cannot each pull a separate lineage chain into the bounded context.
+ # Cutoff answers skip this expansion: reconstructed edges have no
+ # available-time contract and must not leak later neighbors (ADR 0216).
lineage_neighbor_ids: list[str] = []
lineage_anchor_id = candidate_ids[0] if candidate_ids else None
if lineage_anchor_id and knowledge_cutoff is None:
diff --git a/backend/app/source_post_revision.py b/backend/app/source_post_revision.py
index 3b51e09d3..70953cfd2 100644
--- a/backend/app/source_post_revision.py
+++ b/backend/app/source_post_revision.py
@@ -100,7 +100,7 @@ async def fetch_known_at_revisions(
) -> dict[str, dict[str, str]]:
"""Batch-load the retained revision covering ``as_of`` for each post.
- Missing posts stay absent so callers can report an honest historical-body
+ Missing covers are omitted so callers can report an honest historical-body
limitation without substituting the live title or body.
"""
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 9ffb1920a..860fc6588 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import App from "./App";
+import { optionalKnowledgeCutoffIso } from "./api";
import { setLocale } from "./i18n";
import { OIDC_RETURN_URL_STORAGE_KEY } from "./oidcReturnUrl";
@@ -27,6 +28,16 @@ beforeEach(() => {
};
});
+it("normalizes valid knowledge cutoffs and rejects invalid input", () => {
+ expect(optionalKnowledgeCutoffIso("")).toBeUndefined();
+ expect(optionalKnowledgeCutoffIso("2026-01-15T12:00")).toBe(
+ new Date("2026-01-15T12:00").toISOString(),
+ );
+ expect(() => optionalKnowledgeCutoffIso("not-a-date")).toThrow(
+ "invalid knowledge cutoff",
+ );
+});
+
afterEach(() => {
vi.unstubAllGlobals();
window.history.replaceState({}, "", "/");
@@ -1971,6 +1982,35 @@ describe("App, authenticated", () => {
expect(screen.queryByText(/ontology_iri|contextual_orchestrator/i)).not.toBeInTheDocument();
});
+ it("converts the local knowledge cutoff to UTC for Global Ask", async () => {
+ const fetchMock = stubBackend();
+ render();
+ expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Ask Agent" }));
+ expect(screen.getByLabelText("Use evidence available by (optional)")).toBeInTheDocument();
+ expect(screen.getByText("Choose a time on this device, or leave blank to use the latest evidence.")).toBeInTheDocument();
+ await userEvent.type(screen.getByRole("textbox", { name: "Ask a question" }), "Phoenix?");
+ await userEvent.type(
+ screen.getByLabelText("Use evidence available by (optional)"),
+ "2026-01-15T12:00",
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Ask" }));
+ expect(
+ await screen.findByText("The cited project is supported by the stored semantic evidence."),
+ ).toBeInTheDocument();
+ const askCall = fetchMock.mock.calls.find(
+ ([input, init]) => String(input).endsWith("/api/ask") && (init as RequestInit | undefined)?.method === "POST",
+ );
+ expect(askCall).toBeTruthy();
+ const askInit = askCall?.[1] as RequestInit | undefined;
+ expect(askInit).toBeDefined();
+ expect(JSON.parse(String(askInit?.body))).toEqual({
+ question: "Phoenix?",
+ verify_external: false,
+ knowledge_cutoff: new Date("2026-01-15T12:00").toISOString(),
+ });
+ });
+
it("localizes Ask delivery copy instead of rendering Korean literals in English", async () => {
stubBackend({ askDelivery: true });
render();
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 0d78acd57..f8adb6881 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -8,6 +8,7 @@ import { useAuth } from "react-oidc-context";
import {
askPostChat,
askAgent,
+ optionalKnowledgeCutoffIso,
BackendError,
createAnalysisRun,
startAnalysisRun,
@@ -4830,11 +4831,11 @@ export function AskAgentPanel({
onOpenPost: (postId: string) => void;
}) {
const [question, setQuestion] = useState("");
+ const [knowledgeCutoff, setKnowledgeCutoff] = useState("");
const [answer, setAnswer] = useState(null);
const [error, setError] = useState(null);
const [asking, setAsking] = useState(false);
const [verifyExternal, setVerifyExternal] = useState(false);
- const [knowledgeCutoff, setKnowledgeCutoff] = useState("");
const [evidenceLayerPostId, setEvidenceLayerPostId] = useState(null);
const now = new Date();
const localKnowledgeCutoffMax = new Date(
@@ -4844,6 +4845,14 @@ export function AskAgentPanel({
async function handleAsk() {
const normalized = question.trim();
if (!normalized) return;
+ let cutoff: string | undefined;
+ try {
+ cutoff = optionalKnowledgeCutoffIso(knowledgeCutoff);
+ } catch {
+ setAnswer(null);
+ setError(t("Enter a valid knowledge cutoff, then ask again."));
+ return;
+ }
setAsking(true);
setError(null);
try {
@@ -4852,7 +4861,7 @@ export function AskAgentPanel({
accessToken,
normalized,
verifyExternal,
- knowledgeCutoff ? new Date(knowledgeCutoff).toISOString() : undefined,
+ cutoff,
),
);
} catch (err) {
@@ -4888,13 +4897,15 @@ export function AskAgentPanel({
{t("Check eligible public claims")}