From 439338ae8a1f41cbd4765997df7c5ac087169aac Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 20 Jul 2026 23:58:12 +0800 Subject: [PATCH] fix: bound conversational nested JSON arrays --- .../conversational-rectification/contracts.ts | 4 +- .../persistence-contracts.ts | 4 +- ...conversational-rectification-store.test.ts | 67 +++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/conversational-rectification/contracts.ts b/frontend/src/lib/conversational-rectification/contracts.ts index dec03e6e..061627f3 100644 --- a/frontend/src/lib/conversational-rectification/contracts.ts +++ b/frontend/src/lib/conversational-rectification/contracts.ts @@ -66,8 +66,8 @@ const candidateSchema = boundedJson(z.object({ const technicalReceiptSchema = boundedJson(z.object({ calculationVersion: boundedNonblankText(80), - stableLayers: z.array(boundedNonblankText(80)).max(20), - sensitiveLayers: z.array(boundedNonblankText(80)).max(20), + stableLayers: boundedJson(z.array(boundedNonblankText(80)).max(20), 4_096), + sensitiveLayers: boundedJson(z.array(boundedNonblankText(80)).max(20), 4_096), candidateDifferenceRefs: z.array(boundedNonblankText(120)).max(40), }).strict(), 8_192); diff --git a/frontend/src/lib/conversational-rectification/persistence-contracts.ts b/frontend/src/lib/conversational-rectification/persistence-contracts.ts index 33d35b7e..d939a28f 100644 --- a/frontend/src/lib/conversational-rectification/persistence-contracts.ts +++ b/frontend/src/lib/conversational-rectification/persistence-contracts.ts @@ -152,10 +152,10 @@ export const privateCandidateSchema = boundedJson(z.object({ rangeEnd: timeSchema.nullable().optional(), calculationVersion: boundedText(80), candidateWeights: z.array(z.number().finite().min(0).max(1)).max(1_440).optional(), - candidateModelRefs: z.array(boundedText(120)).max(80).optional(), + candidateModelRefs: boundedJson(z.array(boundedText(120)).max(80), 16_384).optional(), d1Stability: z.enum(["stable", "sensitive", "unavailable"]).optional(), boundaryDistanceMinutes: z.number().int().min(0).max(1_440).nullable().optional(), - supportedSensitiveLayers: z.array(boundedText(80)).max(40).optional(), + supportedSensitiveLayers: boundedJson(z.array(boundedText(80)).max(40), 8_192).optional(), scoredHistoricalEvidence: z.array(scoredEvidenceSchema).max(100).optional(), suggestedDomains: z.array(evidenceDomainSchema).max(6).optional(), futureWindows: z.array(futureWindowSchema).max(20).optional(), diff --git a/frontend/tests/conversational-rectification-store.test.ts b/frontend/tests/conversational-rectification-store.test.ts index 20c48e6e..fd90d1ab 100644 --- a/frontend/tests/conversational-rectification-store.test.ts +++ b/frontend/tests/conversational-rectification-store.test.ts @@ -616,6 +616,73 @@ test("evidence recap enforces the SQL-matched aggregate byte limit", () => { assert.equal(conversationalRectificationTurnSchema.safeParse(turn).success, false); }); +test("nested receipt and private-candidate arrays match SQL UTF-8 byte caps", () => { + const fullLayer = "事".repeat(80); + const receiptAtLimit = [ + ...Array.from({ length: 16 }, () => fullLayer), + `${"事".repeat(62)}aa`, + ]; + const receiptOverLimit = [ + ...Array.from({ length: 16 }, () => fullLayer), + "事".repeat(61), + "aa", + ]; + assert.equal(postgresJsonbTextBytes(receiptAtLimit), 4_096); + assert.equal(postgresJsonbTextBytes(receiptOverLimit), 4_097); + + for (const field of ["stableLayers", "sensitiveLayers"] as const) { + assert.equal(conversationalRectificationTurnSchema.safeParse({ + ...firstTurn, + technicalReceipt: { ...firstTurn.technicalReceipt, [field]: receiptAtLimit }, + }).success, true, `${field} at its SQL byte limit`); + assert.equal(conversationalRectificationTurnSchema.safeParse({ + ...firstTurn, + technicalReceipt: { ...firstTurn.technicalReceipt, [field]: receiptOverLimit }, + }).success, false, `${field} above its SQL byte limit`); + } + + const fullModelReference = "事".repeat(120); + const modelReferencesAtLimit = [ + ...Array.from({ length: 44 }, () => fullModelReference), + "事".repeat(119), + "aaa", + ]; + const modelReferencesOverLimit = [ + ...Array.from({ length: 44 }, () => fullModelReference), + "事".repeat(119), + "aaaa", + ]; + assert.equal(postgresJsonbTextBytes(modelReferencesAtLimit), 16_384); + assert.equal(postgresJsonbTextBytes(modelReferencesOverLimit), 16_385); + + const supportedLayersAtLimit = [ + ...Array.from({ length: 33 }, () => fullLayer), + `${"事".repeat(45)}a`, + ]; + const supportedLayersOverLimit = [ + ...Array.from({ length: 33 }, () => fullLayer), + `${"事".repeat(45)}aa`, + ]; + assert.equal(postgresJsonbTextBytes(supportedLayersAtLimit), 8_192); + assert.equal(postgresJsonbTextBytes(supportedLayersOverLimit), 8_193); + + for (const [field, atLimit, overLimit] of [ + ["candidateModelRefs", modelReferencesAtLimit, modelReferencesOverLimit], + ["supportedSensitiveLayers", supportedLayersAtLimit, supportedLayersOverLimit], + ] as const) { + assert.equal(privateCandidateSchema.safeParse({ + resultId, + calculationVersion: "rectification-v3.1", + [field]: atLimit, + }).success, true, `${field} at its SQL byte limit`); + assert.equal(privateCandidateSchema.safeParse({ + resultId, + calculationVersion: "rectification-v3.1", + [field]: overLimit, + }).success, false, `${field} above its SQL byte limit`); + } +}); + test("life-event evidence rejects unknown, blank, and non-boolean durable values", () => { const evidence = { id: "00000000-0000-4000-8000-000000000105",