---
title: "Measurement Bridge"
url: https://docs.growamal.com/en/guides/measurement-bridge/
language: en
---

# Measurement Bridge

The measurement bridge is the wiring between the measurement core and the reporting
surfaces. It turns a finished diagnostic into the per-skill, per-domain, and macro-tile
statuses that every teacher report, parent PDF, board summary, and multi-school rollup
reads. It is a server-side projection hung off diagnostic finish, and it introduces no
new routes.

Before the bridge shipped, `finishSession` wrote per-sub-skill θ to
`student_assessment_result` and stopped. Nothing wrote `evidence_row`, nothing called
the skill-status recompute, and nothing wrote `macro_domain_status_snapshot` at all, so
every reporting, parent, board, and multi-school macro surface read an empty table in
production. The bridge closes that gap by emitting evidence and snapshots at the moment
a diagnostic finishes.

## The gap the bridge closes

The bridge closes two seams left open after a diagnostic finished.

1. θ results were never turned into an `evidence_row`. The skill-status engine had no
   per-sub-skill evidence to recompute from.
2. The decision engine wrote each `domain_status_snapshot` but nothing aggregated the
   6 macro tiles, so the macro table stayed empty.

θ stays exactly where it was: persisted on `student_assessment_result` and consumed by
practice seeding. The bridge is a parallel emission, not a replacement. It does not move
θ or re-score anything.

## The trigger chain

The shipped flow runs in this shape:

```
finishSession (modules/session/diagnostic.orchestrator.ts)
  ├─ (in the finish tx, first-finish gated) writeScreeningEvidence → evidence_row {correct,total}
  └─ (AFTER the tx commits) projectBridge (modules/sse/bridge/diagnostic-bridge.ts)
        ├─ recomputeForEvidence × N sub-skills → skill_status_snapshot + domain_status_snapshot
        └─ recomputeMacroForStudent ONCE       → macro_domain_status_snapshot (6 tiles + GLOBAL)
```

Key facts:

- The bridge runs **after** the finish transaction commits, not nested inside it. There
  are two reasons. The `evidence_row` must be durable before `recomputeForEvidence`
  re-reads it, and the finish lock on `(org, profile.id)` must not span the skill-status
  lock on `(org, student, window)`.
- The whole sequence is synchronous and in-process. There is no queue or broker, because
  the pilot runs at roughly 30 schools.
- A crash between the commit and the bridge **self-heals on re-finish**, because every
  write is version-pinned idempotent.
- The macro pass is **debounced**: N sub-skills drive N skill and domain recomputes, but
  exactly one macro pass runs over the latest committed feeders.

## What evidence_row carries (counts, not θ)

The skill-status engine consumes a 3-item screening result per sub-skill, not θ. Under
the v5 skill screening rule, 3 items resolve as `3/3 = no need`, `2/3 = monitor`, and
`0-1/3 = training need`. The bridge writes that input directly:

- `evidence_row.value = { correct, total, sourceSessionId }` with
  `evidenceType = "screening_score"`.
- `correct` is summed from the **server-graded** `inputResponses.isCorrect` over the
  distinct scored items. It is **never re-derived from θ**, because a second scoring path
  would be a V-6 hazard.
- `studentId = StudentProfile.id` end-to-end.
- The idempotency backstop is the **first-finish gate** in `finishSession`. The
  `evidence_row` table itself has no UNIQUE constraint.
- `continuous` windows produce **no** screening evidence. `screeningCycleToEvidenceWindow`
  returns null and the bridge is skipped. Screening evidence is BOY, MOY, or EOY only.

## The macro projection (6 tiles + GLOBAL)

`writeMacroSnapshot` lives in `repository/snapshot-writer.ts`, the single skill-status
write path that the lint enforces. A pure `engine/macro-rollup.ts` `rollupMacroTile`
aggregates the latest committed `domain_status_snapshot` emissions per feeder into the
6 tiles plus GLOBAL.

The rollup is **worst-case-wins and never numeric** (FR-MDR-1). The precedence ladder is
Severe, then Below, then Approaching, then Partial, then Contextual, then Meets, then
Not_Assessed. `Not_Assessed` is never zero and never an average. For the full rollup
mechanics see [The Decision Engine](https://docs.growamal.com/en/how-it-works/decision-engine/).

The **GLOBAL meta row is written** per student, with `isMetaRow=true`. It is the
cross-cutting rollup over all feeders and is consumed by `Profile_Rules_v2` PR2-00
`DATA_INCOMPLETE`. It is a meta row, never a dashboard tile.

Two more facts about the macro snapshot:

- `feederStatuses` carries `[{domainId, domainStatus, dsmRuleRef, engineSnapshotRef}]` so a
  reader can drill down into which domains drove a tile.
- `computedAt` and `occurredAt` are frozen to `session.finishedAt` (V-6), so the macro
  rows replay byte-identically.

## The id-key contract (StudentProfile.id vs User.id)

> **Warning:** The three skill-status snapshot tables, plus `evidence_row` and
> `student_assessment_result`, key `studentId` on **StudentProfile.id**, not on User.id.
> The downstream plan and alert **chain tables** stay on the **User.id** id-space. So
> inside a single module a snapshot read is on StudentProfile.id while the
> assignment or alert read beside it is on User.id. This is the highest-confusion fact for
> any future reader: do not assume the two id-spaces are interchangeable.

The id-key sweep fixed every reader that read a snapshot on User.id, in two tiers:

1. The named macro consumers: reporting, board PDF, multi-school, parent PDF, and the
   profile module's `readProfileInputContext`.
2. The decision-pipeline snapshot readers: bundle, rti, data-room, ism, and workflow.
   Each resolves User.id to StudentProfile.id for **its snapshot reads only**.

The boundary (tracked as Q-BRIDGE-5, deferred) is that the chain tables
(`student_profile_assignment`, `bundle_assignment`, `bundle_recommendation`,
`student_tier_status`, `acute_regression_alert`, `student_scaffold_assignment`,
`monitoring_status_review`, `data_room_*`, `decision_packet_*`) stay on the
self-consistent User.id id-space that the profile, bundle, rti, and monitoring chain
threads end-to-end. Normalizing those table id-spaces to StudentProfile.id is a single
coherent follow-up, not split piecemeal.

The mismatch was latent because integration tests seed in a per-file database where
User.id equals StudentProfile.id by auto-increment coincidence. The ids only diverge
once two or more users are created before the student. The wide-net proving test is
`__tests__/id-key-sweep.integration.test.ts`.

## Append-only, idempotency, and V-6 replay

`macro_domain_status_snapshot` and `evidence_row` are append-only `.create()` rows,
enforced by `lint-no-update-on-audit-tables.ts`. Macro writes go only through
`snapshot-writer.ts`, enforced by `lint-sse-snapshot-single-write-path.ts`.

The shipped idempotency UNIQUE is
`@@unique([studentId, key, evidenceWindow, ruleVersion, seedVersion])`, plus a new
`evidenceWindow` column. The old `@@unique([studentId, key, computedAt])` de-duped
nothing, because `computedAt` defaults to `now()` and never collides. `writeMacroSnapshot`
does a pre-insert `findUnique` under the advisory lock, so a same-version re-trigger is a
no-op. A re-finish writes nothing new.

## Empty until a diagnostic finishes

The macro tables are empty in a fresh org and stay empty until a diagnostic
`finishSession` runs for a student. Until then, the reporting, board, and parent surfaces
return a `dataIncomplete` or `Not_Assessed` shape with HTTP 200. They fail closed and
never return 404. An all-`Not_Assessed` report before any diagnostic has finished is
expected behavior, not a fault. This ties into the existing `dataIncomplete: true`
behavior described in [Reporting & Parent PDF](https://docs.growamal.com/en/guides/reporting-and-parent-pdf/).

## Related subsystems

- [Diagnostic & Practice](https://docs.growamal.com/en/guides/diagnostic-and-practice/): the producer that finishes a session and triggers the bridge.
- [Skill-Status Engine](https://docs.growamal.com/en/guides/skill-status-engine/): the consumer of `evidence_row` and the home of the macro writer.
- [Reporting & Parent PDF](https://docs.growamal.com/en/guides/reporting-and-parent-pdf/): the macro consumer that reads the snapshots.
- [The Decision Engine](https://docs.growamal.com/en/how-it-works/decision-engine/): the full rollup mechanics and the GLOBAL meta row.
- [Glossary](https://docs.growamal.com/en/glossary/): evidence_row, macro snapshot, GLOBAL meta row, the id-key contract, debounce.
