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 b1d2563..d41e37c 100644 --- a/src/claims/claim-resolution.service.spec.ts +++ b/src/claims/claim-resolution.service.spec.ts @@ -48,3 +48,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 27eb29c..15c9290 100644 --- a/src/claims/claim-resolution.service.ts +++ b/src/claims/claim-resolution.service.ts @@ -9,6 +9,10 @@ import { Repository, DataSource } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; import { Claim, ClaimState } from './entities/claim.entity'; import { ClaimsCache } from '../cache/claims.cache'; +import { + assertResolvedAtInvariant, + buildResolvedFields, +} from './claim-resolution.invariant'; // ─── Types ──────────────────────────────────────────────────────────────────── diff --git a/src/claims/claims.service.spec.ts b/src/claims/claims.service.spec.ts index 1d3c8c0..4c31451 100644 --- a/src/claims/claims.service.spec.ts +++ b/src/claims/claims.service.spec.ts @@ -283,12 +283,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); @@ -301,13 +302,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); @@ -316,20 +361,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); @@ -337,6 +382,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 8331487..ae2cf02 100644 --- a/src/claims/claims.service.ts +++ b/src/claims/claims.service.ts @@ -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() @@ -127,7 +131,7 @@ 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 }; @@ -137,6 +141,10 @@ export class ClaimsService { confidence: 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); diff --git a/src/claims/entities/claim.entity.ts b/src/claims/entities/claim.entity.ts index 71e690c..a53d936 100644 --- a/src/claims/entities/claim.entity.ts +++ b/src/claims/entities/claim.entity.ts @@ -22,6 +22,7 @@ export interface ClaimTransitionData { @Index(['finalized']) @Index(['confidenceScore']) @Index(['resolvedVerdict']) +@Index(['resolvedAt']) export class Claim { @PrimaryGeneratedColumn('uuid') id: string; @@ -52,6 +53,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/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"`, + ); + } +}