Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Changelog

What changed **for a consumer** — the launcher that pins this package, and the partner
that receives its artifacts. Not a commit log: entries here name behaviour someone
outside this repository can observe, in particular **behaviour that can turn a
previously-passing run into a failing one**.

This file exists because the version number was the only signal a consumer got
(register C-111). Releases before 1.2.0 are summarised from their tags rather than
reconstructed in detail.

## 1.2.0 — 2026-08-26

**A previously-passing delivery can now fail in three new ways. All three are
deliberate, and each replaces a silent failure with a loud one.**

### New failure modes that escape into the launcher

- **`views_postprocessing.delivery.findability.DeliveryNotFindableError`** — after
upload, the delivery now asks the store the same question the consumer asks: *is
the newest document under the consumer's name the one this run just uploaded?* If
it is not, the run raises. **Previously a delivery whose artifacts landed somewhere
the consumer could not see reported success.** (C-94)

- **`…findability.FindabilityUnverifiedError`** — raised when the check itself could
not run, e.g. the store errored on the read-back. Deliberately distinct from
`DeliveryNotFindableError`: "could not ask" is not "asked and got nothing".

- **`views_postprocessing.contract.source_metadata.ProducerClientUnavailable`** —
reading the producer's `last_valid_month_id` now raises if `datafactory_query`
cannot be loaded, whether it is absent or raises on import. **Previously a broken
environment degraded open and shipped unobserved months as observed history.**
A producer that publishes no boundary is still handled as before — that is a normal
older store, and a different condition. (C-103)

- **`views_postprocessing.contract.wire.sink.TornRunError`** — a failure partway
through uploading a run now raises a refusal naming the run, every object confirmed
uploaded, and the object that failed. It does **not** delete anything; a torn run
still leaves orphans, but it no longer leaves them undocumented. Note this is a
`RuntimeError`, not a `SinkError` — `SinkError` means do-not-retry, and a torn run
may be retried. (C-105)

### Changed data reaching the partner

- **Delivered artifacts carry a new provenance field, `observed_through`.** It records
the producer boundary the observed-range clip used, or `null` when the boundary
could not be read and the clip was therefore **skipped** — meaning unobserved months
may be present. Carried in the file's `description` metadata, which is a JSON string;
consumers that treat that field as opaque text are unaffected. (#297)

### Changed internals a direct caller would notice

- `unfao.store_port` / `crafd.store_port` `upload()` now **returns the uploaded file
id** instead of discarding it. Required by the findability read-back above.
- `delivery.provenance.build_provenance()` gained a **required** keyword argument,
`observed_through`. Required rather than optional because a caller that omits it is
the failure being fixed.

### Documentation

- ADR-013 gains **§5.1a**, recording that nullable int64 GAUL code columns were
considered and rejected, with the measured pandas round-trip behaviour that decided
it. No `contract_version` change: the wire bytes are untouched. (#278)

### Upgrading

Nothing to change in a launcher. The three new exceptions surface conditions that were
already wrong and already silent — if one fires after upgrading, it is reporting a
pre-existing problem, not creating a new one.

## 1.1.1 — 2026-08-15

Fixes C-99. Consumers on 1.1.0 should move; see views-models#403.

## 1.1.0 — 2026-08-13

Adds the CRAF'd producer package alongside UN-FAO, and the observed-range clip that
drops months above the producer's declared boundary.

## 1.0.0 — 2026-08-01

First released version.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "views-postprocessing"
version = "1.1.1"
version = "1.2.0"
description = ""
authors = [
"Dylan Pinheiro <dylpin@prio.org>",
Expand Down
73 changes: 47 additions & 26 deletions reports/technical_risk_register.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
|-------------------|--------------------------------------|
| Project | views-postprocessing |
| Owner | Dylan Pinheiro / PRIO MD&D Team |
| Last Updated | 2026-08-21 |
| Last Updated | 2026-08-26 |
| Total Concerns | 112 |
| Open Concerns | 32 |
| Resolved Concerns | 80 |
| Open Concerns | 31 |
| Resolved Concerns | 81 |

---

Expand Down Expand Up @@ -1318,29 +1318,6 @@ Cross-refs: **C-84** (the expiry and the tripwire), **C-86** (no way past a red

---

### C-111: A release can change whether a delivery fails, and the version number is the only thing that says so

| Field | Value |
|-------|-------|
| ID | C-111 |
| Tier | 3 — nothing is silent and nothing corrupts; the cost lands on a consumer who takes a version that changes their pipeline's outcome with no notice, and on whoever then diagnoses it across two repositories. |
| Source | `/falsify` release-readiness audit, 2026-08-21 (discovered during execution, not predicted) |
| Trigger | The next version is cut — write what changed for a consumer, or record why the number alone is enough. |
| Owner | This repository. |
| Location | The repository root — there is no `CHANGELOG.md`, no `docs/operations/release_notes.md`, and no release notes on the existing tags. |

The delta since tag `1.1.1` adds three exception types that can escape into a launcher: `delivery.findability.DeliveryNotFindableError` and `FindabilityUnverifiedError` (C-94), and `contract.source_metadata.ProducerClientUnavailable` (C-103). The first is the sharp one — **a delivery whose artifacts land somewhere the consumer cannot see previously succeeded silently and now raises.**

That is the intended behaviour and the entire point of C-94. It is still a change a consumer must be told about, and the only signal views-models receives is a MINOR version bump in `VIEWS_POSTPROCESSING_PIN`. Nothing in this repository states that a previously-passing run can now fail.

**The asymmetry is the finding.** This repository is unusually careful about telling *contributors* things — a register, ADRs, CICs, guards that refuse with paragraph-long explanations. It tells *consumers* nothing but an integer. views-models#403 is the shape of the consequence in the other direction: launchers sat on `1.1.0` for eight days after `1.1.1` fixed a defect that had already killed a delivery, because nothing made the difference legible.

**Deliberately not proposed: a full changelog discipline.** What is needed is a line per release naming behaviour a consumer can observe. Whether that lives in `CHANGELOG.md`, in the GitHub release body, or in the pre-release notes FAO already receives is a choice, not a requirement.

Cross-refs: **C-94** and **C-103** (the new failure modes), **C-112** (the inbound half — nothing checks whether a consumer took the release either; the two compound), **views-models#403** (the same gap costing eight days in the other direction), **C-24** (a consumer-facing contract divergence nobody surfaced).

---

### C-112: Nothing here can see what production actually runs, and it ran a defect we had already fixed for eight days

| Field | Value |
Expand Down Expand Up @@ -3429,6 +3406,50 @@ Cross-refs: **views-faoapi C-161** (the deploy constraint §0.2a now surfaces

---

### C-111: A release can change whether a delivery fails, and the version number is the only thing that says so

| Field | Value |
|-------|-------|
| ID | C-111 |
| Tier | 3 — nothing is silent and nothing corrupts; the cost lands on a consumer who takes a version that changes their pipeline's outcome with no notice, and on whoever then diagnoses it across two repositories. |
| Source | `/falsify` release-readiness audit, 2026-08-21 (discovered during execution, not predicted) |
| Trigger | The next version is cut — write what changed for a consumer, or record why the number alone is enough. |
| Owner | This repository. |
| Location | The repository root — there is no `CHANGELOG.md`, no `docs/operations/release_notes.md`, and no release notes on the existing tags. |

The delta since tag `1.1.1` adds three exception types that can escape into a launcher: `delivery.findability.DeliveryNotFindableError` and `FindabilityUnverifiedError` (C-94), and `contract.source_metadata.ProducerClientUnavailable` (C-103). The first is the sharp one — **a delivery whose artifacts land somewhere the consumer cannot see previously succeeded silently and now raises.**

That is the intended behaviour and the entire point of C-94. It is still a change a consumer must be told about, and the only signal views-models receives is a MINOR version bump in `VIEWS_POSTPROCESSING_PIN`. Nothing in this repository states that a previously-passing run can now fail.

**The asymmetry is the finding.** This repository is unusually careful about telling *contributors* things — a register, ADRs, CICs, guards that refuse with paragraph-long explanations. It tells *consumers* nothing but an integer. views-models#403 is the shape of the consequence in the other direction: launchers sat on `1.1.0` for eight days after `1.1.1` fixed a defect that had already killed a delivery, because nothing made the difference legible.

**Deliberately not proposed: a full changelog discipline.** What is needed is a line per release naming behaviour a consumer can observe. Whether that lives in `CHANGELOG.md`, in the GitHub release body, or in the pre-release notes FAO already receives is a choice, not a requirement.

Cross-refs: **C-94** and **C-103** (the new failure modes), **C-112** (the inbound half — nothing checks whether a consumer took the release either; the two compound), **views-models#403** (the same gap costing eight days in the other direction), **C-24** (a consumer-facing contract divergence nobody surfaced).

**✅ RESOLVED 2026-08-26, at the release its trigger named.** The trigger read *"the
next version is cut — write what changed for a consumer, or record why the number alone
is enough."* 1.2.0 is that release, and `CHANGELOG.md` now exists at the repository root
with an entry naming each of the three escaping exception types, the condition each one
replaces, and the new `observed_through` provenance field — under the heading the entry
asked for: **what changed for a consumer**, with the failure modes stated first.

The venue was left open by the entry deliberately, and the choice made here is the root
`CHANGELOG.md` rather than the GitHub release body, so that a consumer reading the source
tree at a pinned version can see it without leaving the checkout. The release body quotes it.

Two falsification probes asserted this gap and both are retired by this change — S2 from
the 2026-08-21 audit and H1 from the 2026-08-25 one. **They are the same finding, located
twice**, because the second audit designed its probes without reading the first's stubs.
That is a lesson about the audit procedure rather than about this entry, and it is recorded
in `tests/test_falsification_release_readiness.py` where the next auditor will meet it.

**What this does not close: C-112**, the inbound half — nothing here can still see what a
consumer actually runs. A changelog tells views-models what changed; it does not tell this
repository whether views-models read it.

---

## Resolved Disagreements

### D-07: Historical data route — keep pipeline-core dispatcher vs call datafactory directly — RESOLVED
Expand Down
95 changes: 64 additions & 31 deletions tests/test_falsification_release_readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,36 +52,69 @@ def test_the_release_path_survives_its_own_expiry_tripwire():
)


@pytest.mark.xfail(reason="S2: unaddressed falsification — see the audit report", strict=True)
def test_a_release_announces_delivery_failure_modes_it_adds():
"""S2 (soft). 1.2.0 can fail a delivery that 1.1.1 completed, and nothing says so.

The shipped delta since tag 1.1.1 adds three exception types that can escape into a
launcher: `DeliveryNotFindableError` and `FindabilityUnverifiedError` (C-94) and
`ProducerClientUnavailable` (C-103). The first is the sharp one — a delivery whose
artifacts land somewhere the consumer cannot see previously **succeeded silently**
and now raises.

That is the intended behaviour and the whole point of C-94. It is still a change a
consumer must be told about, and the only signal they get is a MINOR version bump.
This repository has no CHANGELOG, so views-models' launchers would take 1.2.0 with
no notice that a previously-passing run can now fail.

Fix: a CHANGELOG naming the new failure modes, or release notes on the tag. Either
satisfies this; the assertion below is deliberately loose about which.
# ─────────────────────────────────────────────────────────────────────────────
# Second audit, 2026-08-25. Claim: *"we are ready to bump the version, set up a
# PR to main, review, merge when review is good, then tag and publish."*
#
# Verdict: FALSIFIED — one hard, two soft.
#
# **H1 DISCHARGED 2026-08-26** (release 1.2.0). Its probe asserted that a release
# names what changed about failing for a consumer. `CHANGELOG.md` now exists and
# 1.2.0's entry names the three escaping exception types and the new provenance
# field, so the probe would XPASS and `strict=True` would turn that into a failure.
# Removed by hand rather than left to flip, per the S4 precedent (#200). Register
# C-111 is closed by the same change.
#
# **S2 DISCHARGED 2026-08-26** by the same change — and it is the same finding.
# S2 (2026-08-21) and H1 (2026-08-25) are one concern found twice by two audits,
# which is itself worth recording: the second audit did not read the first's stubs
# before designing probes. Both are C-111; both are closed by CHANGELOG.md.
#
# S1, S3 and S4 remain open and are below. The bump SIZE (minor,
# not patch) is recorded as an observation, not a falsification: nothing in the
# repo is wrong about it, it is a way the releaser could be.
# ─────────────────────────────────────────────────────────────────────────────


@pytest.mark.xfail(reason="S3: unaddressed falsification — see the audit report", strict=True)
def test_tagging_actually_publishes():
"""S3 (soft). "Tag and publish" is not the mechanism this repo has.

`.github/workflows/publish_package.yml` triggers on `release: published` and
`workflow_dispatch` — **not** on tag push. Pushing a tag runs nothing.

The evidence that this is a live trap rather than a technicality: tags `1.0.0`
and `1.1.0` both exist and **neither has a GitHub Release**. Only `1.1.1` does,
which is the only version this workflow has ever published.

Fails until the workflow triggers on tag push, or until a release runbook states
that cutting a GitHub Release — not tagging — is the publishing step.
"""
candidates = [
_REPO / "CHANGELOG.md",
_REPO / "docs" / "CHANGELOG.md",
_REPO / "docs" / "operations" / "release_notes.md",
]
present = [p for p in candidates if p.exists()]
assert present, (
"no changelog or release-notes file exists, so a consumer's only signal that "
"1.2.0 can fail a delivery 1.1.1 completed is the version number itself"
wf = (_REPO / ".github/workflows/publish_package.yml").read_text()
assert "tags:" in wf or "push:" in wf, (
"publish triggers only on `release: published`; a plan that says 'tag, then "
"publish' will tag and stop, and nothing will say so"
)


@pytest.mark.xfail(reason="S4: unaddressed falsification — see the audit report", strict=True)
def test_the_publish_job_cannot_ship_untested_code():
"""S4 (soft). The publish job runs no tests and declares no dependency.

`publish_package.yml` has no `needs:`, no pytest step, and one gate: that the
version in `pyproject.toml` parses higher than the newest on PyPI. So a GitHub
Release cut from any commit — a branch, a stale `main`, a commit whose `test`
job failed — builds and uploads to PyPI unconditionally.

Nothing has gone wrong yet because releases have been cut from a green `main`
by hand. The guard is the habit, not the workflow, and habits are what C-86
already showed this repo cannot rely on when one person holds them.

Fails until the publish job depends on a passing test run, or refuses a ref
whose checks are not green.
"""
wf = (_REPO / ".github/workflows/publish_package.yml").read_text()
assert "needs:" in wf or "pytest" in wf, (
"publish validates only version-greater-than-PyPI; nothing establishes that "
"the code being shipped passes its own suite"
)
text = "\n".join(p.read_text() for p in present)
for failure_mode in ("DeliveryNotFindableError", "ProducerClientUnavailable"):
assert failure_mode in text, (
f"{failure_mode} can escape into a launcher and is not announced anywhere"
)
Loading