Lesson 176: Partner Audit Reply Packet Versioning Against Archived Tuple Hashes (2026)

Why this matters now

Lessons 171 and 175 gave you two different guarantees: the publish pipeline fail-closes when live dashboard tuples drift, and the cold archive row pins the exact publish_tuple_hash that shipped inside the original submission packet. Neither guarantee, by itself, protects the reply lane — the PDFs and annexes your team and the partner circulate during the multi-round comment loop that follows an auditor question.

In 2026, the default posture of mainstream PDF collaboration stacks skews toward optimistic merge on shared comment rounds: two reviewers resolve overlapping comments, the tool reconciles layers, and the saved file bytes change without a human noticing. That behavior is fine for marketing brochures; it is a governance incident when the bytes you attach to a signer_ack_event row no longer hash-match the narrative you think you are defending against the archive row the partner already bound to their risk letter.

Partner cert forums in late 2026 now list tuple drift inside reply PDFs among the top five reopen causes — not because teams forgot Lesson 171, but because they treated replies as informal attachments outside the tuple discipline. When annex numbers disagree with leadership rollups, the fastest de-escalation path is still the rollup mismatch help workflow; this lesson ensures you know which reply semver carried the annex before you start that workflow. This lesson closes that gap with a mechanical pattern: every reply packet gets a stable reply_packet_id, monotonic semver, content hash, explicit parent link to archive_id, and a signer ack row that references the hash, not the filename.

If you ship this pattern before your next partner readback, you answer the 2026 template question "show me the hash of the reply PDF we countersigned on date D" with a database row and a reproducible recompute path. If you skip it, you discover on reopen that Acrobat or SharePoint silently flattened layers between round two and round three — and your signer ack still says approved even though the file changed.

Lesson objectives

By the end of this lesson you will have:

  • A partner_reply_packet table that versions every outbound reply PDF and annex with parent_archive_id, reply_semver, reply_bytes_sha256, and a frozen reply_tuple_manifest_json that lists every embedded table cell the reply claims to quote from the archived packet.
  • A forbid-silent-rewrite gate at save time: any mutation to reply bytes without bumping reply_semver and recording a new hash fails closed with the same red-banner semantics you already use for FAQ rows in Lesson 170.
  • A signer_ack_event extension (from Lesson 174) that carries nullable reply_packet_id — every ack that approves a reply for partner distribution must reference the exact packet row the signer read, not a path on disk.
  • A recompute verification job that re-merges reply sources from cold storage, re-hashes, and compares to reply_bytes_sha256 before CDN or SharePoint publish is allowed to run.
  • A partner-facing change log block that drops into the Lesson 170 FAQ-bound readback export, listing each reply round with semver, hash, signer, and UTC timestamp so external reviewers can diff rounds without trusting optimistic merge.

Prerequisites from earlier lessons

This lesson assumes:

  • Lesson 171 publish_tuple_hash / source_tuple_hash discipline on the publish pipeline.
  • Lesson 175 packet_archive_pointer with archive_id, artifact_sha256, and publish_tuple_hash binding.
  • Lesson 174 signer_ack_event with route-aware SLA columns you will extend with reply_packet_id.
  • Lesson 170 FAQ-bound readback structure for where the partner-visible change log lands.
  • Lesson 167 epsilon_policy_version pins when reply annexes quote synthetic replay numbers — replies must carry the same pin as the archive row, not the live policy.

If packet_archive_pointer is missing, you cannot parent reply rows to a forensic anchor. If signer_ack_event is missing, you cannot bind human intent to bytes. Build those first.

The partner_reply_packet table

Treat each reply round as a first-class immutable artifact, not a mutable file on a desktop:

CREATE TABLE partner_reply_packet (
  reply_packet_id         TEXT PRIMARY KEY,
  parent_archive_id       TEXT NOT NULL REFERENCES packet_archive_pointer(archive_id),
  reply_semver            TEXT NOT NULL,                 -- e.g. '1.2.0' monotonic per archive_id
  reply_round_label       TEXT NOT NULL,                 -- 'R1_partner_comments' | 'R2_countersign' | ...
  reply_bytes_sha256      CHAR(64) NOT NULL,
  reply_bytes_size        BIGINT NOT NULL,
  reply_mime              TEXT NOT NULL DEFAULT 'application/pdf',
  reply_tuple_manifest_json JSONB NOT NULL,            -- quoted cells + figure IDs from archive
  tool_merge_mode         TEXT NOT NULL
                          CHECK (tool_merge_mode IN ('strict_linear','optimistic_blocked')),
  created_at_dt_utc       TIMESTAMPTZ NOT NULL,
  created_by              TEXT NOT NULL,
  superseded_by           TEXT REFERENCES partner_reply_packet(reply_packet_id),
  UNIQUE (parent_archive_id, reply_semver)
);

Why tool_merge_mode is explicit: Your policy should default to optimistic_blocked for governance PDFs — the authoring tool is configured to refuse silent merges; if a reviewer absolutely must use optimistic merge for a redacted annex, that choice becomes auditable metadata instead of an invisible default.

Why reply_tuple_manifest_json is mandatory: Hashing the PDF alone proves the file did not change; it does not prove the file says true things about the archive. The manifest lists the tuple-backed claims the reply makes (metric IDs, dictionary row keys, figure hashes) so a verifier can fail closed on content drift even when the PDF renderer stayed stable.

Wiring reply_packet_id into signer_ack_event

Extend the Lesson 174 table with a nullable foreign key:

ALTER TABLE signer_ack_event
  ADD COLUMN reply_packet_id TEXT REFERENCES partner_reply_packet(reply_packet_id);

ALTER TABLE signer_ack_event
  ADD CONSTRAINT signer_ack_reply_requires_hash
  CHECK (
    reply_packet_id IS NULL
    OR EXISTS (
      SELECT 1 FROM partner_reply_packet prp
      WHERE prp.reply_packet_id = signer_ack_event.reply_packet_id
        AND prp.reply_bytes_sha256 IS NOT NULL
    )
  );

Operational rule: Any ack whose event_type is in the partner-distribution allow-list (partner_send_ok, cdn_publish_ok, sharepoint_sync_ok — name these to match your enum) must have non-null reply_packet_id. Internal drafts may omit it; anything that crosses the firewall may not.

This is the mechanical answer to the 2026 partner questionnaire line: "Demonstrate that the human signer row references an immutable packet version."

Forbid silent rewrites: application gate

Implement a single function assert_reply_save_allowed(old_hash, new_bytes, signer_id) used by every save path:

  1. If sha256(new_bytes) = old_hash, allow save as metadata-only touch (for example typo fix in sidecar YAML outside the PDF — still log the event).
  2. If bytes changed and reply_semver was not bumped via the controlled bump_reply_semver transaction that also inserts the new partner_reply_packet row, raise and surface the Lesson 170 red banner: silent_reply_rewrite_blocked.
  3. If bytes changed and semver bumped but reply_tuple_manifest_json is missing any key present in the prior row's manifest, raise — shrinking the accountability surface without explicit carve-out documentation is the same failure class as deleting a footnote in a countersigned contract.

The gate is not about trusting humans; it is about making the forbidden path more expensive than the allowed path.

Recompute verification job before distribution

Add a nightly (or per-publish) job verify_reply_packet_bytes:

  1. Load parent_archive_id → fetch cold storage blob per Lesson 175; verify artifact_sha256.
  2. Rehydrate reply sources from your object store (never from a reviewer's laptop path).
  3. Re-run the deterministic PDF build pipeline version pinned in footer_schema_semver for that archive.
  4. Compare output hash to reply_bytes_sha256. Mismatch → insert reply_verify_failure row, block CDN publish, page on-call owner from Lesson 174 heat-map routing.

This job catches the case where a desktop PDF editor wrote a correct-looking file to disk but the CI builder would not reproduce it — exactly the class of issues 2026 partner reviewers learned to probe after two high-profile reopen events traced to font subset differences.

Partner change log block (Markdown export)

Embed this template in docs/partner-reply-templates/reply-round-log.md and append to the Lesson 170 export:

### Partner reply round log (tuple-bound)

| Round | reply_semver | reply_bytes_sha256 | signer | UTC | parent_archive_id | publish_tuple_hash (trunc) |
|-------|--------------|--------------------|--------|-----|-------------------|----------------------------|
| R1 | 1.0.0 | …64… | … | … | … | abc… |
| R2 | 1.1.0 | …64… | … | … | … | abc… |

The truncated hash is display-only; the database row carries full 64 chars. Partner reviewers compare round-to-round semver monotonicity in seconds; missing semver increments stand out as loudly as a skipped mock_audit_log entry from Lesson 172.

Common mistakes to avoid

  • Letting email be the system of record for replies. Email is mutable metadata; the partner_reply_packet row is the record. Attach the hash in the email body, but never treat the inbox as canonical.
  • Allowing "same semver, new bytes" during redaction. Redaction is still a content change — bump patch semver and record redaction_ack_id if your policy requires dual control.
  • Assuming Git LFS pointer stability equals PDF stability. LFS stores blobs; PDF tools still rewrite them on open. Always hash the bytes you are about to send, not the blob you checked in last Tuesday.
  • Omitting tool_merge_mode from exports. If you hide the merge mode, partners assume you used vendor defaults — and 2026 defaults skew optimistic.
  • Binding acks to filenames instead of reply_packet_id. Filenames lie; primary keys do not.
  • Skipping manifest diff on annex XLSX exports. Sheets drift silently when analysts sort columns; manifest keys must follow dictionary IDs, not column order.

Verification checklist

  • [ ] partner_reply_packet exists with UNIQUE (parent_archive_id, reply_semver) and non-null reply_tuple_manifest_json on every row.
  • [ ] signer_ack_event.reply_packet_id enforced for any ack type that authorizes partner-visible distribution.
  • [ ] assert_reply_save_allowed integrated into all desktop + web save handlers that touch governance PDFs.
  • [ ] verify_reply_packet_bytes job exists with failure row + publish block on mismatch.
  • [ ] Partner change log template ships inside the Lesson 170 export and includes full SHA-256 in the machine-readable JSON sidecar your CI emits.
  • [ ] At least one tabletop rehearsal includes an intentional optimistic-merge attempt that must fail closed before any signer ack is granted.

What you have just earned

You can now tell a complete forensic story across three artifacts: the archive pinned publish_tuple_hash (Lesson 175), the live dashboard tuple gate (Lesson 171), and the reply PDF bytes your humans actually countersigned in 2026's hostile PDF toolchain environment. That triad is what turns partner reopen questions from narrative debates into row lookups.

Next lesson teaser

The next lesson (Lesson 177: Governance Metric Dictionary Minor-Increment Migration Without Breaking the Lesson 164 Bind Contract (2026)) covers safe minor dictionary semver bumps when Lessons 173–176 add columns and manifest keys — dual-write windows, exporter allow-list diffs, and rollback triggers tied to the Lesson 166 reconciliation job so leadership–partner rollup parity from Lesson 164 survives real-world Unity 6.x / 2026.2 BI field renames.

Continuity

  • Paired Unity guide chapter (next Guide-Create pass will author): Unity 6.6 LTS OpenXR governance partner reply packet versioning preflight — editor-side Governance/PartnerReplyPacket ScriptableObject mirroring partner_reply_packet with semver bump wizard wired to fail-closed save gate, CI job binding that invokes the same verify_reply_packet_bytes docker image the backend uses, and optional Scriptable build step that emits the Markdown change log fragment for Lesson 170 exports.
  • Help article: OpenXR Governance Partner SLA Snapshot vs Leadership Dashboard Rollup Mismatch (Quest) Fix — when reply annexes quote rollup totals, this article is the upstream discipline for proving annex totals match dashboard tuples before they ever enter a reply manifest.
  • Lesson 175 — archive pointer and retrieval drill; parent rows for every reply_packet_id.
  • Lesson 174 — signer fatigue routing; extended ack rows for reply distribution.
  • Lesson 173 — deficiency recurrence board; reply manifests should cite failure_mode_tag rows, not free text.
  • Lesson 172 — mock audit rubric; reply rounds that close deficiencies must reference mock_audit_log ids inside manifest JSON.
  • Lesson 171publish_tuple_hash source semantics carried into reply parent linkage.
  • Lesson 170 — FAQ-bound readback host for the visible change log.
  • Lesson 167 — epsilon policy pins for any replay-derived numeric quoted in replies.
  • Lesson 164 — dictionary semver pins that Lesson 177 will migrate without breaking bind contracts.

Where optimistic merge bites in real 2026 toolchains

Adobe Acrobat shared reviews — when two reviewers accept overlapping sticky notes, Acrobat may flatten layers and rewrite cross-reference tables inside the PDF structure. The on-screen pixels look identical while the byte stream shifts. Your hash gate catches this immediately if you hash after every save that is intended to become canonical.

SharePoint / OneDrive co-authoring on exported PDFs — teams sometimes drop a PDF into a synced folder "just for comments." The sync client resolves conflicts with last-writer-wins semantics that do not understand governance meaning. Policy fix: PDFs that participate in partner_reply_packet live only in WORM-style buckets (Lesson 175), never in general collaboration folders.

Browser-based PDF annotators inside ticketing systems — Jira, Linear-as-PDF-attachments, and similar flows often transcode uploads. Always treat the ticket attachment as a staging object; promote to partner_reply_packet only after the CI builder ingests bytes from object storage, not from the ticket URL.

Redaction tooling — redaction is a content transform. Even when the visible body shrinks, font embedding and xref tables move. Teams that forget semver bumps on redaction are the same teams that get asked in reopen "why does the countersigned PDF not match the hash in your ack row?"

reply_verify_failure table and alert routing

CREATE TABLE reply_verify_failure (
  failure_id              TEXT PRIMARY KEY,
  reply_packet_id         TEXT NOT NULL REFERENCES partner_reply_packet(reply_packet_id),
  expected_sha256         CHAR(64) NOT NULL,
  observed_sha256         CHAR(64) NOT NULL,
  build_pipeline_rev      TEXT NOT NULL,
  detected_at_dt_utc      TIMESTAMPTZ NOT NULL,
  resolution_status         TEXT NOT NULL DEFAULT 'open'
                          CHECK (resolution_status IN ('open','waived_carve_out','fixed_reshipped')),
  resolution_note           TEXT
);

Page routing should reuse Lesson 174 on-call selection: the signer who approved the bad distribution path is not automatically the resolver — pick the green-bucket owner for forensic isolation.

Step-by-step core path (first ship)

  1. Mint reply_packet_id for round R0 immediately after you assemble the first outbound PDF from archived sources only — no live-dashboard paste.
  2. Compute reply_tuple_manifest_json by walking every numeric table cell; each entry is { "dictionary_metric_id": "...", "value": "...", "source_figure": "F3" } with dictionary IDs from the archived semver, not today's allow-list.
  3. Run verify_reply_packet_bytes in CI before inserting the row as candidate; flip to approved_for_ack only on green.
  4. Collect signer ack with reply_packet_id set; block if heat-map bucket is red unless backup owner already promoted per Lesson 174.
  5. Publish through the same CDN gate Lesson 171 uses — add reply_packet_id to the publish manifest JSON your pipeline already emits for tuple receipts.
  6. Append the Markdown change log fragment to the Lesson 170 export bundle and regenerate its bundle hash.

Mini challenge (45 minutes)

Take any historical PDF reply your team shipped (sanitize names). Recompute SHA-256 today. Open it in Acrobat, accept two synthetic comments, save. Hash again. Document the delta length in bytes and list three xref-table differences if you have a PDF debugger handy. That one exercise convinces stakeholders faster than another governance slide deck.

Troubleshooting

Symptom Likely cause Fix
CI verify always disagrees with desktop hash Font embedding policy mismatch Pin font pack revision inside build_pipeline_rev and dockerize the builder
Manifest insert rejects valid cells Dictionary semver drift vs archive Re-fetch allow-list snapshot for archived dictionary_semver, not HEAD
Signer ack blocked though PDF "unchanged" Metadata-only save still touched bytes Hash the exact stream you attach; ban "Save As" paths that rewrite IDs
Partner sees two R2 files with same semver Race on concurrent editors Wrap semver bump + insert in serializable transaction per parent_archive_id

Pro tips

  • Tip — Binary-identical round reuse: If round R3 is byte-identical to R2, store superseded_by self-pointer semantics only if your counsel agrees; otherwise mint a patch bump anyway — some partners interpret identical semver as fraud-adjacent even when technically true.
  • Tip — Machine-readable sidecar: Emit reply-packet-176.json next to each PDF with the full 64-char hash so automated inbox rules can grep attachments without parsing PDF.
  • Tip — Heat-map coupling: When reply_verify_failure spikes for a single signer, mirror that signal into Lesson 173 trend board as tooling-hot — it is often a training issue, not a malicious one.

FAQ

Does every annotation round need a new semver patch?
No — only rounds that change shipped bytes or manifest keys. Pure comment threads that never produce a new PDF do not consume a semver; they still produce signer_ack_event rows with reply_packet_id null until a build artifact exists.

What if the partner insists on editing our PDF in their tool?
Treat their returned file as inbound evidence, not your canonical reply. Import bytes as a new row with reply_round_label = 'R_partner_redline_return', hash it, diff manifests, then mint your countersigned outbound row — never mutate your canonical row in place.

Can we reuse the same thumbnail pipeline as blog posts?
Thumbnails are decorative; packet hashes are not. Keep selection discipline from data/courses-thumbails.json, but never confuse CDN image URLs with reply_bytes_sha256.

How does this interact with rollup mismatch investigations?
When a partner alleges that annex totals disagree with leadership rollups, you first prove which reply semver carried the annex, then recompute rollup slices under the archived tuple using the help article workflow. Reply versioning does not replace rollup reconciliation — it tells you which file to open while you run that reconciliation.


Reply packets are where optimistic-merge defaults quietly undo months of governance work. Version them like code, bind signer intent to primary keys, and let CI prove the bytes before the partner ever sees them. That is how 2026 cert teams turn reopen risk into a solved operations pattern.