Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 113 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -615,13 +615,19 @@ jobs:
timeout-minutes: 45
# The Maui project runs against a stack THIS RUNNER boots — backend on the SQLite floor with
# WORKWELL_INSTANCE=maui, frontend built in patient terminology with the public-demo affordances
# off — so nothing shared is mutated and the allowlist never comes into play. It deliberately
# runs the authored cms122/cms125 (no WORKWELL_OFFICIAL_MEASURES): the official artifacts need
# the vendored terminology sidecars, which are fetched at build with a VSAC key this job does not
# have, and with no VSAC key at runtime the authored path resolves the bundled expansions and
# yields the same designed 38/7/3 distribution the tests assert. Browser e2e lives here rather
# than on a developer box because Chromium + two dev servers + a test worker exhaust a Windows
# desktop heap long before they exhaust RAM (measured 2026-09-02).
# off — so nothing shared is mutated and the allowlist never comes into play. Browser e2e lives
# here rather than on a developer box because Chromium + two dev servers + a test worker exhaust a
# Windows desktop heap long before they exhaust RAM (measured 2026-09-02).
#
# SINCE 2026-09-08 this job runs the ROUTED configuration — all six of the ACO's measures, the same
# WORKWELL_OFFICIAL_MEASURES the deployed sandbox ships (ADR-078) — by vendoring each measure's
# terminology sidecar with the VSAC credential, exactly as flip-gate.yml does. It previously ran
# authored cms122/cms125 with no VSAC key, which meant the suite's green was about a configuration
# nobody deploys: the four official-only measures sat `official-pending`, and the specs that
# enumerate them failed silently from 2026-09-06 (when #528 added them) until the next manual
# dispatch five days later. A stack that is not the one the pilot runs is not a pilot test.
env:
ROUTED_MEASURES: cms122,cms125,cms2,cms130,cms165,cms137
steps:
# backend-ts consumes @mieweb/cloud + @mieweb/cli from the external/mieweb-cloud submodule.
- uses: actions/checkout@v7
Expand All @@ -636,19 +642,76 @@ jobs:
- name: Install backend dependencies
working-directory: backend-ts
run: pnpm install --frozen-lockfile
# ---- The credentialed steps run HERE, before the frontend and e2e dependency trees are on disk.
# This job is public-repo CI, and the one exposure that is not covered by "workflow_dispatch needs
# write access" is supply chain: anything in an installed node_modules can read the environment of
# a step it runs in. The VSAC key is therefore scoped to a single step (never job-level `env`),
# and that step executes with the BACKEND tree only — not Chromium, not Next.js, not the e2e tree.
#
# The official artifacts retrieve through their OWN terminology (ADR-036), completed from a
# pinned VSAC release at vendor time. Without it the four official-only measures resolve capped
# expansions, read a zero initial population, and the stack reports them `official-pending`.
- name: Cache official content
uses: actions/cache@v6
with:
path: backend-ts/.official-content
key: official-content-${{ hashFiles('backend-ts/scripts/fetch-official-cases.ps1') }}
- name: Fetch official content (pinned commit)
working-directory: backend-ts
run: pwsh -NoProfile -File scripts/fetch-official-cases.ps1
# The completed sidecars are deliberately NOT cached. They are VSAC value-set expansions —
# licensed content, gitignored for that reason — and an Actions cache is restorable by other
# workflow runs in this PUBLIC repo, including a fork pull request whose code we do not control.
# Re-vendoring costs about a minute; putting licensed expansions somewhere fork-authored code can
# read them is not worth a minute.
- name: Vendor each routed measure's terminology, completed from VSAC
working-directory: backend-ts
env:
WORKWELL_VSAC_API_KEY: ${{ secrets.WORKWELL_VSAC_API_KEY_VENDOR }}
run: |
set -o pipefail
if [ -z "$WORKWELL_VSAC_API_KEY" ]; then
echo "::error::No VSAC credential in this context. This job asserts the ROUTED configuration; over capped expansions every official-only measure would read a zero initial population and the suite would call that a pass."
exit 1
fi
IFS=',' read -ra MEASURES <<< "$ROUTED_MEASURES"
for id in "${MEASURES[@]}"; do
NAME="$(pnpm exec tsx -e "import {officialMeasureName} from './src/standards/official-cases.ts'; process.stdout.write(officialMeasureName('${id}') ?? '')")"
if [ -z "$NAME" ]; then echo "::error::${id} is not an official measure id"; exit 1; fi
pnpm vendor:official --measure "$NAME" --catalog-id "$id" --strip-elm-annotations --complete-terminology
TRUNCATED=$(jq -r '.terminology.truncated | length' "measures/official/${id}/manifest.json")
if [ "$TRUNCATED" != "0" ]; then
echo "::error::${id}'s vendored terminology still reports ${TRUNCATED} TRUNCATED expansion(s) — refusing to run the suite against capped expansions."
exit 1
fi
done
# Only the gitignored sidecar may be new; the committed artifact must still match its pin.
- name: The committed artifacts are reproducible from their pins
working-directory: backend-ts
run: git diff --exit-code measures/official
# ---- End of the credentialed steps. Everything below runs with no VSAC key in scope.

- name: Install frontend dependencies
working-directory: frontend
run: pnpm install --frozen-lockfile
- name: Cache Chromium
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ hashFiles('e2e/pnpm-lock.yaml') }}
- name: Install e2e dependencies + Chromium
working-directory: e2e
run: |
pnpm install --frozen-lockfile
npx playwright install chromium --with-deps

- name: Start the backend as Maui
working-directory: backend-ts
env:
WORKWELL_INSTANCE: maui
WORKWELL_AUTH_JWT_SECRET: maui-e2e-ci-secret-key-32-characters-minimum
# The configuration the sandbox deploys (deploy-maui-mieweb.yml), not a reduced stand-in.
WORKWELL_OFFICIAL_MEASURES: ${{ env.ROUTED_MEASURES }}
run: |
nohup pnpm dev > ../backend-maui.log 2>&1 &
for i in $(seq 1 60); do
Expand All @@ -657,6 +720,46 @@ jobs:
sleep 2
done
echo "::error::backend did not answer /api/version within 120s"; tail -50 ../backend-maui.log; exit 1
# The boot line names each measure's state, and this asserts the POSITIVE one: every routed
# measure must read `<id>:official`.
#
# Asserting the absence of `official-pending` instead does almost nothing, which is what the
# first version of this step did. `classifyRunnable` (config/deployment-profile.ts) returns
# `official-pending` for exactly one reason — the id is not named in WORKWELL_OFFICIAL_MEASURES —
# and for cms122/cms125 it cannot return it at all, because both are authored and the `authored`
# branch is reached first. So dropping those two ids from the routing list would print
# `cms122:authored,cms125:authored`, the negative grep would find nothing, and the suite would run
# the pilot's two flagship measures on the AUTHORED engine while the sandbox runs them officially.
# `runs.spec`'s 48 x 6 = 288 holds either way, so the job would be green on the wrong stack.
#
# Note what this can and cannot see: terminology is not part of the classification, so a capped or
# missing sidecar still reads `official` here. The control for THAT is the `truncated` check in the
# vendor step above, which refuses before the backend ever starts.
- name: Every routed measure actually routed
run: |
# The health check above returns as soon as the host listens, and the runnable= line is
# written just after that — so poll for it rather than reading a log that may still be a
# line short.
LINE=""
for _ in $(seq 1 15); do
LINE="$(grep -m1 'runnable=' backend-maui.log || true)"
[ -n "$LINE" ] && break
sleep 2
done
echo "$LINE"
[ -n "$LINE" ] || { echo "::error::the backend logged no runnable= line"; tail -50 backend-maui.log; exit 1; }
# The line is `[workwell] runnable=cms122:official,cms125:official,...`; drop everything up to
# and including `runnable=` so the FIRST token is a bare `<id>:<kind>` like the rest.
STATES="${LINE##*runnable=}"
IFS=',' read -ra MEASURES <<< "$ROUTED_MEASURES"
for id in "${MEASURES[@]}"; do
# -x on one token per line: an exact match, so `cms2` can never be satisfied by `cms2...`
# and `official` can never be satisfied by `official-pending`.
printf '%s' "$STATES" | tr ',' '\n' | grep -qx "${id}:official" || {
echo "::error::${id} did not boot 'official' — the suite would be testing a stack the pilot does not run"
exit 1
}
done
- name: Build and start the frontend in patient mode
working-directory: frontend
env:
Expand All @@ -682,7 +785,9 @@ jobs:
PLAYWRIGHT_PROFILE: maui
PLAYWRIGHT_BASE_URL: http://localhost:3000
PLAYWRIGHT_API_BASE_URL: http://localhost:8080
run: npx playwright test --project=maui --reporter=list,html
# Both Maui projects. `maui-writes` declares `dependencies: ["maui"]`, so the read-only specs
# all finish before the run-triggering one starts, whatever order these are named in.
run: npx playwright test --project=maui --project=maui-writes --reporter=list,html
- name: Upload Playwright report and server logs
uses: actions/upload-artifact@v7
if: always()
Expand Down
25 changes: 18 additions & 7 deletions backend-ts/src/fhir/run-aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,24 @@ export interface OfficialRunAggregate extends RateAggregate {
}

/**
* Whether ANY evaluated row of the run carries official population evidence. An errored row says
* Whether ANY evaluated row of the run FOR THIS MEASURE carries official population evidence. Scoped
* to the measure because ADR-072 admits a run that mixes engines: an ALL_PROGRAMS run over a routed
* cms125 and an authored occupational measure would otherwise answer "official" for both from
* whichever row the store returned first. An errored row says
* nothing about which engine the run used, so it is skipped, and the scan continues page by page until
* a row that was evaluated answers; only a run in which EVERY subject errored reads to the end, and that
* run has nothing to export either way. First page of one: the overwhelmingly common case (first row
* evaluated fine) costs a single row.
*/
export async function runProducedOfficialEvidence(os: Pick<OutcomeStore, "listOutcomes">, runId: string): Promise<boolean> {
export async function runProducedOfficialEvidence(
os: Pick<OutcomeStore, "listOutcomes">,
runId: string,
measureId: string,
): Promise<boolean> {
let limit = 1;
let offset = 0;
for (;;) {
const page = await os.listOutcomes(runId, { limit, offset });
const page = await os.listOutcomes(runId, { limit, offset, measureId });
for (const row of page) {
if (isEvaluationErrorEvidence(row.evidence)) continue;
return officialMembership(row.evidence) !== null;
Expand All @@ -61,12 +68,16 @@ export async function aggregateOfficialRun(
): Promise<OfficialRunAggregate> {
const aggregator = createRateAggregator(measureId);
// The artifact identity travels with the counts so BOTH exporters describe the same measure. Read off
// the first evaluated row that carries it — a run evaluates one measure with one engine, so any row is
// decisive, and a run where only some rows errored still names the artifact the rest were scored by
// (ADR-046).
// the first evaluated row that carries it — within ONE measure a run uses one engine, so any of its
// rows is decisive, and a run where only some rows errored still names the artifact the rest were
// scored by (ADR-046).
let identity: OfficialReportIdentity | null = null;
// SCOPED TO THE MEASURE. An ALL_PROGRAMS run holds one row per (subject, measure) pair, so an
// unscoped scan sums every measure the run touched and returns that one number for whichever measure
// was asked about. On the pilot's 2026-09-08 nightly that served CMS125's initial population, score
// and `ecqmId` under CMS122's name on the programs overview.
for (let offset = 0; ; offset += AGGREGATE_PAGE) {
const page = await os.listOutcomes(runId, { limit: AGGREGATE_PAGE, offset });
const page = await os.listOutcomes(runId, { limit: AGGREGATE_PAGE, offset, measureId });
for (const row of page) {
aggregator.add(row);
if (!identity && !isEvaluationErrorEvidence(row.evidence)) identity = officialReportIdentity(row.evidence);
Expand Down
29 changes: 29 additions & 0 deletions backend-ts/src/program/measure-rate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,32 @@ test("a multi-rate measure carries its reviewed rate labels", async () => {
const rate = await officialMeasureRate(os, "run-3", "cms137");
assert.deepEqual(rate?.rates.map((r) => [r.label, r.numer, r.effectiveDenominator]), [["Initiation", 1, 1], ["Engagement", 0, 1]]);
});

test("an ALL_PROGRAMS run is read PER MEASURE — each measure's rate is its own rows, not the run's sum", async () => {
resetMeasureRateMemo();
// One nightly run, two measures, one row per (subject, measure) pair — the pilot's shape since the
// 2026-09-08 flip. Before the scan was measure-scoped, both cards showed the summed aggregate and
// whichever `ecqmId` sorted first, so CMS125's 59.2% was served under CMS122's name.
const rows = [
rec("OVERDUE", { official: { ecqmId: "122FHIR", populationResults: { ipp: true, denom: true, numer: true, denex: false, denexcep: false } } }, "cms122"),
rec("COMPLIANT", { official: { ecqmId: "122FHIR", populationResults: { ipp: true, denom: true, numer: false, denex: false, denexcep: false } } }, "cms122"),
rec("COMPLIANT", { official: { ecqmId: "125FHIR", populationResults: { ipp: true, denom: true, numer: true, denex: false, denexcep: false } } }, "cms125"),
rec("COMPLIANT", { official: { ecqmId: "125FHIR", populationResults: { ipp: true, denom: true, numer: true, denex: false, denexcep: false } } }, "cms125"),
rec("COMPLIANT", { official: { ecqmId: "125FHIR", populationResults: { ipp: true, denom: true, numer: true, denex: false, denexcep: false } } }, "cms125"),
];
// The fake honours `measureId` because the real stores push it into SQL; a fake that ignored it would
// pass while the shipped query narrowed, which is the harness being gentler than the caller.
const os = {
listOutcomes: async (_runId: string, opts?: { limit?: number; offset?: number; measureId?: string }) => {
const scoped = opts?.measureId ? rows.filter((r) => r.measureId === opts.measureId) : rows;
const offset = opts?.offset ?? 0;
return scoped.slice(offset, offset + (opts?.limit ?? scoped.length));
},
};
const a = await officialMeasureRate(os, "run-mixed", "cms122");
const b = await officialMeasureRate(os, "run-mixed", "cms125");
assert.deepEqual(a?.rates.map((r) => [r.ipp, r.numer]), [[2, 1]], "cms122 sees only its own two rows");
assert.deepEqual(b?.rates.map((r) => [r.ipp, r.numer]), [[3, 3]], "cms125 sees only its own three rows");
assert.equal(a?.official?.ecqmId, "122FHIR");
assert.equal(b?.official?.ecqmId, "125FHIR", "the artifact identity is the measure's, not the first row of the run");
});
6 changes: 4 additions & 2 deletions backend-ts/src/program/measure-rate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@ export async function officialMeasureRate(
const key = `${runId}|${measureId}`;
const hit = memo.get(key);
if (hit) return hit;
// One row decides whether there is official evidence to reduce; an authored run is never paged.
if (!(await runProducedOfficialEvidence(os, runId))) return null;
// One row decides whether there is official evidence to reduce; an authored measure is never paged.
// Scoped to the measure: on an ALL_PROGRAMS run every measure's rows share the run id, so an
// unscoped question is answered by whichever measure happened to sort first.
if (!(await runProducedOfficialEvidence(os, runId, measureId))) return null;
const aggregate = await aggregateOfficialRun(os, runId, measureId);
const labels = officialMeasureSemantics(measureId)?.rateLabels;
const rate: MeasureRate = {
Expand Down
2 changes: 1 addition & 1 deletion backend-ts/src/routes/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ async function aggregateCountsForRun(
// case; when it is off, the first EVALUATED row settles it (`runProducedOfficialEvidence` — an errored
// row carries no engine's evidence and is skipped, never read as "not official").
const routedNow = isOfficialRouted(measureId, env as unknown as Record<string, unknown>);
const official = routedNow || (await runProducedOfficialEvidence(os, runId));
const official = routedNow || (await runProducedOfficialEvidence(os, runId, measureId));
if (!official) {
// The authored status histogram is single-rate by construction — it reduces workflow buckets, and
// a measure with no official evidence has one rate. Wrapped so the return type is uniform. A status
Expand Down
8 changes: 7 additions & 1 deletion backend-ts/src/stores/outcome-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,14 @@ export interface OutcomeStore {
* Outcomes for one run, oldest-first. Pass `opts.limit`/`opts.offset` to page the scan (Fable H4):
* the run-detail grid + the outcomes CSV must never materialize a `seed:scale` run's 120k rows in the
* single-replica worker. Omitting `opts` returns every row (back-compat, small runs only).
*
* `opts.measureId` narrows the scan to ONE measure of the run. An ALL_PROGRAMS run holds a row per
* (subject, measure) pair, so a caller that sums a run's evidence without saying which measure it
* means sums every measure into one number and serves it under each measure's name — which is what
* the programs overview did until 2026-09-08. Pushed into SQL rather than filtered after the read:
* on the pilot's six-measure nightly the app-side filter would page 120,000 rows once per measure.
*/
listOutcomes(runId: string, opts?: { limit?: number; offset?: number }): Promise<OutcomeRecord[]>;
listOutcomes(runId: string, opts?: { limit?: number; offset?: number; measureId?: string }): Promise<OutcomeRecord[]>;
getOutcomeById(id: string): Promise<OutcomeRecord | null>;
/**
* Delete outcome rows older than `cutoff`, KEEPING four things (ADR-073, amended by ADR-077 d3):
Expand Down
Loading
Loading