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.
- θ results were never turned into an
evidence_row. The skill-status engine had no per-sub-skill evidence to recompute from. - The decision engine wrote each
domain_status_snapshotbut 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_rowmust be durable beforerecomputeForEvidencere-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 }withevidenceType = "screening_score".correctis summed from the server-gradedinputResponses.isCorrectover the distinct scored items. It is never re-derived from θ, because a second scoring path would be a V-6 hazard.studentId = StudentProfile.idend-to-end.- The idempotency backstop is the first-finish gate in
finishSession. Theevidence_rowtable itself has no UNIQUE constraint. continuouswindows produce no screening evidence.screeningCycleToEvidenceWindowreturns 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.
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:
feederStatusescarries[{domainId, domainStatus, dsmRuleRef, engineSnapshotRef}]so a reader can drill down into which domains drove a tile.computedAtandoccurredAtare frozen tosession.finishedAt(V-6), so the macro rows replay byte-identically.
The id-key contract (StudentProfile.id vs User.id)
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:
- The named macro consumers: reporting, board PDF, multi-school, parent PDF, and the
profile module’s
readProfileInputContext. - 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.
Related subsystems
- Diagnostic & Practice: the producer that finishes a session and triggers the bridge.
- Skill-Status Engine: the consumer of
evidence_rowand the home of the macro writer. - Reporting & Parent PDF: the macro consumer that reads the snapshots.
- The Decision Engine: the full rollup mechanics and the GLOBAL meta row.
- Glossary: evidence_row, macro snapshot, GLOBAL meta row, the id-key contract, debounce.