Files
Dragan Spiridonov b62eb83ad4 fix(learning): rehydrate Date fields at all kv-read seams (#493 follow-up)
#493 was not the last instance of the kv-Date hazard — it was the most
reachable. Audit of the rest of the learning pipeline found 11 more
unprotected kv-read sites where TypeScript declares `Date` but JSON
round-trip through `memory.get` yields an ISO string at runtime.

Two failure modes, both already documented at
learning-coordinator.ts:898-904 (#491 Bug 3):

  1. THROW — `record.someDate.getTime()` raises TypeError. Surfaces
     immediately on the first invocation once any record exists.
  2. SILENT — `timeRange.contains(record.someDate)` or
     `new Date() > record.expiresAt` coerces string vs Date to NaN; all
     comparisons return false; records are silently filtered out or
     expiry is never recognised. No error, just empty/stale analytics.

The silent variant is the worse one — #491 Bug 3 was the same class
and shipped for a release cycle undetected.

Strategic fix: a single `rehydrateDates<T>(record, dateFields[])` helper
at `src/shared/utils/kv-date-rehydrate.ts` applied at every kv-read seam
that touches Date-bearing types. Shallow-copies the record and coerces
named string fields back to Date; pass-through for already-Date, null,
undefined.

Sites patched (11 total):

  src/domains/learning-optimization/services/
    production-intel.ts       — 4 sites (HIGH #2-#5 throw, MEDIUM #8 silent)
                                 getMetricsHistory, getRecentIncidents (×2),
                                 resolveIncident, getRecentMilestones
    metrics-optimizer.ts      — 1 site (HIGH #1 throw)
                                 getMetricsHistory.sort
    learning-coordinator.ts   — 1 site (HIGH #6 throw)
                                 getReplayBuffer.sort
    transfer-specialist.ts    — 4 sites (HIGH #7 throw, MEDIUM #9 silent)
                                 queryKnowledge (×2), getKnowledgeById,
                                 bulkTransfer
  src/domains/learning-optimization/
    coordinator-helpers.ts    — 1 site (SILENT)
                                 getExperiencesForDomain
    coordinator.ts            — 1 site (SILENT, duplicate of above)
                                 getExperiencesForDomain (private)

Sites verified SAFE (kv-read but only access non-Date fields):
  - coordinator.ts:1045/1134/1175 — uses .domain/.confidence/.version
  - metrics-optimizer.ts:676      — uses .metrics map
  - learning-coordinator.ts:379/438/483/547/830/842 — uses
    .confidence/.successRate/.domain/.usageCount
  - learning-coordinator.ts:901 — already has #491 Bug 3 inline
    rehydration with instanceof guard
  - experience-capture.ts:520/563 — TaskExperience uses number
    timestamps, not Date

Sites NOT patched (deferred per scope):
  - coordination/protocols/learning-consolidation.ts — defined but
    never imported anywhere. Dead code; would resurrect the bug if
    reactivated. Documented in the audit but not fixed.

Regression tests (29 new, 6 files):
  tests/unit/shared/utils/kv-date-rehydrate.test.ts             (10)
  tests/unit/domains/learning-optimization/
    production-intel-date-coercion.test.ts                       (5)
    metrics-optimizer-date-coercion.test.ts                      (2)
    learning-coordinator-date-coercion.test.ts                   (2)
    transfer-specialist-date-coercion.test.ts                    (5)
    coordinator-helpers-date-coercion.test.ts                    (2)

Each service test seeds its mock memory backend with kv-shape records
(ISO strings instead of Date instances — matching the post-JSON.parse
shape that better-sqlite3-backed memory yields in production) and
exercises the kv-read → consumer path that would fail pre-fix. Both
throw and silent failure modes are pinned: throws are asserted not to
fire, silent failures are caught by behavioural assertions (e.g. the
expired-knowledge test asserts `expired.score < live.score * 0.5 ±`,
which would fail pre-fix because the 0.5 penalty never triggered).

Verification:
  - 29/29 new regression tests green
  - 135/135 existing tests for patched services green (production-intel,
    metrics-optimizer, transfer-specialist, learning-coordinator,
    learning-consolidation, learning-consolidation-mining,
    learning-consolidation-liveness)
  - `tsc --noEmit` clean
  - `npm run build` produces dist with rehydrateDates at all 17
    expected call sites across the 6 patched files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:38:53 +00:00

167 lines
5.6 KiB
TypeScript

/**
* Unit tests for the kv-Date rehydration helper.
*
* Pins the contract that the cross-service kv-Date fixes depend on. If this
* regresses, every consumer (production-intel, metrics-optimizer,
* transfer-specialist, learning-coordinator.getReplayBuffer) loses its
* protection against the #493 / #491-Bug-3 bug class.
*/
import { describe, it, expect } from 'vitest';
import { rehydrateDates, rehydrateDatesAll } from '../../../../src/shared/utils/kv-date-rehydrate';
interface SampleRecord {
id: string;
createdAt: Date;
expiresAt?: Date;
payload: { tag: string };
}
describe('rehydrateDates', () => {
it('coerces an ISO-string field to a real Date', () => {
const iso = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const kvRecord = {
id: 'r1',
createdAt: iso as unknown as Date,
payload: { tag: 'a' },
} as SampleRecord;
const result = rehydrateDates(kvRecord, ['createdAt'])!;
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.createdAt.toISOString()).toBe(iso);
// Non-date fields untouched.
expect(result.id).toBe('r1');
expect(result.payload).toEqual({ tag: 'a' });
});
it('returns a shallow COPY — does not mutate the input', () => {
const iso = new Date().toISOString();
const input = {
id: 'r1',
createdAt: iso as unknown as Date,
payload: { tag: 'a' },
} as SampleRecord;
const result = rehydrateDates(input, ['createdAt'])!;
expect(result).not.toBe(input);
// Input's runtime shape is preserved — important when the helper is
// called from a service that hands the same record to multiple readers.
expect(typeof (input as unknown as { createdAt: unknown }).createdAt).toBe('string');
});
it('leaves an already-Date field untouched', () => {
const realDate = new Date();
const input = {
id: 'r1',
createdAt: realDate,
payload: { tag: 'a' },
} as SampleRecord;
const result = rehydrateDates(input, ['createdAt'])!;
expect(result.createdAt).toBe(realDate); // Same instance — no needless allocation.
});
it('handles optional Date fields cleanly', () => {
const iso = new Date().toISOString();
const onlyCreated = {
id: 'r1',
createdAt: iso as unknown as Date,
payload: { tag: 'a' },
} as SampleRecord;
const withExpiry = {
id: 'r2',
createdAt: iso as unknown as Date,
expiresAt: iso as unknown as Date,
payload: { tag: 'b' },
} as SampleRecord;
const r1 = rehydrateDates(onlyCreated, ['createdAt', 'expiresAt'])!;
const r2 = rehydrateDates(withExpiry, ['createdAt', 'expiresAt'])!;
expect(r1.createdAt).toBeInstanceOf(Date);
expect(r1.expiresAt).toBeUndefined();
expect(r2.createdAt).toBeInstanceOf(Date);
expect(r2.expiresAt).toBeInstanceOf(Date);
});
it('returns null/undefined inputs as-is', () => {
expect(rehydrateDates(null, ['createdAt'])).toBeNull();
expect(rehydrateDates(undefined, ['createdAt'])).toBeUndefined();
});
it('coerces multiple fields in a single call', () => {
const isoCreated = '2026-05-01T00:00:00.000Z';
const isoExpires = '2026-06-01T00:00:00.000Z';
const input = {
id: 'r1',
createdAt: isoCreated as unknown as Date,
expiresAt: isoExpires as unknown as Date,
payload: { tag: 'a' },
} as SampleRecord;
const result = rehydrateDates(input, ['createdAt', 'expiresAt'])!;
expect(result.createdAt.toISOString()).toBe(isoCreated);
expect(result.expiresAt!.toISOString()).toBe(isoExpires);
});
it('round-trip safety: rehydrated Dates round-trip cleanly through .getTime()', () => {
// This is THE assertion that pins the #493 fix: post-rehydrate, the
// downstream consumer's `.getTime()` returns a sensible number, not a
// throw. Mirrors the exact failure mode from the issue.
const iso = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const kvRecord = {
id: 'r1',
createdAt: iso as unknown as Date,
payload: { tag: 'a' },
} as SampleRecord;
const result = rehydrateDates(kvRecord, ['createdAt'])!;
// The original kv shape would throw here.
expect(() => result.createdAt.getTime()).not.toThrow();
expect(result.createdAt.getTime()).toBe(new Date(iso).getTime());
});
});
describe('rehydrateDatesAll', () => {
it('rehydrates every record in an array', () => {
const iso1 = '2026-05-01T00:00:00.000Z';
const iso2 = '2026-05-02T00:00:00.000Z';
const records: SampleRecord[] = [
{ id: 'a', createdAt: iso1 as unknown as Date, payload: { tag: 'x' } },
{ id: 'b', createdAt: iso2 as unknown as Date, payload: { tag: 'y' } },
];
const result = rehydrateDatesAll(records, ['createdAt']);
expect(result).toHaveLength(2);
expect(result[0]!.createdAt).toBeInstanceOf(Date);
expect(result[1]!.createdAt).toBeInstanceOf(Date);
expect(result[0]!.createdAt.toISOString()).toBe(iso1);
});
it('filters out null/undefined entries (common when memory.get returns nothing)', () => {
const iso = new Date().toISOString();
const records = [
{ id: 'a', createdAt: iso as unknown as Date, payload: { tag: 'x' } },
null,
undefined,
{ id: 'b', createdAt: iso as unknown as Date, payload: { tag: 'y' } },
] as Array<SampleRecord | null | undefined>;
const result = rehydrateDatesAll(records, ['createdAt']);
expect(result).toHaveLength(2);
expect(result.map((r) => r.id)).toEqual(['a', 'b']);
});
it('returns an empty array for an empty input', () => {
expect(rehydrateDatesAll([], ['createdAt'])).toEqual([]);
});
});