# ECP-0156: Duplicate Publisher Deterministic Data Layer

Status: Draft

## Context

Two publisher nodes may broadcast the same logical channel at the same time. The archive and relay
layers need this for resilience, but duplicate publishers currently risk looking like conflicting
streams instead of convergent copies of the same media.

## Decision

Duplicate publishers are valid for a published channel. The data layer dedupes and verifies media by
content identity, not by publisher envelope identity:

- CMAF init and media segment bytes for the same input, ladder profile, and chunk cadence must be
  byte-for-byte identical.
- BLAKE3 media hashes and per-rung Merkle roots are the shared data identity.
- Publisher manifests may carry different `stream_id`, `epoch_id`, `created_unix_ms`, signatures,
  locators, and manifest ids.
- The archive must treat matching media hashes from different publishers as corroborating sources.
- Archive records must carry source identity. Two copied buffers with the same `source_node` are not
  duplicate-publisher proof, even when their BLAKE3 hashes match.
- Divergent hashes for the same logical channel, rendition, and media time are misses that must be
  measured before the data is promoted as redundant.

## Verification

The proof path has two stages:

1. Single-node duplicate-publisher tests produce the same ladder twice with different publisher
   identities and assert byte-for-byte BLAKE3 equality for every generated init and media segment.
   The `duplicate_publishers_same_input_produce_identical_cmaf_ladder_bytes` test is part of the
   default Rust test path when ffmpeg is present; it is not an ignored E2E.
2. Production verification runs the same channel on two real publishers long enough to measure
   duplicate media convergence, hash divergence, missing objects, and backfill behavior in Grafana.

The goal is not just "two publishers are online." Success requires elapsed production time behind the
numbers and dashboards that show duplicate hits, misses, and archive repair.

## Consequences

Manifest ids cannot be used as the archive dedupe key for duplicate publishers. Operators get a
clear signal when two publishers produce identical bytes versus merely announcing the same channel.
If encoder determinism changes, the single-node test fails before production redundancy silently
degrades.

## Alternatives considered

- Dedupe by manifest id. This preserves envelope identity but misses the resilience property because
  duplicate publishers necessarily produce different envelopes.
- Dedupe by logical channel and time only. This can hide encoder divergence and promote bad
  redundancy before byte-level media equality is proven.
- Disable duplicate publishers until the scheduler is perfect. This avoids conflict handling but
  weakens live resilience and leaves the archive data layer untested.

## Rollout/teardown

Roll forward by landing the local deterministic test, adding miss/duplicate metrics to the archive
scrape surface, then running two publishers for one logical channel in production. Roll back by
disabling duplicate scheduling for that channel; existing content-addressed archive objects remain
valid.

## Implementation notes

The node-agent archive scrape now exposes duplicate-source and miss gauges without placing hashes in
labels. Per node, role, broadcast, rendition, and track it reports duplicate matching hash sources,
duplicate hash sequences, divergent hash sequences, and missing hash records. Grafana shows those
next to archive ladder coverage so the production duplicate-publisher run has an operator-visible
convergence and miss signal.

`ec-node archive-convergence` is the primary proof surface for duplicate media identity. It compares
named archive manifest roots directly inside the Rust node binary, groups records by logical stream,
rendition, track, and sequence, and only returns `ok` when every expected sequence has matching
duplicate source hashes with no missing or divergent sequence. It also requires archive records to
carry at least two distinct `source_node` values, so mirrored global-origin manifests cannot pass as
independent publishers. This keeps the media-data invariant in the already-shipped Rust artifact
instead of extending the Python node-agent. Rollout gates should use
`ec-node archive-convergence --require-ok`; the command emits the JSON report either way, but
`--require-ok` exits non-zero unless duplicate convergence is actually proven.
`ec-node archive-convergence --prometheus` renders the same Rust convergence report as scrapeable
`every_channel_archive_*` gauges for duplicate source records, duplicate sequences, divergent
sequences, source-local divergence, missing hashes, missing source identity, media timing conflicts,
record source count, and pass/fail state. This gives Grafana a Rust-owned proof metric path while
the older node-agent ladder metrics remain available during migration.
`ec-node archive-convergence-serve` keeps that proof path live for Prometheus: it serves `/health`
and `/metrics`, recomputes convergence on each scrape, and emits `scrape_ok=0` metrics instead of
disappearing when manifests are missing or not ready. Production Grafana can therefore distinguish a
healthy metrics target from an unproven duplicate-publisher run.
The Nix `services.every-channel.ec-node.archive.convergence.proofs` option turns those Rust proof
servers into named systemd units. Each proof must name at least two `NAME=PATH` sources and gets a
dedicated listen address, so operators can add one Prometheus scrape target per duplicate channel
without resurrecting the Python node-agent as the proof oracle.
Forge enables an initial `la-kcop-publisher-origin` proof target on `127.0.0.1:7812` and Prometheus
scrapes it alongside the other local every.channel targets. Until two real publisher manifest roots
are mounted or fetched into Forge, the target intentionally uses the Forge manifest root as a
placeholder peer and must report unproven convergence rather than green duplicate-publisher proof.
Forge also exposes a static two-NUC `la-kcet-remote-publisher-origin` proof target once that channel
is the live converged duplicate sample. Dynamic Headscale file-SD remains useful for discovery, but
it can include relays and stale nodes; duplicate-publisher proof should use an explicit publisher
pair or future scheduler group labels so unrelated agents do not turn a passing channel red.
This static proof exports its own Rust convergence gauges rather than gating on broad legacy
Prometheus aggregates, because older node-agent archive metrics do not yet carry enough proof-role
labels to avoid summing stale divergence from unrelated scrape targets.

`ec-node archive-convergence-measure` is the primary production proof harness. It fetches named
node-agent `/v1/archive-manifest` samples or direct manifest JSONL URLs, writes bounded temporary
manifest roots, reuses the Rust `archive-convergence` report, and optionally queries Prometheus for
the Grafana-facing duplicate/miss series. A production run only counts as complete when the report
has elapsed samples, matching duplicate media hashes, zero divergent hash sequences, and live
Prometheus series for the duplicate/miss gauges. The measurement groups records by archive record
source identity, not by the URL used to fetch a manifest, and reports source identity failures when
the sample is too weak to prove independent publisher data. The older
`scripts/measure-duplicate-publishers.py` stays compatibility-only until live operators and Forge
jobs are switched to the Rust command.
The convergence report carries bounded divergent-sequence samples with per-source hash, byte size,
receive time, source node/session, CAS path, and media timing when present, so a red proof is
immediately actionable without fetching full manifests by hand.
It also reports a non-blocking media-timing-missing count and Prometheus gauge; hash equality can
still prove duplicate bytes, but missing timing means a divergent proof cannot yet classify whether
the mismatch is a phase/windowing problem or an encoder byte problem.
Publisher service builders must pass proof cadence explicitly. Both the node-agent publisher
supervisor and Nix systemd publisher module set `--publisher-archive-segment-duration-ms` and
`--publisher-start-boundary-ms` by default, so netbooted NUCs do not depend on stale hotpatch CLI
defaults when aligning duplicate publisher proof windows.

`ec-node archive-convergence-measure-serve` turns that production proof harness into a live
Prometheus target. Each `/metrics` scrape fetches one fresh sample from node-agent or direct JSONL
manifest URLs, keeps a bounded in-memory sample window, and only reports measurement `ok` after the
configured elapsed window has passed. This avoids blocking Prometheus scrapes for the measurement
duration while still preventing two immediate samples from looking like a real production run.
The service emits measurement-level gauges for fetch success, source record counts, invalid records,
elapsed seconds, Prometheus series presence, reasons, and then appends the same
`every_channel_archive_*` convergence gauges from the latest sample. The service can also read
Prometheus file-SD JSON from Forge's Headscale node-agent discovery and turn each discovered target
into a sampled node-agent manifest source. The Nix
`services.every-channel.ec-node.archive.convergence.remoteProofs` option creates these remote proof
services as systemd units from either static `NAME=URL` endpoints or dynamic file-SD inputs. Forge
now exposes `la-kcop-remote-publisher-origin` on `127.0.0.1:7813` using the live
`/var/lib/prometheus/every-channel-node-agents.json` inventory. It must stay red until that
inventory contains at least two independent publisher node-agents whose `publisher.m4s` records
converge.

When archive-serve ports are not reachable from the proof runner, the node-agent exposes a bounded,
tailnet-authenticated `/v1/archive-manifest` sample endpoint. The harness can use that endpoint for
each named publisher, compare local manifest records directly, and still require at least two elapsed
samples before declaring success.

Production duplicate proof also requires archive-buffer freshness on each participating publisher.
During mixed-generation rollouts, the current node-agent may supervise an older installed
`archive-hot-sync` helper. The agent must probe helper flag support and omit optional arguments such
as `--link-mode` when an older helper lacks them, because a silently failing archive-buffer sync can
leave one publisher with healthy live streams but stale manifests.

The publisher buffer refresh is freshness-first: the node-managed sync must mirror full manifests
without origin object fetch before running the slower cache fill/prune pass. This lets convergence
checks, Grafana scrape surfaces, and demand fetch see current BLAKE3 indexes even when proactive CAS
object backfill is still catching up.

`wt-archive` stamps each archive index record with `source_node` and `source_session`. The Nix
archive launcher passes the runtime hostname as `--source-node`; explicit CLI users can override it.
Older records without this identity continue to parse, but proof commands and production measurement
mark them incomplete instead of accepting them as independent publisher evidence.

Publisher-origin proof must be captured before relay/archive mirroring can collapse source identity.
When node-agent archive buffering is enabled, supervised `wt-publish` processes pass
`--publisher-archive-output-dir`, `--publisher-archive-manifest-dir`, and
`--publisher-archive-source-node`. `wt-publish` now supervises the Rust
`publisher-proof-archive-source` worker for that archive track. The worker splits the MPEG-TS source
by source-clock windows, fresh-encodes each bounded window with the deterministic proof profile,
stores the resulting media fragments under `publisher.m4s` in the same CAS/index format, and stamps
them with node-agent source identity. The relay playback encoder remains continuous for watchability,
but it is no longer the BLAKE3 data identity for duplicate-publisher proof. The source identity is
explicit override first, then hostname plus a short hash of machine-id, with boot-id only as a
fallback; hostname alone is not enough because publisher images can share names like `ec-node`.
Production duplicate verification can therefore compare `publisher.m4s` from two publisher buffers
without treating copied relay-origin manifests as independent sources.

Proof tooling defaults to `publisher.m4s`. The relay video track `0.m4s` is useful playback data,
but it is not duplicate-publisher proof: a publisher buffer may hold relay/cache records on `0.m4s`
that have no publisher source identity. Production convergence checks that sample `0.m4s` should be
treated as playback/archive-cache diagnostics, not byte-for-byte duplicate publisher evidence.

The first live publisher-origin measurements on 2026-06-08 showed correct distinct source labels but
zero matching duplicate sequences for `la-nbc4`, `la-pbs-socal`, and `la-kcet`. The failure is
useful: independent `wt-publish` processes currently start their fragment sequence and encoder chunk
phase at local process start, so sequence `0` from two publishers is not necessarily the same
broadcast moment. Duplicate-publisher proof therefore requires a shared chunk clock or
scheduler-controlled aligned encoder phase before byte-for-byte archive convergence can pass in
production.

Publisher-origin `publisher.m4s` records now require timed fMP4 fragments for global proof and map
those fragments onto observed wall-clock epoch buckets instead of local process counters. The Rust
writer learns track timescales from the init `moov` box, reads fragment
`moof/traf/tfhd+tfdt` decode timestamps to reject untimed proof when possible, then assigns
`group_sequence = observed_epoch_bucket * bucket_stride + fragment_slot`. Fragments that lack usable
timing still fall back to the previous local counter so publishing does not fail hard on malformed
metadata, but duplicate-publisher proof should use timed fragments. The `wt-publish` ffmpeg path
also preserves source timestamps and uses closed-GOP, single-threaded x264 settings with forced
keyframe cadence so independent publishers have a real chance of producing identical bytes for the
same media time window.

A later live run on 2026-06-08 found a stricter local invariant before cross-publisher byte equality:
each publisher must produce at most one hash for a given `source_node` and `group_sequence`.
Production `publisher.m4s` samples for `la-kcop` and `la-ktla` showed multiple hashes from the same
source in the same sequence bucket because real fMP4 fragments can arrive faster than the configured
proof segment duration, and the writer rounded decode time into repeated buckets. The writer now
uses a fixed per-epoch bucket stride and increments an in-bucket fragment slot when multiple timed
fragments arrive inside the same proof duration. This keeps source-local manifests unique while
allowing independently restarted publishers to align on the same observed wall-clock bucket.
`ec-node archive-convergence` reports this separately as `source_local_divergent_sequences` so
operator tooling can distinguish a self-contradicting publisher from two publishers that simply
disagree about the same sequence.
Because bucket-strided proof sequences intentionally leave numeric gaps, archive convergence uses
the observed sparse sequence union for publisher-origin manifests. Dense contiguous sequence ranges
remain available in the simulation layer when a model explicitly expects every integer sequence.

The 2026-06-08 live `la-kcet/publisher.m4s` sample from Forge confirmed that both publishers now
emit distinct source identities (`ec-node-c3546fa5abc3` and `ec-node-72cf1c3aa196`) with no missing
source identity records on the sampled publisher-origin manifests. It also confirmed the remaining
bug: 156 shared publisher-origin sequences had zero byte-for-byte BLAKE3 matches and 156 divergent
hashes. The next production fix must align the publisher chunk clock and encoded fMP4 byte stream,
not merely improve scrape or Grafana plumbing.

After the wall-clock bucket hotpatch, the same live proof no longer has fake sparse-range missing
IDs: `la-kcet/publisher.m4s` reported 376 observed proof sequences, zero missing source identities,
zero source-local divergent sequences, and 234 divergent shared sequences. A byte-level sample for
sequence `7287381184512` had different sizes, different BLAKE3 hashes, different `tfdt`
base-media-decode-times (`210210` versus `0`), and different `mdat` payload prefixes. Across that
sampled window there were zero common fragment hashes even when sequence IDs were ignored, proving
that the remaining failure was independent-encoder media phase and fMP4 payload determinism, not an
archive manifest identity bug.

A later `la-kcop/publisher.m4s` sample exposed a stricter live-source bug: source-window proof
records were using unsynced MPEG-TS PCR chunk indexes as `group_sequence` when the OTA UTC clock was
unavailable, causing restart-dependent jumps such as 93M, 135M, 341M, and 390M. The source-proof
writer now uses the chunk UTC start only when the chopper reports synced timing, otherwise it falls
back to the local wall-clock window start, and rewrites fMP4 `tfdt` onto that shared window before
hashing. The live HTTP proof worker also retries transient source opens/reader failures in unbounded
live mode, so a tuner `503` or malformed TS burst is skipped/retried instead of killing the
publisher proof process.

The synced source-window clock must use the chopper's exact global chunk index, not integer UTC
seconds. A 1001 ms proof cadence makes whole-second UTC start metadata lossy: adjacent source
windows can share the same `utc_start_unix`, which caused one publisher to write several different
hashes under the same source-local `group_sequence`. Synced chunks therefore use
`ChunkTiming.chunk_index` directly; only unsynced chunks fall back to local wall-clock receipt.
The live source-window proof writer also keeps subfragment slot allocation as stream state instead
of per-chunk state. Real source windows can be emitted in more than one proof chunk for the same
media timing sequence; resetting the slot counter for every chunk reused the same
`group_sequence` and made one healthy publisher look self-divergent. The counter is bounded so the
long-running live worker does not grow state unbounded.

`wt-publish` now has an explicit Unix-epoch start boundary, defaulting to the publisher-origin proof
cadence. After relay setup and immediately before spawning ffmpeg it waits until the next boundary,
so a newly restarted duplicate publisher starts its forced-keyframe clock on the same global cadence
as already-running publishers.
This does not by itself prove byte equality; it removes the local-process-start phase error from the
live publisher path and gives rollout measurement a deterministic knob (`--publisher-start-boundary-ms
0` disables it). The live ffmpeg argument plan is factored into a Rust unit-testable helper so
future timestamp/keyframe changes are pinned in `ec-node` instead of being inferred from node-agent
process strings or production samples.

The first post-start-clock live sample still failed duplicate byte identity: both publishers landed
in the same wall-clock proof bucket, but one fragment carried `tfdt=390390` while the other carried
`tfdt=30030`, matching the staggered restart gap. Their `mdat` prefixes differed too, which means a
continuous x264 encoder keeps enough local history that a later restart cannot prove byte equality
merely by joining the same wall-clock cadence. The live profile therefore enables x264
`stitchable=1` alongside closed GOP, no scenecut, no B-frames, no lookahead, and one thread. If that
still does not converge in production, the next fix is a deliberately stateless per-fragment encode
or a Rust-owned media clock/segmenter that resets encoder history at each proof boundary.

The follow-up production hotpatch moved the start-boundary wait to immediately before ffmpeg spawn,
enabled `stitchable=1`, and restarted both publisher nodes in the same batch. The latest `la-kcet`
sample still reported zero matching duplicate hashes with no missing source identity and no
source-local divergence. A final sampled shared sequence differed by hundreds of milliseconds of
receive time and by media size (`439737` versus `270283` bytes for the video fragment), so the
remaining mismatch is not just MP4 timestamp metadata. Production duplicate proof now needs a
stateless fragment boundary: either encode each proof segment from the same bounded source window
with fresh encoder state, or make the Rust media pipeline own exact frame-window capture before
calling ffmpeg/x264.

Archive manifests now carry optional fMP4 media timing for publisher-origin fragments. The
`archive-convergence` gate treats equal archive group sequence IDs with different media sequence or
decode-time metadata as `media_sequence_conflict`, even if the byte hash happens to match. This keeps
production proof aligned with the Rust simulation model: a duplicate publisher only proves the same
broadcast moment when the archive sequence and media window agree.

The first stateless proof primitives are now in `ec-node`. `publisher-proof-segment` takes one
bounded MPEG-TS source-clock window, runs a fresh deterministic x264/AAC fMP4 encode, splits the
result into init bytes and media fragments, and emits BLAKE3 hashes for each. `publisher-proof-windows`
uses the Rust MPEG-TS source-clock splitter first, then fresh-encodes each bounded window and reports
per-window source TS, init, and media hashes. Proof windows carry explicit MPEG-TS decoder context
with `--preroll-packets`, defaulting to the repo-owned `WT_PUBLISH_PROOF_PREROLL_PACKETS` budget, so
mid-GOP windows do not silently depend on best-effort decoder recovery. Focused Rust tests
fresh-encode the same bounded input and the same finite source-window campaign twice and assert
byte-for-byte identical proof hashes.

`publisher-proof-duplicates` is the single-node duplicate-publisher gate for the stateless path. It
runs `publisher-proof-windows` independently under at least two publisher identity labels, defaults
to `publisher-a` and `publisher-b`, and compares source TS, init, and media fragment BLAKE3 hashes
for every source-clock window. `--require-ok` exits non-zero unless every compared window matches,
and duplicate publisher labels are rejected so the proof cannot accidentally collapse to one source
identity. `publisher-proof-compare` is the cross-machine stateless proof gate: each publisher can run
`publisher-proof-windows` against the same bounded source TS file locally, copy the JSON report back
to the operator host, and compare the reports by named publisher. It rejects mismatched chunk cadence,
missing windows, source TS hash mismatches, init hash mismatches, media fragment hash mismatches, and
empty media windows.

`publisher-proof-remote-compare` is the production operator harness for that cross-machine gate. It
copies one bounded `.ts` proof input to each named SSH target, runs `ec-node publisher-proof-windows`
on the target, stores each returned JSON report under the local output directory, writes a
`compare.json`, and returns the existing compare report with upload/proof timing. Remote labels use
the same single-component validation as publisher identities, remote proof roots are constrained to
`/tmp/every-channel-*`, and cleanup is opt-in so the generated proof files remain inspectable unless
the operator explicitly requests removal. This keeps the live proof path in Rust without making the
Python node-agent a new oracle. It proves the machine/runtime/compiler boundary without requiring
the two NUCs to share a live tuner at the exact same instant.

`publisher-proof-archive-source` is the live archive implementation of the same proof model. It can
read local source files directly, read plain HTTP MPEG-TS bodies directly for HDHomeRun-style
sources, or fall back to an ffmpeg MPEG-TS copy reader for other inputs. Each emitted source-clock
window is encoded with fresh proof state, archived as CAS-backed `publisher.m4s` records, and mapped
to source-clock group sequences with explicit media timing metadata. A focused Rust regression now
archives the same bounded TS input as two source nodes, then runs `archive-convergence` against the
two manifest roots and requires full duplicate convergence with zero divergent or source-local
divergent sequences.

Forge `ci-gates` now runs the `publisher_proof` and `archive_convergence` Rust filters before the
distributed simulator campaign, so single-node byte-for-byte determinism, source-window archive
proof semantics, and duplicate archive convergence are checked before production rollout evidence is
considered. The next production step is to deploy the updated node binary and let fresh
`publisher.m4s` source-window records age into the Grafana scrape window so live duplicate metrics
can replace the older continuous-encoder divergence.
