Skip to content

[EPIC] ADR-013 Hop-B sink leg: frame-native FAO delivery (shards + sidecar + run manifest + gates) #105

Description

@Polichinel

Problem

The Hop-B half of ADR-013 does not exist. Today's outbound _save (unfao/managers/unfao.py:283-329) uploads a pandas point-estimate parquet under the ensemble's document name — the confirmed F1 invisibility: faoapi's unconditional name filter has never been able to see a forecast uploaded this way. Meanwhile everything upstream is built: pipeline-core's Hop-A publish leg ships Track-A archives, vpp's track_a_source.py verifies and assembles them, the §6 draws gate is merged (but wired nowhere), and the §10 golden fixture pins every byte the sink must produce.

Why now

The wire exists end-to-end in code from PFE to the internal store. This epic is the missing middle. The C-161 constraint (faoapi's Hop-B guard must reach production before any contract upload) orders the finish, not the building: the sink ships upload-disabled by default (ADR-013 §11.4 interlock) and cannot touch the live bucket until explicitly enabled after C-161 closes.

End state

A composed, fixture-proven Hop-B sink: contract-mode inbound (Hop-A manifest selection, await-all-configured-targets, verified assembly) → §6 no-collapse gate → per-(target,month) arrow shards → §5 sidecar (+ gid parity) → ONE run manifest uploaded LAST → all documents under name="un_fao" with §4.1a fields — with the e2e proof that fixture Track-A artifacts in produce byte-identical fixture Hop-B artifacts out. UPLOAD_ENABLED=False throughout the epic.

Layout (maintainer-approved 2026-07-20)

New subpackage views_postprocessing/unfao/wire/ (naming, header, shard, sidecar, run_manifest, source_selection, sink) + unfao/product.py (the declared FAO product: targets, S_MIN, consumer document name, upload interlock) + delivery/parity.py (gid-set parity invariant, pandas-free). Manager gains a thin declared contract-mode branch only. Principles: wire/ = one closure (CCP/CRP, screams the contract); product separate (SRP — changes with the partner product); delivery/ ← seams ← wire/ ← manager (SDP/ADP); sink composed with an injected store port (DIP).

In scope

Stories S1–S8 (linked below): product+naming, header, shard writer, sidecar+parity, run manifest, source selection, sink+wiring+e2e, closeout.

Out of scope

Legacy-path deletion (later story); S7/#92 historical inbound; partner generalization (#97 — WET stands); repo rename (#96 / D-12); track_a_source.py relocation into wire/ (future mechanical move); live upload enablement (gated on C-161).

Epic acceptance criteria

Supersedes #91 (S6 outbound) and #88 (S3 convert-at-the-door); resolves #45. Contract: docs/ADRs/013_sampled_forecast_wire_contract.md.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    epicA large capability spanning multiple storiesviews-frames-adoptionAdopt the views-frames v1.0.0 data-contract

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions