Lesson 171: Tuple Drift Automatic Block on Dashboard Publish Pipeline (2026)
Why this matters now
Through 2026 the small-team OpenXR governance stack has finally closed the loop on what readers see and what partners read:
- Lesson 164 bound every metric to a versioned dictionary.
- Lesson 165 pinned the footer schema and the replay parser contract.
- Lesson 169 rehearsed the freeze-lift dry run against dashboard snapshot tuples.
- Lesson 170 stopped silent rewrites between board packs and partner annex FAQ rows.
The remaining trust failure is the one that does not look like a number bug or a wording bug. It looks like a clock bug. A partner-facing analyst opens the source sheet at 02:14 UTC during a crunch week, fixes a typo in cell D47, saves, and walks away. Twelve minutes later your weekly publish pipeline pushes the dashboard tuple to your CDN and SharePoint annex. The tuple in the published packet now disagrees with the source sheet, but no human saw the disagreement because the dashboard had already rendered before the edit. The FAQ-bound readback rows from Lesson 170 still point at the previous tuple. Your partner reviewer opens the annex and sees numbers that contradict the same row in the board pack.
This lesson installs a hash-comparison gate at the publish pipeline boundary so this failure mode becomes mechanically impossible. The gate is small (a single hash check plus an owner route) and fail-closed (a missing or mismatched hash stops the publish before any artifact reaches the CDN or SharePoint annex). It runs at the same checkpoint cadence you built in Lesson 170: dictionary advance → FAQ pending_review → partner annex → executive readback → freeze-lift dry run → publish gate → distribution.
If you skipped Lesson 170, finish it first. The publish gate consumes Lesson 170's FAQ-bound readback tuple by reference, not by re-fetching the source sheet.
Lesson objectives
By the end of this lesson you will have:
- A
publish_tuple_hashcolumn on every dashboard snapshot artifact, computed from the same row set the FAQ-bound readback consumes. - A block contract that compares
publish_tuple_hashagainst the source-sheet hash at the moment of publish and fail-closes on any mismatch. - A named
block_reasonenum for the publish gate so post-incident retros can categorize blocks without re-reading the pipeline log. - An owner-route table that maps each
block_reasonto a single human owner with a 30-minute SLA for the first response. - A publish replay-audit log binding
publish_run_idtopublish_tuple_hash,source_tuple_hash,block_reason,owner, andresolution_id. - A same-checkpoint cadence entry placing the publish gate immediately after the freeze-lift dry-run rehearsal and immediately before CDN and SharePoint distribution.
- A first-day habit: pre-mint three sample publish runs (one passing, one fail-closed on mismatch, one fail-closed on missing hash) before your first cert-window publish.
Prerequisites from earlier lessons
- Lesson 162 - SLA breach forecasting freeze gates - gives you the freeze and lift identifiers the publish gate joins on.
- Lesson 164 - leadership / partner SLA dashboard sync and executive readback - gives you the metric dictionary the source-tuple hash is computed over.
- Lesson 165 - governance packet footer metadata schema semver - gives you the
footer_schema_semverthe publish artifact carries. - Lesson 167 - synthetic replay diff gate - gives you the
epsilon_policy_versionthe publish gate consumes when computing tuple equality. - Lesson 169 - freeze-lift rehearsal dry run - the publish gate runs immediately after the dry-run rehearsal pass.
- Lesson 170 - executive readback redlines versus partner annex FAQ discipline - the publish gate consumes the FAQ-bound readback tuple by reference.
If you have not yet implemented these, return to them. The publish gate is additive, not a replacement.
Step 1 - Add publish_tuple_hash to every snapshot artifact
The publish artifact gains one new required column. Every dashboard snapshot the pipeline writes carries this hash from now on.
publish_tuple_hash is computed by:
- Selecting the same row set the FAQ-bound readback consumes (Lesson 170's
faq_rowstable joined to the dashboard view). - Sorting rows by
metric_idascending, then bydimension_filter_idascending (the same sort order Lesson 167's synthetic replay diff gate uses). - Serialising the sorted row set to a canonical form. We recommend JSON Canonical Form (RFC 8785) so trailing whitespace, key order, and number formatting cannot create false-positive mismatches.
- Hashing the canonical bytes with SHA-256.
-- pseudocode in your dashboard view layer
INSERT INTO publish_snapshots (
publish_run_id,
freeze_id,
lift_id,
dry_run_id,
publish_tuple_hash,
source_tuple_hash,
footer_schema_semver,
replay_parser_contract,
epsilon_policy_version,
status,
created_at_utc
)
VALUES (
:publish_run_id,
:freeze_id,
:lift_id,
:dry_run_id,
:publish_tuple_hash,
:source_tuple_hash,
'v2.4.0',
'v1.6.2',
'v3.1.0',
'pending_gate',
NOW()
);
source_tuple_hash is the hash of the source sheet at the moment the pipeline reads it. The two hashes are computed by the same function over the same canonical form.
Step 2 - The publish block contract
The gate is a single SQL statement. It runs after the freeze-lift dry-run rehearsal completes (Lesson 169) and before the CDN and SharePoint distribution jobs.
-- block contract: fail closed on any of the four conditions
UPDATE publish_snapshots
SET status = 'blocked',
block_reason = CASE
WHEN publish_tuple_hash IS NULL THEN 'missing_publish_hash'
WHEN source_tuple_hash IS NULL THEN 'missing_source_hash'
WHEN publish_tuple_hash != source_tuple_hash THEN 'tuple_drift'
WHEN footer_schema_semver != (SELECT current_semver FROM footer_schema_registry) THEN 'footer_schema_drift'
ELSE NULL
END,
blocked_at_utc = NOW()
WHERE publish_run_id = :publish_run_id
AND status = 'pending_gate'
AND (
publish_tuple_hash IS NULL
OR source_tuple_hash IS NULL
OR publish_tuple_hash != source_tuple_hash
OR footer_schema_semver != (SELECT current_semver FROM footer_schema_registry)
);
The CDN and SharePoint distribution jobs both refuse to run on any publish_run_id whose status is not exactly gate_passed. There is no override flag at the distribution layer; an emergency promotion has to flow through the carve-out workflow from Lesson 163, which writes a separate freeze_bypass_id column.
Step 3 - The block_reason enum
The gate writes one of these block_reason values, never free text. Categorisation discipline matters because retros (Lesson 160) consume this column as input.
block_reason |
Meaning | Typical owner |
|---|---|---|
missing_publish_hash |
The pipeline failed to compute publish_tuple_hash (logic bug). |
engineering_lead |
missing_source_hash |
The source sheet hash was unreachable at publish time (sheet locked, permissions). | ops_lead |
tuple_drift |
The two hashes disagree (the canonical case this lesson exists for). | governance_lead |
footer_schema_drift |
The artifact's footer_schema_semver no longer matches the registered current version (Lesson 165). |
governance_lead |
Anything outside this enum is a gate bug, not a block_reason. If your code path can write a fifth value, fix the code path.
Step 4 - The owner-route table
Every block_reason maps to exactly one human owner. The owner has a 30-minute first-response SLA. The route is a table, not a runbook page, so it cannot drift silently.
CREATE TABLE block_reason_owner_route (
block_reason TEXT PRIMARY KEY,
owner_role TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
backup_owner_user_id TEXT NOT NULL,
sla_first_response_minutes INT NOT NULL DEFAULT 30,
updated_at_utc TIMESTAMP NOT NULL,
updated_by_user_id TEXT NOT NULL
);
Any update to block_reason_owner_route is itself a governance event: it requires the same signer-ack pattern Lesson 170 used for FAQ row changes. You cannot silently re-route a tuple_drift block to a different owner; the routing change is a route_change_id that signers ack just like a FAQ rewrite.
Step 5 - The publish replay-audit log
Every publish run writes one row to publish_replay_audit:
CREATE TABLE publish_replay_audit (
publish_run_id TEXT PRIMARY KEY,
freeze_id TEXT NOT NULL,
lift_id TEXT NOT NULL,
dry_run_id TEXT NOT NULL,
publish_tuple_hash TEXT,
source_tuple_hash TEXT,
status TEXT NOT NULL CHECK (status IN ('gate_passed','blocked','resolved','withdrawn')),
block_reason TEXT,
owner_user_id TEXT,
resolution_id TEXT,
created_at_utc TIMESTAMP NOT NULL,
resolved_at_utc TIMESTAMP
);
A blocked run can transition only to resolved (after a resolution_id records the corrective publish run) or withdrawn (after the source sheet edit itself was rolled back). It can not transition to gate_passed after the fact - that would let an off-record edit retroactively legitimise a drifted publish.
Step 6 - Same-checkpoint cadence placement
The publish gate runs in this exact position in the cadence, every cert week:
- Dictionary advance (Lesson 164).
- FAQ
pending_reviewcleared (Lesson 170). - Partner annex revision pinned (Lesson 164).
- Executive readback projection pinned (Lesson 164 + 170).
- Freeze-lift dry-run rehearsal pass (Lesson 169).
- Publish gate (this lesson).
- CDN distribution job.
- SharePoint annex sync job.
- Weekly reconciliation tail (Lesson 166).
Step 6 is the only step that can stop steps 7 and 8 from running. Steps 1-5 surface red banners on the dashboard; only step 6 holds the distribution jobs.
Step 7 - Refuse to lift on any open publish block
The freeze-lift rehearsal from Lesson 169 gains one new fail-closed gate. The dry-run block_reason enum extends with open_publish_block:
SELECT
pra.publish_run_id,
pra.block_reason,
pra.owner_user_id,
pra.created_at_utc
FROM publish_replay_audit pra
WHERE pra.status = 'blocked'
AND pra.resolved_at_utc IS NULL;
If this query returns any rows during the next dry-run rehearsal, the rehearsal fails with block_reason = 'open_publish_block'. A team that lifts the freeze with an open publish block is publishing a tuple that contradicts the FAQ-bound readback - the exact failure the gate exists to prevent.
Step 8 - Pre-mint three sample publish runs before your first cert window
Before your first cert-window publish, run the pipeline three times in a staging environment:
- Passing run - source sheet and dashboard agree, gate writes
status = 'gate_passed', distribution jobs run, audit row recordsblock_reason = NULL. - Fail-closed on tuple_drift - edit one cell in the source sheet between snapshot computation and publish; gate writes
block_reason = 'tuple_drift', distribution jobs do not run, owner route emailsgovernance_leadwithin 30 minutes. - Fail-closed on missing_publish_hash - simulate a pipeline bug by skipping the canonical-form serialisation step; gate writes
block_reason = 'missing_publish_hash', distribution jobs do not run, owner route emailsengineering_leadwithin 30 minutes.
The three pre-mints teach your owners what the email subject line and dashboard banner look like before a real cert-week publish lands an unfamiliar pattern on a tired team.
Common mistakes to avoid
- Computing
publish_tuple_hashover a different row set than the FAQ-bound readback - the two hashes have to be over the same canonical form, sorted the same way, or you will generate false-positive drift. - Allowing the CDN job to override the gate with a flag - any override at the distribution layer collapses the whole governance stack. Emergency promotion routes through Lesson 163's carve-out workflow with a separate
freeze_bypass_id. - Free-text
block_reason- retros (Lesson 160) consume this column. Free text destroys the histogram and the route table. - Silent rerouting - changes to
block_reason_owner_routeneed the same signer-ack discipline as FAQ row changes from Lesson 170. - Skipping pre-mint runs - the first failed gate during a real cert window is not the time to discover the email goes to the wrong inbox.
- Treating
resolvedas a soft pass -resolvedrequires a new publish run with a fresh hash pair. It is not a flag on the original blocked row.
Verification checklist
Run this checklist on a fresh staging environment before your first cert-window publish:
- [ ]
publish_tuple_hashandsource_tuple_hashare both populated on every row ofpublish_snapshotsafter a successful run. - [ ] Editing one cell in the source sheet between snapshot computation and publish writes
block_reason = 'tuple_drift'and does not kick the CDN job. - [ ] Removing a
publish_tuple_hashrow writesblock_reason = 'missing_publish_hash'and does not kick the CDN job. - [ ] Bumping
footer_schema_semverwithout updatingfooter_schema_registry.current_semverwritesblock_reason = 'footer_schema_drift'. - [ ] Owner-route emails arrive at the correct inbox within 30 minutes for each of the four block reasons.
- [ ] The freeze-lift dry-run rehearsal fails with
block_reason = 'open_publish_block'when an unresolved publish block exists. - [ ]
publish_replay_auditrows transition only along the allowed paths (blocked -> resolvedorblocked -> withdrawn, neverblocked -> gate_passed).
If any item fails, fix it before publishing. The gate's value is its fail-closedness; one weak edge undoes the whole pattern.
What you have just earned
After this lesson the failure mode that drove this lesson out of existence is mechanically impossible in your stack. An async edit to the source sheet during crunch week can no longer push a stale tuple through CDN or SharePoint. The edit either:
- Lands before snapshot computation - the gate sees consistent hashes and passes.
- Lands after publish - the next pipeline run picks it up, the gate sees consistent hashes, and the previous publish is the canonical version until the next checkpoint cadence.
- Lands between snapshot and publish - the gate sees mismatched hashes, fail-closes the publish, routes to
governance_leadwithin 30 minutes, and the dashboard banner shows the block until a corrective publish lands.
You also gained a new audit surface (publish_replay_audit) that retros (Lesson 160) and weekly reconciliation (Lesson 166) both consume directly. The gate produces structured evidence by design, not by accident.
Next lesson teaser
Continue with Lesson 172 - Q3 2026 Submission Intake Mock Audit Tabletop Scoring Rubric (2026) to rehearse the full packet stack against a Q3 2026 submission intake density mock - leadership-versus-partner alignment, carve-out annex presence, footer semver, reconciliation job green, and now the publish-gate audit row, all scored on a single rubric so the small team practices the entire packet before the real submission window opens.
Continuity
- Paired Unity guide chapter: Unity 6.6 LTS OpenXR Governance Tuple Drift Automatic Block on Dashboard Publish Pipeline Preflight - editor-side ScriptableObject groups for the
publish_snapshots,block_reason_owner_route, andpublish_replay_audittables; BI-side bind contract; same-checkpoint cadence placement and pre-mint workflow. - Help article: OpenXR Governance Partner SLA Snapshot vs Leadership Dashboard Rollup Mismatch (Quest) Fix - the rollup-mismatch failure mode the publish gate retires once it lands in your stack.
- Lesson 170 - Executive Readback Redlines Versus Partner Annex FAQ Discipline (2026) - the FAQ-bound readback tuple this lesson's gate consumes by reference.
- Lesson 169 - Freeze Lift Rehearsal Dry Run Coupling to Dashboard Snapshot Tuples (2026) - the dry-run rehearsal that runs immediately before the publish gate.
- Lesson 167 - Synthetic Replay Diff Gate for Dashboard Column Variance Epsilon Policy (2026) - the epsilon policy version the publish gate consumes when computing tuple equality.
- Lesson 164 - Leadership Partner SLA Dashboard Sync and Executive Readback (2026) - the metric dictionary the source-tuple hash is computed over.
Tuple drift is a clock problem disguised as a number problem. The publish gate stops the clock at the only moment when stopping it matters: the second before partner-facing distribution.