Skip to content
Binary file modified backend-ts/src/audit/audit-packet.ts
Binary file not shown.
4 changes: 2 additions & 2 deletions backend-ts/src/case/appointment-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { CaseEventStore } from "../stores/case-event-store.ts";
import type { OutcomeStore } from "../stores/outcome-store.ts";
import type { AppointmentStore } from "../stores/appointment-store.ts";
import { toCaseDetail, type CaseDetail } from "./case-detail-read-model.ts";
import { outcomeForCase } from "./case-outcome.ts";

/** 400 — missing/invalid appointment fields. */
export class AppointmentError extends Error {}
Expand All @@ -33,8 +34,7 @@ export interface ScheduleInput {
async function buildDetail(deps: AppointmentDeps, caseId: string): Promise<CaseDetail | null> {
const c = await deps.cases.getCase(caseId);
if (!c) return null;
const outcomes = await deps.outcomes.listOutcomes(c.lastRunId);
const outcome = outcomes.find((o) => o.subjectId === c.employeeId && o.measureId === c.measureId) ?? null;
const outcome = await outcomeForCase(deps.outcomes, c.lastRunId, c.employeeId, c.measureId);
const timeline = await deps.events.caseTimeline(caseId);
const latest = await deps.events.latestOutreachDeliveryStatus(caseId);
return toCaseDetail(c, outcome, timeline, latest);
Expand Down
4 changes: 2 additions & 2 deletions backend-ts/src/case/case-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { CaseEventStore } from "../stores/case-event-store.ts";
import type { OutcomeStore } from "../stores/outcome-store.ts";
import { DEPLOYMENT_PROFILE } from "../config/deployment-profile.ts";
import { toCaseDetail, type CaseDetail } from "./case-detail-read-model.ts";
import { outcomeForCase } from "./case-outcome.ts";

const ESCALATION_NEXT_ACTION = DEPLOYMENT_PROFILE.subjectTerm === "patient"
? "Escalated for immediate handling."
Expand All @@ -35,8 +36,7 @@ export interface CaseActionDeps {
async function buildDetail(deps: CaseActionDeps, caseId: string): Promise<CaseDetail | null> {
const c = await deps.cases.getCase(caseId);
if (!c) return null;
const outcomes = await deps.outcomes.listOutcomes(c.lastRunId);
const outcome = outcomes.find((o) => o.subjectId === c.employeeId && o.measureId === c.measureId) ?? null;
const outcome = await outcomeForCase(deps.outcomes, c.lastRunId, c.employeeId, c.measureId);
const timeline = await deps.events.caseTimeline(caseId);
const latest = await deps.events.latestOutreachDeliveryStatus(caseId);
return toCaseDetail(c, outcome, timeline, latest);
Expand Down
32 changes: 32 additions & 0 deletions backend-ts/src/case/case-outcome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* The one outcome row a case is about — the evidence its detail page, its outreach copy, its AI
* explanation and its MCP projection all read.
*
* This exists because the same two lines were written out eight times:
*
* const outcomes = await outcomes.listOutcomes(c.lastRunId);
* const outcome = outcomes.find((o) => o.subjectId === c.employeeId && o.measureId === c.measureId);
*
* — every outcome row of the whole run, `evidence_json` blobs included, fetched to use exactly one of
* them. Against the pilot's six-measure nightly (120,000 pairs) opening a case measured 43s on a cold
* read and ~4s warm with NO run in flight, and the cost grew with the roster rather than with anything
* the surface showed. Some paths paid it twice per interaction: a case detail page also loads its
* appointments, and an outreach send renders its context and then rebuilds the detail.
*
* `limit: 1` is exactly equivalent to the `.find()` it replaces — the store orders by
* `evaluated_at ASC, id ASC`, so the first row under the same filter is the row `.find()` returned.
*
* Takes the three fields rather than a whole `CaseRecord` so a caller holding only the ids can use it,
* and so the signature says precisely what the lookup is keyed on.
*/
import type { OutcomeRecord, OutcomeStore } from "../stores/outcome-store.ts";

export async function outcomeForCase(
outcomes: Pick<OutcomeStore, "listOutcomes">,
lastRunId: string,
subjectId: string,
measureId: string,
): Promise<OutcomeRecord | null> {
const rows = await outcomes.listOutcomes(lastRunId, { subjectId, measureId, limit: 1 });
return rows[0] ?? null;
}
4 changes: 2 additions & 2 deletions backend-ts/src/case/case-outreach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { OutcomeStore } from "../stores/outcome-store.ts";
import { toCaseDetail, type CaseDetail } from "./case-detail-read-model.ts";
import { resolveChannel, type ChannelType, type ChannelEnv, type OutreachChannel } from "./outreach-channel.ts";
import { DEPLOYMENT_PROFILE, subjectNoun } from "../config/deployment-profile.ts";
import { outcomeForCase } from "./case-outcome.ts";

interface OutreachTemplateContent {
id: string | null;
Expand Down Expand Up @@ -169,8 +170,7 @@ function computeDueDate(evidence: Record<string, unknown>, evaluationPeriod: str
}

async function loadOutcomeEvidence(deps: OutreachDeps, lastRunId: string, employeeId: string, measureId: string) {
const outcomes = await deps.outcomes.listOutcomes(lastRunId);
return outcomes.find((o) => o.subjectId === employeeId && o.measureId === measureId) ?? null;
return outcomeForCase(deps.outcomes, lastRunId, employeeId, measureId);
}

async function buildDetail(deps: OutreachDeps, caseId: string): Promise<CaseDetail | null> {
Expand Down
4 changes: 2 additions & 2 deletions backend-ts/src/case/case-rerun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { priorityFor, nextActionFor } from "./case-logic.ts";
import { toCaseDetail, type CaseDetail } from "./case-detail-read-model.ts";
import { caseRerunMeasurementPeriod } from "../run/run-period.ts";
import { OFFICIAL_LOGIC_VERSION_PREFIX } from "../wiring/executor-router.ts";
import { outcomeForCase } from "./case-outcome.ts";

export interface RerunDeps {
cases: CaseStore;
Expand Down Expand Up @@ -233,8 +234,7 @@ export async function rerunToVerify(deps: RerunDeps, caseId: string, actor: stri
async function buildDetail(deps: RerunDeps, caseId: string): Promise<CaseDetail | null> {
const c = await deps.cases.getCase(caseId);
if (!c) return null;
const outcomes = await deps.outcomes.listOutcomes(c.lastRunId);
const outcome = outcomes.find((o) => o.subjectId === c.employeeId && o.measureId === c.measureId) ?? null;
const outcome = await outcomeForCase(deps.outcomes, c.lastRunId, c.employeeId, c.measureId);
const timeline = await deps.events.caseTimeline(caseId);
const latest = await deps.events.latestOutreachDeliveryStatus(caseId);
return toCaseDetail(c, outcome, timeline, latest);
Expand Down
7 changes: 3 additions & 4 deletions backend-ts/src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { computeDataReadiness } from "../measure/data-readiness.ts";
import { complianceRateOf } from "../program/rollup-shared.ts";
import { AGE_BANDS, isAgeBand, isSex, matchesSubjectFilters } from "../compliance/subject-filters.ts";
import type { JsonRecord } from "./tool-audit.ts";
import { outcomeForCase } from "../case/case-outcome.ts";

export interface McpToolDeps {
caseStore: CaseStore;
Expand Down Expand Up @@ -190,8 +191,7 @@ async function getCase(args: JsonRecord, deps: McpToolDeps): Promise<unknown> {
if (!c) return safeError("CASE_NOT_FOUND", "Case not found");
const directory = directoryForSubjects(deps, [c.employeeId]);
if (!profileSubjectMatcher(directory.employeeById)(c.employeeId)) return safeError("CASE_NOT_FOUND", "Case not found");
const outcomes = await deps.outcomeStore.listOutcomes(c.lastRunId);
const outcome = outcomes.find((o) => o.subjectId === c.employeeId && o.measureId === c.measureId) ?? null;
const outcome = await outcomeForCase(deps.outcomeStore, c.lastRunId, c.employeeId, c.measureId);
const detail = toCaseDetail(c, outcome, [], null, undefined, directory.employeeById);
const evidence = detail.evidenceJson ?? {};
const whyFlagged = (evidence as JsonRecord).why_flagged ?? {};
Expand Down Expand Up @@ -356,8 +356,7 @@ async function explainOutcome(args: JsonRecord, deps: McpToolDeps): Promise<unkn
if (!c) return safeError("CASE_NOT_FOUND", "Case not found");
const directory = directoryForSubjects(deps, [c.employeeId]);
if (!profileSubjectMatcher(directory.employeeById)(c.employeeId)) return safeError("CASE_NOT_FOUND", "Case not found");
const outcomes = await deps.outcomeStore.listOutcomes(c.lastRunId);
const outcome = outcomes.find((o) => o.subjectId === c.employeeId && o.measureId === c.measureId) ?? null;
const outcome = await outcomeForCase(deps.outcomeStore, c.lastRunId, c.employeeId, c.measureId);
const detail = toCaseDetail(c, outcome, [], null, undefined, directory.employeeById);
const wf = ((detail.evidenceJson ?? {}) as JsonRecord).why_flagged as JsonRecord | undefined;
const val = (k: string, fb: string): string => (wf && wf[k] != null ? String(wf[k]) : fb);
Expand Down
4 changes: 2 additions & 2 deletions backend-ts/src/routes/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ensureMeasureStore } from "./measures.ts";
import { toCaseDetail } from "../case/case-detail-read-model.ts";
import { toRunSummaryFromCounts } from "../run/read-models.ts";
import { createChat, type ChatFn } from "../ai/openai-chat.ts";
import { outcomeForCase } from "../case/case-outcome.ts";
import {
draftSpec,
draftCql,
Expand Down Expand Up @@ -126,8 +127,7 @@ export async function handleAi(req: Request, env: AiEnv, actor = "system"): Prom
const s = await getStores(env);
const c = await s.cases.getCase(explainId);
if (!c) return json({ error: "not_found", id: explainId }, 404);
const outcomes = await s.outcomes.listOutcomes(c.lastRunId);
const outcome = outcomes.find((o) => o.subjectId === c.employeeId && o.measureId === c.measureId) ?? null;
const outcome = await outcomeForCase(s.outcomes, c.lastRunId, c.employeeId, c.measureId);
const detail = toCaseDetail(c, outcome);

const cacheKey = `${detail.caseId}:${detail.measureVersion}`;
Expand Down
4 changes: 2 additions & 2 deletions backend-ts/src/routes/cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { resolveBucket } from "../case/resolve-bucket.ts";
import { isWebChartConfigured } from "../engine/ingress/data-source.ts";
import { profileForId } from "../engine/ingress/webchart/live-directory.ts";
import { DIRECTORY, employeeById, profileSubjectMatcher } from "../config/deployment-profile.ts";
import { outcomeForCase } from "../case/case-outcome.ts";

interface CasesEnv {
DB: CloudDatabase;
Expand Down Expand Up @@ -286,8 +287,7 @@ export async function handleCases(req: Request, env: CasesEnv, actor = "system")
const c = await (await caseStore(env)).getCase(detailId);
if (!c) return json({ error: "not_found", id: detailId }, 404);
if (!profileSubjectMatcher(employeeLookup)(c.employeeId)) return json({ error: "not_found", id: detailId }, 404);
const outcomes = await (await outcomeStore(env)).listOutcomes(c.lastRunId);
const outcome = outcomes.find((o) => o.subjectId === c.employeeId && o.measureId === c.measureId) ?? null;
const outcome = await outcomeForCase(await outcomeStore(env), c.lastRunId, c.employeeId, c.measureId);
const events = (await getStores(env)).events;
const timeline = await events.caseTimeline(detailId);
const latest = await events.latestOutreachDeliveryStatus(detailId);
Expand Down
8 changes: 5 additions & 3 deletions backend-ts/src/run/employee-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,11 @@ export async function getEmployeeProfile(deps: EmployeeProfileDeps, externalId:

// One cases fetch for this employee: derive the open subset AND the full case-id set used by the
// recent-activity timeline below (this previously called listCases twice — open, then all).
const employeeCases = (await deps.cases.listCases({ limit: 100000, offset: 0 })).filter(
(c) => c.employeeId === externalId,
);
// Filtered in SQL. This used to read every case in the tenant at `limit: 100000` and keep the ones
// whose `employeeId` matched, so a page about ONE patient scaled with the whole practice's case
// count. The limit stays high because a subject legitimately has one case per (measure, cycle) and
// the timeline below wants all of them — but it now bounds this subject's history, not the tenant's.
const employeeCases = await deps.cases.listCases({ employeeId: externalId, limit: 100000, offset: 0 });
const openCases = employeeCases.filter((c) =>
(ACTIVE_CASE_STATUSES as readonly string[]).includes((c.status ?? "").toUpperCase()),
);
Expand Down
8 changes: 8 additions & 0 deletions backend-ts/src/run/run-pipeline.chunking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ function makeTestDeps(opts: {
return realCases.listCases(q);
},
upsertFromOutcome: (input: Parameters<typeof realCases.upsertFromOutcome>[0]) => realCases.upsertFromOutcome(input),
// Re-bound like the rest: `...realCases` spreads an INSTANCE, and its methods live on the
// prototype, so nothing here is inherited by the spread.
upsertFromOutcomes: (inputs: Parameters<typeof realCases.upsertFromOutcomes>[0]) => realCases.upsertFromOutcomes(inputs),
patchCase: (id: string, patch: Parameters<typeof realCases.patchCase>[1]) => realCases.patchCase(id, patch),
} as unknown as RunPipelineDeps["caseStore"];

Expand Down Expand Up @@ -188,6 +191,11 @@ function makeTestDeps(opts: {
// The pipeline ignores the return; the real store returns a record.
return { id: crypto.randomUUID() } as never;
},
// Delegates to this fake's own `appendAudit`, so the batch can never record less than the
// single-row path this fixture asserts on.
appendAudits: async (events: { eventType: string; payload?: Record<string, unknown> }[]) => {
for (const event of events) auditEvents.push({ eventType: event.eventType, payload: event.payload ?? {} });
},
},
counters,
auditEvents,
Expand Down
12 changes: 7 additions & 5 deletions backend-ts/src/run/run-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ test("a degraded Patient-only WebChart bundle evaluates MISSING_DATA and reports
employees: [],
webChartEnv: WEBCHART_ENV,
webChartClient: fixtureWebChartClient([patientOnly("degraded-patient", true)]),
events: { async appendAudit(input) { audits.push(input); } },
events: { async appendAudit(input) { audits.push(input); }, async appendAudits(inputs) { for (const i of inputs) await this.appendAudit(i); } },
}, {
scopeType: "MEASURE",
measureId: "audiogram",
Expand Down Expand Up @@ -479,7 +479,7 @@ test("live preparation failure finalizes FAILED before outcomes and preserves th
employees: [],
webChartEnv: WEBCHART_ENV,
webChartClient: fixtureWebChartClient([patientOnly("last-good")]),
events: { async appendAudit(input) { audits.push({ eventType: input.eventType, payload: input.payload }); } },
events: { async appendAudit(input) { audits.push({ eventType: input.eventType, payload: input.payload }); }, async appendAudits(inputs) { for (const i of inputs) await this.appendAudit(i); } },
};
const success = await executeManualRun(base, { scopeType: "MEASURE", measureId: "audiogram" });
const failingClient: WebChartClient = {
Expand Down Expand Up @@ -638,7 +638,7 @@ test("a live preparation failure still finalizes FAILED when its terminal audit
kind: "failure-test",
async fetchPatientPayloads() { throw new Error("population unavailable"); },
},
events: { async appendAudit() { throw new Error("audit unavailable"); } },
events: { async appendAudit() { throw new Error("audit unavailable"); }, async appendAudits() { throw new Error("audit unavailable"); } },
};
const planned = await planManualRun(failing, { scopeType: "MEASURE", measureId: "audiogram" });
await finishOrFail(failing, planned);
Expand Down Expand Up @@ -717,6 +717,7 @@ test("Codex P1: a failing case-audit write never fails an otherwise-complete run
if (input.entityType === "case") throw new Error("audit_events insert failed");
// RUN_COMPLETED (entityType "run") succeeds — it is independently best-effort.
},
async appendAudits(inputs) { for (const i of inputs) await this.appendAudit(i); },
},
};
// The default-date ALL_PROGRAMS run produces non-compliant subjects → case upserts → CASE_* audit
Expand Down Expand Up @@ -802,6 +803,7 @@ test("Fable H1: a population run emits RUN_COMPLETED + case audit events (the ha
async appendAudit(input) {
captured.push({ eventType: input.eventType, entityType: input.entityType, refRunId: input.refRunId, actor: input.actor, payload: input.payload });
},
async appendAudits(inputs) { for (const i of inputs) await this.appendAudit(i); },
},
};
// triggeredBy is a spoofable body field / trigger label; the audit actor must ignore it.
Expand Down Expand Up @@ -1578,7 +1580,7 @@ test("ADR-078: a subject the official logic finds OUTSIDE the initial population
} as unknown as RunPipelineDeps["engine"],
employees: EMPLOYEES.slice(0, 2),
actor: "cm@workwell.dev",
events: { async appendAudit(input) { captured.push({ eventType: input.eventType, payload: input.payload }); } },
events: { async appendAudit(input) { captured.push({ eventType: input.eventType, payload: input.payload }); }, async appendAudits(inputs) { for (const i of inputs) await this.appendAudit(i); } },
};
const [first, second] = EMPLOYEES.slice(0, 2).map((e) => e.externalId) as [string, string];
outsideIds = new Set([second]);
Expand Down Expand Up @@ -1617,7 +1619,7 @@ test("ADR-078 is gated on official routing: an AUTHORED measure's out-of-populat
} as unknown as RunPipelineDeps["engine"],
employees: EMPLOYEES.slice(0, 1),
actor: "cm@workwell.dev",
events: { async appendAudit() {} },
events: { async appendAudit() {}, async appendAudits() {} },
};
await executeManualRun(deps, { scopeType: "MEASURE", measureId: "audiogram", triggeredBy: "test" });
const cases = await caseStore.listCases({ limit: 10 });
Expand Down
Loading
Loading