fix: restrict dynamic journey responses

This commit is contained in:
Jesse_Chen
2026-07-19 09:54:56 +08:00
parent 806d2babbf
commit db5a6fbe52
3 changed files with 68 additions and 13 deletions
@@ -108,9 +108,13 @@ function dynamicActionMatchesPhase(value: {
const dynamicJourneyResponseSchema = z.object({
...responseCore,
scoring: versionedScoringSchema.nullable(),
answers: z.record(answerSchema).readonly(),
lifeEvents: z.array(lifeEventSchema).readonly(),
questionnaire: z.null(),
scoring: z.null(),
answers: z.record(answerSchema).refine(
(answers) => Object.keys(answers).length === 0,
"dynamic answers are server-private",
).readonly(),
lifeEvents: z.array(lifeEventSchema).max(0, "dynamic life events are server-private").readonly(),
candidateResult: candidateResultSchema.nullable(),
journeyProtocol: z.literal("dynamic-choice-v2"),
turnVersion: z.number().int().nonnegative(),
@@ -140,10 +140,10 @@ export function storedDynamicJourneyResponse(
return {
caseId: stored.id,
snapshot: stored.snapshot,
questionnaire: stored.questionnaire,
scoring: stored.scoring ?? null,
answers: stored.answers,
lifeEvents: stored.lifeEvents ?? [],
questionnaire: null,
scoring: null,
answers: {},
lifeEvents: [],
candidateResult: stored.candidateResult ?? null,
evidenceDraft: null,
...stored.dynamicTurnState,
+57 -6
View File
@@ -40,15 +40,66 @@ test("unmatched context is optional, trimmed, and bounded", () => {
assert.equal(birthTimeGuideRequestSchema.safeParse({ ...valid, partitionId: "private" }).success, false);
});
test("dynamic responses preserve the protocol discriminant without private scoring data", () => {
const response = storedDynamicJourneyResponse(dynamicCase());
test("dynamic responses project one exact public shape", () => {
const stored = {
...dynamicCase(),
questionnaire: { questions: [], samples: [], raw: { candidateVector: [0.4, 0.6] } },
scoring: {
answeredCount: 1,
candidateClusterRankings: [{ cluster: "05:00", score: 9 }],
raw: { candidateVector: [0.4, 0.6] },
nextRound: null,
nextRoundQuestions: [],
},
answers: { legacy_private_answer: "A" as const },
lifeEvents: [{
id: "ec4ab5f0-829c-468a-90ea-42842f9f70d3",
domain: "career" as const,
precision: "year" as const,
date: "2020",
}],
};
const response = storedDynamicJourneyResponse(stored);
const parsed = parseJourneyResponse(response);
assert.deepEqual(Object.keys(parsed).sort(), [
"answers", "candidateResult", "caseId", "evidenceDraft", "journeyProtocol",
"lifeEvents", "nextAction", "permissions", "progress", "questionnaire",
"scoring", "snapshot", "turnVersion",
]);
assert.equal(parsed.journeyProtocol, "dynamic-choice-v2");
assert.equal(parsed.nextAction.kind, "ask_dynamic_choice");
const serialized = JSON.stringify(parsed);
assert.doesNotMatch(serialized, /partitionId|candidateScores|agentContext|candidateModel/);
assert.throws(() => parseJourneyResponse({ ...response, partitionId: "forged" }));
assert.deepEqual({
questionnaire: parsed.questionnaire,
scoring: parsed.scoring,
answers: parsed.answers,
lifeEvents: parsed.lifeEvents,
}, { questionnaire: null, scoring: null, answers: {}, lifeEvents: [] });
});
test("dynamic response parsing rejects private legacy and candidate payloads", () => {
const response = storedDynamicJourneyResponse(dynamicCase());
const scoring = {
answeredCount: 1,
candidateClusterRankings: [{ cluster: "05:00", score: 9 }],
raw: { candidateVectors: { "05:00": [0.4, 0.6] } },
nextRound: null,
nextRoundQuestions: [],
};
const privatePayloads = [
{ ...response, scoring },
{ ...response, questionnaire: { questions: [], samples: [], raw: { candidateVectors: [1] } } },
{ ...response, answers: { hidden: "A" } },
{ ...response, lifeEvents: [{ id: "ec4ab5f0-829c-468a-90ea-42842f9f70d3", domain: "career", precision: "year", date: "2020" }] },
{ ...response, candidateModel: { candidates: ["05:10"] } },
{ ...response, currentChoiceQuestion: persistedQuestion },
{ ...response, partitionId: "window-a" },
{ ...response, nextAction: { ...response.nextAction, question: {
...persistedQuestion,
options: persistedQuestion.options,
} } },
];
for (const payload of privatePayloads) assert.throws(() => parseJourneyResponse(payload));
});
test("dynamic routes authenticate before parsing and dispatch scoped methods", () => {