From 13d83183d070cbf7356569250b43c24ae7d1d369 Mon Sep 17 00:00:00 2001 From: akoita Date: Tue, 15 Sep 2026 03:17:03 +0200 Subject: [PATCH] fix(knowledge): stabilize mutation timestamps --- docs/roadmap.md | 3 +- .../application/src/knowledge-base.test.ts | 10 +++++++ packages/application/src/knowledge-base.ts | 6 ++-- .../src/monotonic-timestamp.test.ts | 28 +++++++++++++++++++ .../application/src/monotonic-timestamp.ts | 15 ++++++++++ 5 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 packages/application/src/monotonic-timestamp.test.ts create mode 100644 packages/application/src/monotonic-timestamp.ts diff --git a/docs/roadmap.md b/docs/roadmap.md index 3277b26..46378cd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -177,7 +177,7 @@ applications. | Previous | Integration hardening and outcome validation ([v0.6.0](https://github.com/akoita/draft-loop/releases/tag/v0.6.0)) | Released; validation failed | Preserve a reproducible integrated baseline without overstating application readiness | Failed representative result carried into v0.7; see [stage evidence](stage-evidence-v0.6.0.md) | | Previous | Evidence-backed CV drafting (v0.7 program) | [Released alpha.5 checkpoint](stage-evidence-v0.7.0-alpha.5.md); implementation history carried forward; outcome not validated | Produce a complete factual, source-traceable application draft | v0.8 candidate evidence now covers the bounded drafting and review vertical | | Previous | Usable CV MVP ([v0.8.0-alpha.1](https://github.com/akoita/draft-loop/releases/tag/v0.8.0-alpha.1)) | [Released alpha](stage-evidence-v0.8.0-alpha.1.md); 17/17 issues closed; representative outcome not recorded | Produce one complete, factual, reviewed, human-approved, ATS-readable CV | Representative outcome evidence remains without overstating DOCX visual coverage | -| Now | Workflow parity and release ([milestone v0.9.0](https://github.com/akoita/draft-loop/milestone/4)) | [Twenty-five observations across two consented cases are indeterminate](consented-pilot-v0.9.md); parity not validated | Demonstrate the complete application-grade workflow and publish evidence | #350 informed corrections #351/#352; later attempts repeatedly failed before a draft. #376 corrects per-generation cap accounting, #380 adds fixed result diagnostics, and #393/#395 add private category capture. #397 established terminal reason `api_error`, and #398 added its bounded statusless classification. #401 then exhausted three author attempts on local factual and coverage validation without an artifact or review. Any later live observation requires fresh authorization. #75/#250 remain blocked | +| Now | Workflow parity and release ([milestone v0.9.0](https://github.com/akoita/draft-loop/milestone/4)) | [Twenty-five observations across two consented cases are indeterminate](consented-pilot-v0.9.md); parity not validated | Demonstrate the complete application-grade workflow and publish evidence | #350 informed corrections #351/#352; later attempts repeatedly failed before a draft. #376 corrects per-generation cap accounting, #380 adds fixed result diagnostics, and #393/#395 add private category capture. #397 established terminal reason `api_error`, and #398 added its bounded statusless classification. #401 then exhausted three author attempts on local factual and coverage validation without an artifact or review. #389 keeps application-generated mutation timestamps monotonic while retaining storage rollback rejection. Any later live observation requires fresh authorization. #75/#250 remain blocked | | Later | Retrieval and provider quality | Integrated lexical baseline; partial components | Improve evidence selection and dependable live runs | Vector/hybrid comparison, cancellation, and provider recovery in the packaged path | | Later | Broader real-application pilot | Implemented harness; not outcome-validated | Test factuality, quality, and effort across more cases | Consented cases, calibrated measures, and recorded limitations | | Later | Production-ready beta | Partial implementation; not production-validated | Distribute a safe, dependable desktop application | Signed installers, safe migrations, recovery, accessibility, and platform evidence | @@ -919,6 +919,7 @@ issues retain implementation chronology. | Date | Decision | Product implication | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-09-15 | Made candidate-knowledge application timestamps monotonic under #389. | A deterministic backward-clock case reproduced the captured `StorageValidationError`. Candidate-knowledge services now retain their latest valid generated timestamp across wall-clock regressions, while storage anti-rollback validation and invalid-timestamp rejection remain unchanged. | | 2026-09-15 | Recorded #401 as an indeterminate twenty-fifth matched-backend observation. | Both user-session authentication probes passed, but three Anthropic author responses failed local factual and substantive-coverage validation before an artifact or critic review. The run did not exercise #398's API-error branch. Authorization is exhausted, and #75/#250 remain blocked. | | 2026-09-15 | Classified statusless structured Claude API errors under #398. | The documented `api_error` terminal reason and error-result `success` subtype now produce fixed diagnostics. Without a finite numeric status, the failure is transient and retryable only within existing orchestration caps; numeric statuses retain precedence, provider prose remains excluded, and no live attempt is authorized. | | 2026-09-15 | Recorded #397 as an indeterminate twenty-fourth matched-backend observation. | Both authentication probes passed, but one Anthropic author attempt failed non-retryably with `unknown`. Private bounded capture established terminal reason `api_error` but no subtype or underlying cause; no artifact or critic review occurred. Authorization is exhausted, #398 owns classification, and #75/#250 remain blocked. | diff --git a/packages/application/src/knowledge-base.test.ts b/packages/application/src/knowledge-base.test.ts index d4ae2e1..54be6e6 100644 --- a/packages/application/src/knowledge-base.test.ts +++ b/packages/application/src/knowledge-base.test.ts @@ -6218,6 +6218,16 @@ describe("candidate knowledge store application service", () => { expect(current).toEqual({ sourceId: "source-uuid", checkedAt: changedAt, status: "current" }); expect(generateId).not.toHaveBeenCalled(); expect(now).toHaveBeenCalledOnce(); + now.mockReturnValue(createdAt); + now.mockClear(); + await expect( + service.refreshKnowledgeSourceFromOrigin({ + storeRoot, + knowledgeBaseId: "default-ckb-uuid", + sourceId: "source-uuid", + }), + ).resolves.toEqual({ sourceId: "source-uuid", checkedAt: changedAt, status: "current" }); + expect(now).toHaveBeenCalledOnce(); const refreshState = await service.getKnowledgeSourceRefreshState({ storeRoot, knowledgeBaseId: "default-ckb-uuid", diff --git a/packages/application/src/knowledge-base.ts b/packages/application/src/knowledge-base.ts index 393f0cf..c2cbc40 100644 --- a/packages/application/src/knowledge-base.ts +++ b/packages/application/src/knowledge-base.ts @@ -76,6 +76,7 @@ import { restoreCandidateKnowledgePortableBackup, } from "@draft-loop/storage/knowledge-store"; import { StorageWriterLeaseError } from "@draft-loop/storage/writer-lease"; +import { monotonicTimestamp } from "./monotonic-timestamp.js"; export type { CandidateKnowledgeBase, @@ -3856,8 +3857,7 @@ async function useWriterHandle( ): Promise { return useHandle(acquire, (handle) => { if (typeof handle.withWriterLease !== "function") { - // Narrow compatibility seam for unit-test adapters. Production handles - // always expose the coordinated writer contract. + // Narrow compatibility seam for unit-test adapters; production handles expose this contract. return callback(handle); } return handle.withWriterLease(operation, () => callback(handle)); @@ -3869,7 +3869,7 @@ function resolveDependencies( ): ResolvedDependencies { return { generateId: dependencies.generateId ?? randomUUID, - now: dependencies.now ?? (() => new Date().toISOString()), + now: monotonicTimestamp(dependencies.now ?? (() => new Date().toISOString())), initialize: dependencies.initialize ?? initializeCandidateKnowledgeStore, open: dependencies.open ?? openCandidateKnowledgeStore, exportPortableBackup: diff --git a/packages/application/src/monotonic-timestamp.test.ts b/packages/application/src/monotonic-timestamp.test.ts new file mode 100644 index 0000000..3dc5739 --- /dev/null +++ b/packages/application/src/monotonic-timestamp.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { monotonicTimestamp } from "./monotonic-timestamp.js"; + +describe("monotonicTimestamp", () => { + it("keeps the latest valid value when the wall clock moves backward", () => { + const values = [ + "2026-09-15T01:00:00.000Z", + "2026-09-15T02:00:00.000Z", + "2026-09-15T01:30:00.000Z", + ]; + const now = monotonicTimestamp(() => values.shift() as string); + + expect([now(), now(), now()]).toEqual([ + "2026-09-15T01:00:00.000Z", + "2026-09-15T02:00:00.000Z", + "2026-09-15T02:00:00.000Z", + ]); + }); + + it("passes invalid values through for the existing validation boundary", () => { + const values = ["2026-09-15T01:00:00.000Z", "not-a-time"]; + const now = monotonicTimestamp(() => values.shift() as string); + + expect(now()).toBe("2026-09-15T01:00:00.000Z"); + expect(now()).toBe("not-a-time"); + }); +}); diff --git a/packages/application/src/monotonic-timestamp.ts b/packages/application/src/monotonic-timestamp.ts new file mode 100644 index 0000000..486791e --- /dev/null +++ b/packages/application/src/monotonic-timestamp.ts @@ -0,0 +1,15 @@ +/** Keep application-generated timestamps nondecreasing across wall-clock regressions. */ +export function monotonicTimestamp(clock: () => string): () => string { + let latestTime = Number.NEGATIVE_INFINITY; + let latestValue: string | undefined; + + return () => { + const value = clock(); + const time = Date.parse(value); + if (!Number.isFinite(time)) return value; + if (time < latestTime && latestValue !== undefined) return latestValue; + latestTime = time; + latestValue = value; + return value; + }; +}