ADR: Historian ingest path split (#1017 L1)

Status: Accepted for L1 baseline (docs + metrics contract). L2/L3 implement crash durability and scheduled hisRead.
Issue: #1017
Related: #781 protocol split · #997 Haystack RDF · ADR protocol connector process split

Decision

Open-FDD keeps one canonical historian (Parquet under OPENFDD_STORAGE_URL + DataFusion). Two edge collector families feed it; they must not share process/socket ownership:

BACnet / Modbus OT
        │
        ▼
openfdd-bacnet-modbus  ──MQTTS──►  openfdd-mqtt  ──►  openfdd-central ingest
                                                         │
Haystack Project HTTP                                    │  shared validation,
openfdd-haystack  ──HTTPS hisRead / catalog──►           │  identity, Parquet writer
        (MQTT optional; not required for history sync)   ▼
                                              immutable Parquet parts
Path Owns Must not own
OT MQTTS BACnet/IP UDP, Modbus/TCP, poll → MQTTS publish Haystack HTTP credentials, hisRead schedulers
Haystack history Outbound authenticated Haystack HTTP (about, catalog/read, bounded hisRead) BACnet UDP, Modbus sockets, broker requirement for history import
Central Envelope validation, tenant/building identity, receipt journal, sole Parquet writer Edge protocol sockets

CSV/ZIP package import remains a third operator path into the same historian. It does not replace either live path.

Durability metrics baseline (measure before claiming)

L1 records the metric names operators and L2 tips must emit. Numbers are Soft-OPEN until measured on a tip.

Metric Meaning Fail-closed signal
ingest_receipts_pending Envelopes accepted but not committed Growth without commit → spool retention pressure
ingest_receipts_committed HTTP 200 path; persisted_rows > 0 Zero-row “success” is not durable
ingest_receipts_terminal_zero Empty eligible payload; not retriable as data loss Distinct from reject
ingest_receipts_rejected / conflict Auth, schema, digest mismatch, duplicate message id Quarantine; do not retry blindly
ingest_replay_count Exact replay of a committed envelope Must stay at documented acceptance count
historian_flush_rows / historian_flush_latency_ms Micro-batch publish Bound memory; do not claim crash durability from graceful shutdown alone
historian_watermark_lag_s Watermark vs newest committed part Stale analytics stale: true honesty
hisread_checkpoint_ts (L3) Last successful Haystack history cursor per site/equip Missing checkpoint → no “caught up” claim

Graceful shutdown flush is necessary but not sufficient for crash durability. L2 must prove staging → atomic publish → receipt journal compaction across kill -9 / process restart.

Seven-day hot tier

Do not introduce a second time-series database in L1–L2. L3 may benchmark seven-day hot access on the existing Parquet layout and write numbers; choosing another store requires that writeup, not a predetermined dependency.

Non-goals (this ADR)

  • Closing #1070 (gate39 MQTTS prove) — separate Soft-OPEN wall-clock.
  • Rewriting Haystack RDF identity (#997 / C2+ semantic_meta) — reuse, do not fork.
  • FQ / OPS pin from docs alone.
  • Sacred cookbook rewrites.

Exit for L1 tip

  • This ADR + historian.md pointer + architecture index link
  • L2 implements crash/replay + metric emission on the shared ingest path
  • L3 ships standalone scheduled hisRead + 7-day hot-tier benchmark writeup → may close #1017