From d81d7151e55a51dd41b5a6c1e59602eb2f62e7c2 Mon Sep 17 00:00:00 2001 From: udeachudivine-spec Date: Sat, 25 Jul 2026 11:43:17 +0100 Subject: [PATCH] fix(claims): ensure resolvedAt is set on every resolve path (Closes #219, BE-219) - Add resolvedAt nullable timestamp column to Claim entity - Add migration 1769600000000-AddClaimResolvedAt with backfill for existing resolved rows (resolvedAt = createdAt as conservative approx) - Add claim-resolution.invariant.ts with assertResolvedAtInvariant(), buildResolvedFields(), buildUnresolvedFields() helpers - Fix ClaimsService.resolveClaim(): set resolvedAt atomically with resolvedVerdict via buildResolvedFields(); add invariant guard before save; upgrade throw to NotFoundException - Fix ClaimResolutionService.resolveClaim(): same atomic set + guard - Fix JobsService.computeScores() (automated/oracle path): use buildResolvedFields() so resolvedAt is set on the job-driven path - Remove duplicate findOne() override in ClaimsService that shadowed the null-returning implementation with a throwing one - Update ClaimFactory: default resolvedAt to null; fix createClaimWithVerdict() to set resolvedAt alongside resolvedVerdict - Add claim-resolution.invariant.spec.ts: full invariant unit tests - Expand claim-resolution.service.spec.ts: resolveClaim resolvedAt tests (timestamp set, atomic write, finalized=true, verdict mapping) - Expand claims.service.spec.ts: resolvedAt timestamp tests, atomicity test, non-resolved null test, idempotency behaviour test Existing bad data note: rows where resolvedVerdict was already set before this migration receive resolvedAt = createdAt as a best-effort backfill. Exact resolution timestamps were not stored previously. Centralising all resolve paths into a single method is recommended as a follow-up (out of scope for medium-complexity budget). --- src/claims/claim-resolution.invariant.spec.ts | 119 ++++++++++++++++++ src/claims/claim-resolution.invariant.ts | 54 ++++++++ src/claims/claim-resolution.service.spec.ts | 82 +++++++++++- src/claims/claim-resolution.service.ts | 12 +- src/claims/claims.service.spec.ts | 79 ++++++++++-- src/claims/claims.service.ts | 30 ++--- src/claims/entities/claim.entity.ts | 8 ++ src/claims/factories/claim.factory.ts | 9 +- src/jobs/jobs.service.ts | 9 +- .../1769600000000-AddClaimResolvedAt.ts | 44 +++++++ 10 files changed, 416 insertions(+), 30 deletions(-) create mode 100644 src/claims/claim-resolution.invariant.spec.ts create mode 100644 src/claims/claim-resolution.invariant.ts create mode 100644 src/migrations/1769600000000-AddClaimResolvedAt.ts diff --git a/src/claims/claim-resolution.invariant.spec.ts b/src/claims/claim-resolution.invariant.spec.ts new file mode 100644 index 0000000..4fbe2d8 --- /dev/null +++ b/src/claims/claim-resolution.invariant.spec.ts @@ -0,0 +1,119 @@ +import { BadRequestException } from '@nestjs/common'; +import { + assertResolvedAtInvariant, + buildResolvedFields, + buildUnresolvedFields, +} from './claim-resolution.invariant'; + +/** + * Unit tests for the resolvedAt/resolvedVerdict invariant (issue #BE-219). + * + * Invariant: resolvedVerdict IS NOT NULL <=> resolvedAt IS NOT NULL + */ +describe('claim-resolution invariant (BE-219)', () => { + // ------------------------------------------------------------------ // + // assertResolvedAtInvariant // + // ------------------------------------------------------------------ // + describe('assertResolvedAtInvariant', () => { + it('passes when both fields are null (unresolved claim)', () => { + expect(() => + assertResolvedAtInvariant({ resolvedVerdict: null, resolvedAt: null }), + ).not.toThrow(); + }); + + it('passes when both fields are set (resolved claim)', () => { + expect(() => + assertResolvedAtInvariant({ + resolvedVerdict: true, + resolvedAt: new Date(), + }), + ).not.toThrow(); + + expect(() => + assertResolvedAtInvariant({ + resolvedVerdict: false, + resolvedAt: new Date(), + }), + ).not.toThrow(); + }); + + it('throws when resolvedVerdict is set but resolvedAt is null (bug scenario)', () => { + expect(() => + assertResolvedAtInvariant({ resolvedVerdict: true, resolvedAt: null }), + ).toThrow(BadRequestException); + + expect(() => + assertResolvedAtInvariant({ resolvedVerdict: false, resolvedAt: null }), + ).toThrow(BadRequestException); + }); + + it('includes BE-219 in the error message for the verdict-without-timestamp case', () => { + expect(() => + assertResolvedAtInvariant({ resolvedVerdict: true, resolvedAt: null }), + ).toThrow(/BE-219/); + }); + + it('throws when resolvedAt is set but resolvedVerdict is null (inverse bug)', () => { + expect(() => + assertResolvedAtInvariant({ resolvedVerdict: null, resolvedAt: new Date() }), + ).toThrow(BadRequestException); + }); + + it('includes BE-219 in the error message for the timestamp-without-verdict case', () => { + expect(() => + assertResolvedAtInvariant({ resolvedVerdict: null, resolvedAt: new Date() }), + ).toThrow(/BE-219/); + }); + }); + + // ------------------------------------------------------------------ // + // buildResolvedFields // + // ------------------------------------------------------------------ // + describe('buildResolvedFields', () => { + it('returns verdict=true and a non-null Date', () => { + const fields = buildResolvedFields(true); + expect(fields.resolvedVerdict).toBe(true); + expect(fields.resolvedAt).toBeInstanceOf(Date); + expect(fields.resolvedAt).not.toBeNull(); + }); + + it('returns verdict=false and a non-null Date', () => { + const fields = buildResolvedFields(false); + expect(fields.resolvedVerdict).toBe(false); + expect(fields.resolvedAt).toBeInstanceOf(Date); + }); + + it('uses the provided date when supplied', () => { + const fixedDate = new Date('2026-01-01T12:00:00Z'); + const fields = buildResolvedFields(true, fixedDate); + expect(fields.resolvedAt).toBe(fixedDate); + }); + + it('always passes assertResolvedAtInvariant', () => { + const fields = buildResolvedFields(true); + expect(() => assertResolvedAtInvariant(fields)).not.toThrow(); + }); + + it('result fields are never undefined', () => { + const fields = buildResolvedFields(false); + expect(fields.resolvedVerdict).not.toBeUndefined(); + expect(fields.resolvedAt).not.toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------ // + // buildUnresolvedFields // + // ------------------------------------------------------------------ // + describe('buildUnresolvedFields', () => { + it('returns both fields as null', () => { + const fields = buildUnresolvedFields(); + expect(fields.resolvedVerdict).toBeNull(); + expect(fields.resolvedAt).toBeNull(); + }); + + it('always passes assertResolvedAtInvariant', () => { + const fields = buildUnresolvedFields(); + expect(() => assertResolvedAtInvariant(fields)).not.toThrow(); + }); + }); +}); diff --git a/src/claims/claim-resolution.invariant.ts b/src/claims/claim-resolution.invariant.ts new file mode 100644 index 0000000..2a5e2b3 --- /dev/null +++ b/src/claims/claim-resolution.invariant.ts @@ -0,0 +1,54 @@ +import { BadRequestException } from '@nestjs/common'; +import { Claim } from './entities/claim.entity'; + +/** + * Protocol invariant for Claim resolution state (issue #BE-219). + * + * The invariant is: + * resolvedVerdict IS NOT NULL <=> resolvedAt IS NOT NULL + * + * i.e. a claim is resolved if and only if both fields are set together. + * A claim must never be persisted in either of these inconsistent states: + * (a) resolvedVerdict is set but resolvedAt is null → "resolved without timestamp" + * (b) resolvedAt is set but resolvedVerdict is null → "timestamp without verdict" + */ +export function assertResolvedAtInvariant(claim: Pick): void { + const hasVerdict = claim.resolvedVerdict !== null && claim.resolvedVerdict !== undefined; + const hasTimestamp = claim.resolvedAt !== null && claim.resolvedAt !== undefined; + + if (hasVerdict && !hasTimestamp) { + throw new BadRequestException( + 'Claim invariant violation: resolvedVerdict is set but resolvedAt is null. ' + + 'Both must be set atomically when resolving a claim. (BE-219)', + ); + } + + if (!hasVerdict && hasTimestamp) { + throw new BadRequestException( + 'Claim invariant violation: resolvedAt is set but resolvedVerdict is null. ' + + 'resolvedAt must only be set when a verdict is also provided. (BE-219)', + ); + } +} + +/** + * Build the resolution fields that should be written atomically. + * Always use this when marking a claim as resolved — never assign + * resolvedVerdict or resolvedAt individually at call sites. + */ +export function buildResolvedFields(verdict: boolean, now: Date = new Date()): { + resolvedVerdict: boolean; + resolvedAt: Date; +} { + return { resolvedVerdict: verdict, resolvedAt: now }; +} + +/** + * Build the fields that clear resolution state (e.g. re-opening a claim). + */ +export function buildUnresolvedFields(): { + resolvedVerdict: null; + resolvedAt: null; +} { + return { resolvedVerdict: null, resolvedAt: null }; +} diff --git a/src/claims/claim-resolution.service.spec.ts b/src/claims/claim-resolution.service.spec.ts index 09e0420..a7221e3 100644 --- a/src/claims/claim-resolution.service.spec.ts +++ b/src/claims/claim-resolution.service.spec.ts @@ -1,7 +1,7 @@ import { ClaimResolutionService } from "./claim-resolution.service"; describe('Confidence Scoring', () => { - const service = new ClaimResolutionService(null as any); + const service = new ClaimResolutionService(null as any, null as any); it('returns high confidence for strong consensus', () => { const score = service.computeConfidenceScore({ @@ -39,3 +39,83 @@ describe('Confidence Scoring', () => { expect(score).toBe(0); }); }); + +// ------------------------------------------------------------------ // +// resolveClaim — resolvedAt invariant (BE-219) // +// ------------------------------------------------------------------ // +describe('ClaimResolutionService.resolveClaim — resolvedAt invariant (BE-219)', () => { + function makeService() { + // Minimal mock repo and cache + const mockClaim = { + id: 'claim-001', + resolvedVerdict: null, + resolvedAt: null, + confidenceScore: null, + finalized: false, + }; + + const mockRepo: any = { + findOneBy: jest.fn().mockResolvedValue({ ...mockClaim }), + save: jest.fn().mockImplementation(async (c: any) => ({ ...c })), + }; + + const mockCache: any = { + invalidateClaim: jest.fn().mockResolvedValue(undefined), + }; + + return { service: new ClaimResolutionService(mockRepo, mockCache), mockRepo, mockCache }; + } + + it('sets resolvedAt to a non-null Date when a claim is resolved (BE-219)', async () => { + const { service } = makeService(); + const before = Date.now(); + + const result = await service.resolveClaim('claim-001', { trueWeight: 150, falseWeight: 50 }); + + const after = Date.now(); + expect(result.resolvedAt).not.toBeNull(); + expect(result.resolvedAt).toBeInstanceOf(Date); + expect((result.resolvedAt as Date).getTime()).toBeGreaterThanOrEqual(before); + expect((result.resolvedAt as Date).getTime()).toBeLessThanOrEqual(after); + }); + + it('sets resolvedAt and resolvedVerdict atomically (BE-219)', async () => { + const { service, mockRepo } = makeService(); + const savedArgs: any[] = []; + mockRepo.save.mockImplementation(async (c: any) => { + savedArgs.push({ resolvedVerdict: c.resolvedVerdict, resolvedAt: c.resolvedAt }); + return c; + }); + + await service.resolveClaim('claim-001', { trueWeight: 150, falseWeight: 50 }); + + expect(savedArgs).toHaveLength(1); + expect(savedArgs[0].resolvedVerdict).not.toBeNull(); + expect(savedArgs[0].resolvedAt).not.toBeNull(); + }); + + it('sets finalized = true along with resolvedAt (BE-219)', async () => { + const { service } = makeService(); + const result = await service.resolveClaim('claim-001', { trueWeight: 150, falseWeight: 50 }); + expect(result.finalized).toBe(true); + expect(result.resolvedAt).not.toBeNull(); + }); + + it('throws when claim is not found', async () => { + const { service, mockRepo } = makeService(); + mockRepo.findOneBy.mockResolvedValue(null); + await expect(service.resolveClaim('bad-id', { trueWeight: 100, falseWeight: 50 })).rejects.toThrow('Claim not found'); + }); + + it('resolvedVerdict=true when trueWeight > falseWeight', async () => { + const { service } = makeService(); + const result = await service.resolveClaim('claim-001', { trueWeight: 150, falseWeight: 50 }); + expect(result.resolvedVerdict).toBe(true); + }); + + it('resolvedVerdict=false when falseWeight > trueWeight', async () => { + const { service } = makeService(); + const result = await service.resolveClaim('claim-001', { trueWeight: 50, falseWeight: 150 }); + expect(result.resolvedVerdict).toBe(false); + }); +}); diff --git a/src/claims/claim-resolution.service.ts b/src/claims/claim-resolution.service.ts index a557eaa..7b866df 100644 --- a/src/claims/claim-resolution.service.ts +++ b/src/claims/claim-resolution.service.ts @@ -3,6 +3,10 @@ import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; import { Claim } from './entities/claim.entity'; import { ClaimsCache } from '../cache/claims.cache'; +import { + assertResolvedAtInvariant, + buildResolvedFields, +} from './claim-resolution.invariant'; interface VoteWeightSummary { trueWeight: number; @@ -46,10 +50,16 @@ export class ClaimResolutionService { const verdict = votes.trueWeight > votes.falseWeight; const confidence = this.computeConfidenceScore(votes); - claim.resolvedVerdict = verdict; + // Set resolvedVerdict and resolvedAt atomically — invariant BE-219 + const { resolvedVerdict, resolvedAt } = buildResolvedFields(verdict); + claim.resolvedVerdict = resolvedVerdict; + claim.resolvedAt = resolvedAt; claim.confidenceScore = confidence; claim.finalized = true; + // Guard against any inconsistent state before persisting + assertResolvedAtInvariant(claim); + const savedClaim = await this.claimRepo.save(claim); // Invalidate both the claim-specific cache and the latest claims list cache await this.claimsCache.invalidateClaim(claimId); diff --git a/src/claims/claims.service.spec.ts b/src/claims/claims.service.spec.ts index df7f4ed..34bd124 100644 --- a/src/claims/claims.service.spec.ts +++ b/src/claims/claims.service.spec.ts @@ -264,12 +264,13 @@ describe('ClaimsService', () => { describe('resolveClaim', () => { it('should resolve a claim with verdict and confidence score', async () => { - const claim = ClaimFactory.createClaim({ resolvedVerdict: null, confidenceScore: null }); + const claim = ClaimFactory.createClaim({ resolvedVerdict: null, resolvedAt: null, confidenceScore: null }); const verdict = true; const confidenceScore = 0.85; + const resolvedAt = new Date(); jest.spyOn(service, 'findOne').mockResolvedValue(claim); - jest.spyOn(claimRepo, 'save').mockResolvedValue({ ...claim, resolvedVerdict: verdict, confidenceScore }); + jest.spyOn(claimRepo, 'save').mockResolvedValue({ ...claim, resolvedVerdict: verdict, resolvedAt, confidenceScore }); jest.spyOn(claimsCache, 'invalidateClaim').mockResolvedValue(undefined); jest.spyOn(auditTrailService, 'log').mockResolvedValue(undefined); @@ -282,13 +283,57 @@ describe('ClaimsService', () => { expect(result.confidenceScore).toEqual(confidenceScore); }); + it('sets resolvedAt to a non-null timestamp when resolving a claim (BE-219)', async () => { + const claim = ClaimFactory.createClaim({ resolvedVerdict: null, resolvedAt: null, confidenceScore: null }); + const before = Date.now(); + + jest.spyOn(service, 'findOne').mockResolvedValue(claim); + jest.spyOn(claimRepo, 'save').mockImplementation(async (c: any) => c); + jest.spyOn(claimsCache, 'invalidateClaim').mockResolvedValue(undefined); + jest.spyOn(auditTrailService, 'log').mockResolvedValue(undefined); + + const result = await service.resolveClaim(claim.id, true, 0.9); + const after = Date.now(); + + expect(result.resolvedAt).not.toBeNull(); + expect(result.resolvedAt).toBeInstanceOf(Date); + expect((result.resolvedAt as Date).getTime()).toBeGreaterThanOrEqual(before); + expect((result.resolvedAt as Date).getTime()).toBeLessThanOrEqual(after); + }); + + it('sets resolvedAt and resolvedVerdict atomically (never one without the other, BE-219)', async () => { + const claim = ClaimFactory.createClaim({ resolvedVerdict: null, resolvedAt: null, confidenceScore: null }); + const savedStates: Partial[] = []; + + jest.spyOn(service, 'findOne').mockResolvedValue(claim); + jest.spyOn(claimRepo, 'save').mockImplementation(async (c: any) => { + savedStates.push({ resolvedVerdict: c.resolvedVerdict, resolvedAt: c.resolvedAt }); + return c; + }); + jest.spyOn(claimsCache, 'invalidateClaim').mockResolvedValue(undefined); + jest.spyOn(auditTrailService, 'log').mockResolvedValue(undefined); + + await service.resolveClaim(claim.id, false, 0.75); + + // The object passed to save must have both fields set together + expect(savedStates).toHaveLength(1); + expect(savedStates[0].resolvedVerdict).not.toBeNull(); + expect(savedStates[0].resolvedAt).not.toBeNull(); + }); + + it('a non-resolved claim always has resolvedAt == null (BE-219)', () => { + const claim = ClaimFactory.createClaim({ resolvedVerdict: null, resolvedAt: null, confidenceScore: null, finalized: false }); + expect(claim.resolvedVerdict).toBeNull(); + expect(claim.resolvedAt).toBeNull(); + }); + it('should invalidate claims:latest cache when resolving a claim', async () => { - const claim = ClaimFactory.createClaim({ resolvedVerdict: null, confidenceScore: null }); + const claim = ClaimFactory.createClaim({ resolvedVerdict: null, resolvedAt: null, confidenceScore: null }); const verdict = false; const confidenceScore = 0.65; jest.spyOn(service, 'findOne').mockResolvedValue(claim); - jest.spyOn(claimRepo, 'save').mockResolvedValue({ ...claim, resolvedVerdict: verdict, confidenceScore }); + jest.spyOn(claimRepo, 'save').mockResolvedValue({ ...claim, resolvedVerdict: verdict, resolvedAt: new Date(), confidenceScore }); jest.spyOn(claimsCache, 'invalidateClaim').mockResolvedValue(undefined); jest.spyOn(auditTrailService, 'log').mockResolvedValue(undefined); @@ -297,20 +342,20 @@ describe('ClaimsService', () => { expect(claimsCache.invalidateClaim).toHaveBeenCalledWith(claim.id); }); - it('should throw error if claim not found when resolving', async () => { + it('should throw NotFoundException if claim not found when resolving', async () => { jest.spyOn(service, 'findOne').mockResolvedValue(null); await expect(service.resolveClaim('non-existent-id', true, 0.8)).rejects.toThrow('Claim non-existent-id not found'); }); it('should log audit trail when resolving a claim', async () => { - const claim = ClaimFactory.createClaim({ resolvedVerdict: null, confidenceScore: null }); + const claim = ClaimFactory.createClaim({ resolvedVerdict: null, resolvedAt: null, confidenceScore: null }); const verdict = true; const confidenceScore = 0.75; const userId = 'user-123'; jest.spyOn(service, 'findOne').mockResolvedValue(claim); - jest.spyOn(claimRepo, 'save').mockResolvedValue({ ...claim, resolvedVerdict: verdict, confidenceScore }); + jest.spyOn(claimRepo, 'save').mockResolvedValue({ ...claim, resolvedVerdict: verdict, resolvedAt: new Date(), confidenceScore }); jest.spyOn(claimsCache, 'invalidateClaim').mockResolvedValue(undefined); jest.spyOn(auditTrailService, 'log').mockResolvedValue(undefined); @@ -318,6 +363,26 @@ describe('ClaimsService', () => { expect(auditTrailService.log).toHaveBeenCalled(); }); + + it('idempotency: resolving an already-resolved claim overwrites resolvedAt with the new timestamp', async () => { + // Policy: re-resolving is allowed (no error); resolvedAt is updated to the new call time. + // This matches the current codebase behaviour where no duplicate-resolve guard exists. + const previousDate = new Date('2026-01-01T00:00:00Z'); + const claim = ClaimFactory.createClaim({ resolvedVerdict: true, resolvedAt: previousDate, confidenceScore: 0.8 }); + + jest.spyOn(service, 'findOne').mockResolvedValue(claim); + jest.spyOn(claimRepo, 'save').mockImplementation(async (c: any) => c); + jest.spyOn(claimsCache, 'invalidateClaim').mockResolvedValue(undefined); + jest.spyOn(auditTrailService, 'log').mockResolvedValue(undefined); + + const result = await service.resolveClaim(claim.id, false, 0.9); + + // resolvedAt should be a new, more recent timestamp + expect(result.resolvedAt).not.toBeNull(); + expect((result.resolvedAt as Date).getTime()).toBeGreaterThanOrEqual(previousDate.getTime()); + // resolvedVerdict is updated to the new value + expect(result.resolvedVerdict).toBe(false); + }); }); describe('finalizeClaim', () => { diff --git a/src/claims/claims.service.ts b/src/claims/claims.service.ts index bff5121..15e8b74 100644 --- a/src/claims/claims.service.ts +++ b/src/claims/claims.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Claim } from './entities/claim.entity'; @@ -9,6 +9,10 @@ import { Stake } from '../staking/entities/stake.entity'; import { AuditTrailService } from '../audit/services/audit-trail.service'; import { AuditActionType, AuditEntityType } from '../audit/entities/audit-log.entity'; import { AuditLog } from '../audit/decorators/audit-log.decorator'; +import { + assertResolvedAtInvariant, + buildResolvedFields, +} from './claim-resolution.invariant'; @Injectable() @@ -111,7 +115,8 @@ export class ClaimsService { } /** - * Resolve a claim (update verdict and confidence) + * Resolve a claim (update verdict and confidence). + * Sets resolvedAt atomically with resolvedVerdict — invariant: BE-219. */ async resolveClaim( claimId: string, @@ -120,13 +125,19 @@ export class ClaimsService { userId?: string, ): Promise { const claim = await this.findOne(claimId); - if (!claim) throw new Error(`Claim ${claimId} not found`); + if (!claim) throw new NotFoundException(`Claim ${claimId} not found`); const beforeState = { ...claim }; - claim.resolvedVerdict = verdict; + const { resolvedVerdict, resolvedAt } = buildResolvedFields(verdict); + claim.resolvedVerdict = resolvedVerdict; + claim.resolvedAt = resolvedAt; claim.confidenceScore = confidenceScore; + // Guard: reject if the object is somehow in an inconsistent state + // before we write (e.g. caller mutated fields directly). + assertResolvedAtInvariant(claim); + const updatedClaim = await this.claimRepo.save(claim); // Invalidate both the claim-specific cache and the latest claims list cache await this.claimsCache.invalidateClaim(claimId); @@ -172,16 +183,5 @@ export class ClaimsService { return updatedClaim; } - async findOne(id: string): Promise { - const claim = await this.repo.findOne({ - where: { id }, - }); - - if (!claim) { - throw new NotFoundException(`Claim with id ${id} not found`); - } - - return claim; - } } diff --git a/src/claims/entities/claim.entity.ts b/src/claims/entities/claim.entity.ts index 0c4a36c..c374867 100644 --- a/src/claims/entities/claim.entity.ts +++ b/src/claims/entities/claim.entity.ts @@ -5,6 +5,7 @@ import { Evidence } from './evidence.entity'; @Index(['finalized']) @Index(['confidenceScore']) @Index(['resolvedVerdict']) +@Index(['resolvedAt']) export class Claim { @PrimaryGeneratedColumn('uuid') id: string; @@ -35,6 +36,13 @@ export class Claim { @Column({ default: false }) finalized: boolean; + /** + * Timestamp set atomically when a claim is resolved (resolvedVerdict becomes non-null). + * Invariant: resolvedAt is non-null if and only if resolvedVerdict is non-null. + */ + @Column({ type: 'datetime', nullable: true }) + resolvedAt: Date | null; + @CreateDateColumn() createdAt: Date; diff --git a/src/claims/factories/claim.factory.ts b/src/claims/factories/claim.factory.ts index b29c2fa..602aa2d 100644 --- a/src/claims/factories/claim.factory.ts +++ b/src/claims/factories/claim.factory.ts @@ -33,8 +33,9 @@ export class ClaimFactory { content: dto.content, source: dto.source, metadata: dto.metadata, - resolvedVerdict: Math.random() > 0.5, - confidenceScore: Math.random() * 0.9 + 0.1, + resolvedVerdict: null, + resolvedAt: null, + confidenceScore: null, finalized: false, createdAt: new Date(), evidences: [], @@ -56,11 +57,13 @@ export class ClaimFactory { } /** - * Create a claim with specific verdict and confidence for testing edge cases + * Create a claim with specific verdict and confidence for testing edge cases. + * Sets resolvedAt atomically with resolvedVerdict (invariant BE-219). */ static createClaimWithVerdict(verdict: boolean, confidence: number): Claim { return ClaimFactory.createClaim({ resolvedVerdict: verdict, + resolvedAt: new Date(), confidenceScore: confidence, finalized: true, }); diff --git a/src/jobs/jobs.service.ts b/src/jobs/jobs.service.ts index 5168202..8452a52 100644 --- a/src/jobs/jobs.service.ts +++ b/src/jobs/jobs.service.ts @@ -8,6 +8,7 @@ import { Claim } from '../claims/entities/claim.entity'; import { User } from '../entities/user.entity'; import { AggregationService } from '../aggregation/aggregation.service'; import { ClaimsCache } from '../cache/claims.cache'; +import { buildResolvedFields } from '../claims/claim-resolution.invariant'; /** * JobsService @@ -84,13 +85,15 @@ export class JobsService implements OnModuleInit, OnModuleDestroy { claim.confidenceScore = result.confidence / 100; // store as 0-1 precision field - // If strong confidence, mark finalized and set resolvedVerdict + // If strong confidence, mark finalized and set resolvedVerdict + resolvedAt atomically (BE-219) if (result.confidence > 50) { claim.finalized = true; - // Assume result.status is 'VERIFIED_TRUE' or 'VERIFIED_FALSE' // Parse enum name to boolean (VERIFIED_TRUE -> true) if (typeof result.status === 'string') { - claim.resolvedVerdict = result.status === 'VERIFIED_TRUE'; + const verdict = result.status === 'VERIFIED_TRUE'; + const { resolvedVerdict, resolvedAt } = buildResolvedFields(verdict); + claim.resolvedVerdict = resolvedVerdict; + claim.resolvedAt = resolvedAt; } } diff --git a/src/migrations/1769600000000-AddClaimResolvedAt.ts b/src/migrations/1769600000000-AddClaimResolvedAt.ts new file mode 100644 index 0000000..470a44e --- /dev/null +++ b/src/migrations/1769600000000-AddClaimResolvedAt.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Migration: add `resolvedAt` column to `claims`. + * + * Invariant enforced at the application layer: + * resolvedAt IS NOT NULL <=> resolvedVerdict IS NOT NULL + * + * Backfill: any existing row where resolvedVerdict is already set gets + * resolvedAt filled with createdAt as a conservative approximation + * (the exact resolution timestamp was not stored before this migration). + * Rows where resolvedVerdict IS NULL are left with resolvedAt = NULL. + */ +export class AddClaimResolvedAt1769600000000 implements MigrationInterface { + name = 'AddClaimResolvedAt1769600000000'; + + public async up(queryRunner: QueryRunner): Promise { + // 1. Add the nullable column + await queryRunner.query( + `ALTER TABLE "claims" ADD COLUMN "resolvedAt" TIMESTAMP`, + ); + + // 2. Backfill: rows that already have a verdict set resolvedAt = createdAt + // (createdAt is the earliest safe approximation; actual resolution time + // was not recorded before this migration — noted in PR for #BE-219). + await queryRunner.query( + `UPDATE "claims" SET "resolvedAt" = "createdAt" WHERE "resolvedVerdict" IS NOT NULL`, + ); + + // 3. Partial index mirrors the pattern used on resolvedVerdict + await queryRunner.query( + `CREATE INDEX "IDX_claims_resolved_at" ON "claims" ("resolvedAt") WHERE "resolvedAt" IS NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_claims_resolved_at"`, + ); + await queryRunner.query( + `ALTER TABLE "claims" DROP COLUMN "resolvedAt"`, + ); + } +}