diff --git a/src/core/chronicle/extract-events.ts b/src/core/chronicle/extract-events.ts index 2bcf85964..4354614ea 100644 --- a/src/core/chronicle/extract-events.ts +++ b/src/core/chronicle/extract-events.ts @@ -73,6 +73,9 @@ export function isValidProposal(e: unknown): e is ChronicleEventProposal { const o = e as Record; return ( typeof o.when === 'string' && o.when.length >= 4 && + // Must be a REAL parseable date — otherwise isoDay()/::date would write a + // garbage event page and then throw on the projection cast (partial write). + !Number.isNaN(new Date(o.when).getTime()) && typeof o.what === 'string' && o.what.trim().length > 0 && Array.isArray(o.who) && o.who.every((w) => typeof w === 'string') && typeof o.kind === 'string' diff --git a/src/core/operations.ts b/src/core/operations.ts index 5d202e9f5..e8d28913c 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -5217,10 +5217,18 @@ const ontology_conflicts: Operation = { params: { min_confidence: { type: 'number', description: 'Only consider observations at/above this confidence (0..1).' }, }, - handler: async (ctx, p) => ctx.engine.findOntologyConflicts({ - minConfidence: typeof p.min_confidence === 'number' ? p.min_confidence : undefined, - ...sourceScopeOpts(ctx), - }), + handler: async (ctx, p) => { + const conflicts = await ctx.engine.findOntologyConflicts({ + minConfidence: typeof p.min_confidence === 'number' ? p.min_confidence : undefined, + ...sourceScopeOpts(ctx), + }); + if (ctx.remote === false) return conflicts; + // Remote: redact diary-sourced values; drop conflicts that no longer have + // ≥2 distinct values once diary provenance is removed (no leak via conflicts). + return conflicts + .map((c) => ({ ...c, values: c.values.filter((v) => !(v.source ?? '').startsWith('life/diary/')) })) + .filter((c) => new Set(c.values.map((v) => v.value)).size >= 2); + }, cliHints: { name: 'ontology-contradictions' }, }; diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index aaae32fff..abe1a386f 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -3627,7 +3627,7 @@ export class PGLiteEngine implements BrainEngine { const r = await this.db.query( `WITH cur AS ( SELECT entity_slug, dimension, value, source_markdown_slug AS source, confidence, id AS fact_id - FROM facts WHERE dimension IS NOT NULL AND expired_at IS NULL + FROM facts WHERE dimension IS NOT NULL AND expired_at IS NULL AND valid_until IS NULL AND (dim_status IS NULL OR dim_status = 'active') AND confidence >= $1 ${scope} ) SELECT entity_slug, dimension, diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index 91c685a1c..19131c73e 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -3681,7 +3681,7 @@ export class PostgresEngine implements BrainEngine { WITH cur AS ( SELECT entity_slug, dimension, value, source_markdown_slug AS source, confidence, id AS fact_id FROM facts - WHERE dimension IS NOT NULL AND expired_at IS NULL + WHERE dimension IS NOT NULL AND expired_at IS NULL AND valid_until IS NULL AND (dim_status IS NULL OR dim_status = 'active') AND confidence >= ${minConf} ${scope} ) diff --git a/test/chronicle-extract.test.ts b/test/chronicle-extract.test.ts index 671e292c8..404177af4 100644 --- a/test/chronicle-extract.test.ts +++ b/test/chronicle-extract.test.ts @@ -97,6 +97,15 @@ describe('runChronicleExtract', () => { expect(await countEvents()).toBe(before); // no partial write }); + test('parse barrier: a non-date `when` writes NOTHING (codex fix #2)', async () => { + const before = await countEvents(); + const badDate: ChronicleJudge = async () => ({ events: [{ when: 'not-a-date', who: [], what: 'x', kind: 'meeting' }] }); + const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: badDate }); + expect(r.status).toBe('skipped'); + expect(r.reason).toBe('malformed_proposal'); + expect(await countEvents()).toBe(before); + }); + test('no events → no_events status', async () => { const none: ChronicleJudge = async () => ({ events: [] }); const r = await runChronicleExtract(engine, { slug: 'meetings/2026-06-18-sync', judge: none }); diff --git a/test/chronicle-ontology-ops.test.ts b/test/chronicle-ontology-ops.test.ts index 0f6030033..c043e40da 100644 --- a/test/chronicle-ontology-ops.test.ts +++ b/test/chronicle-ontology-ops.test.ts @@ -40,6 +40,16 @@ describe('ontology ops', () => { expect(remote.some((r) => (r.source ?? '').startsWith('life/diary/'))).toBe(false); }); + test('remote ontology_conflicts redacts diary-sourced disagreement (codex fix #3)', async () => { + // A conflict where one side is diary-sourced: advisor (meeting) vs founder (diary). + await engine.mergeOntologyFact({ entitySlug: 'people/y', dimension: 'role', value: 'advisor', source: 'meetings/a', validFrom: '2026-05-01' }); + await engine.mergeOntologyFact({ entitySlug: 'people/y', dimension: 'role', value: 'founder', source: 'life/diary/2026-06-18', validFrom: '2026-01-01' }); + const local = await operationsByName.ontology_conflicts.handler(ctx(false), {}) as { entity_slug: string }[]; + expect(local.some((c) => c.entity_slug === 'people/y')).toBe(true); + const remote = await operationsByName.ontology_conflicts.handler(ctx(true), {}) as { entity_slug: string }[]; + expect(remote.some((c) => c.entity_slug === 'people/y')).toBe(false); // diary side redacted → no longer a disagreement + }); + test('ontology_dimensions + ontology_conflicts surface via ops', async () => { await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'advisor', source: 'meetings/a', validFrom: '2026-05-01' }); await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'founder', source: 'meetings/b', validFrom: '2026-01-01' }); diff --git a/test/chronicle-ontology.test.ts b/test/chronicle-ontology.test.ts index 8bf20b5b7..5ef2f1bf8 100644 --- a/test/chronicle-ontology.test.ts +++ b/test/chronicle-ontology.test.ts @@ -82,6 +82,15 @@ describe('findOntologyConflicts + discoverOntologyDimensions', () => { expect(bobRole!.values.map((v) => v.value).sort()).toEqual(['advisor', 'founder']); }); + test('forward supersession is NOT reported as a conflict (only live disagreement is)', async () => { + // founder (2024) → advisor (2026): the founder row is closed via valid_until, + // so it must NOT count as a current conflict (codex pre-landing fix #1). + await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'founder', source: 'm/a', validFrom: '2024-01-01' }); + await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'advisor', source: 'm/b', validFrom: '2026-05-01' }); + const conflicts = await engine.findOntologyConflicts(); + expect(conflicts.some((c) => c.entity_slug === SARAH && c.dimension === 'role')).toBe(false); + }); + test('discoverOntologyDimensions rolls up by dimension', async () => { await engine.mergeOntologyFact({ entitySlug: SARAH, dimension: 'role', value: 'founder', source: 'meetings/a' }); await engine.mergeOntologyFact({ entitySlug: 'people/bob', dimension: 'role', value: 'advisor', source: 'meetings/b' });