From f77cfe390f13dd1e3335cf4fa0be08ddf9a0d16d Mon Sep 17 00:00:00 2001 From: Polichinl Date: Mon, 27 Jul 2026 20:57:33 +0200 Subject: [PATCH] =?UTF-8?q?fix(delivery):=20run-0=20verification=20finding?= =?UTF-8?q?s=20=E2=80=94=20255-safe=20provenance=20descriptions=20+=20loud?= =?UTF-8?q?=20upload=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run-0's historical upload stranded an orphan: the file uploaded but Appwrite rejected its metadata document (description attribute caps at 255 chars; the prose-prefixed C-15 provenance exceeded it), and the store module's graceful degradation only logged the error — the port's ledger then falsely claimed success. Two fixes: provenance.compact_description (delivery/, pipeline-core- free) emits compact JSON provenance guaranteed <=255 with fail-loud refusal to truncate, used by both historical description paths; _ContractStorePort.upload now inspects the OperationResult and RAISES on success=False — upstream graceful degradation must never silently strand our artifacts. Refs #131 (run-0 delivery verification). Co-Authored-By: Claude Fable 5 --- tests/test_selection_guard.py | 19 +++++++++++++ views_postprocessing/delivery/provenance.py | 28 ++++++++++++++++++++ views_postprocessing/unfao/managers/unfao.py | 27 ++++++++++--------- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/tests/test_selection_guard.py b/tests/test_selection_guard.py index 05da56a..bf3f0ed 100644 --- a/tests/test_selection_guard.py +++ b/tests/test_selection_guard.py @@ -39,3 +39,22 @@ def test_guard_type_is_disjoint_from_contract_vocabulary(): "sampled_forecast_sidecar", ): assert f'"type": "{contract_type}"' not in _SRC + + +def test_compact_description_fits_the_store_limit(): + """Run-0 lesson (2026-07-27): Appwrite's description attribute caps at 255 + chars; the old prose-prefixed provenance exceeded it and stranded the + historical upload as an orphan file without a metadata document.""" + from views_postprocessing.delivery import provenance + from views_postprocessing.delivery.provenance import DESCRIPTION_MAX, compact_description + + prov = provenance.build_provenance( + lookup_version="land_gaul@272cdb01", + region="land_gaul", + expected_cell_count=64742, + actual_cell_count=64742, + unmapped_count=0, + ) + text = compact_description(prov) + assert len(text) <= DESCRIPTION_MAX + assert "land_gaul@272cdb01" in text # provenance survives, prose does not diff --git a/views_postprocessing/delivery/provenance.py b/views_postprocessing/delivery/provenance.py index 1baa785..35adab8 100644 --- a/views_postprocessing/delivery/provenance.py +++ b/views_postprocessing/delivery/provenance.py @@ -52,3 +52,31 @@ def build_provenance( if fill_count is not None: provenance["fill_count"] = fill_count return provenance + + +DESCRIPTION_MAX = 255 # Appwrite metadata attribute limit (run-0 lesson, 2026-07-27) + + +def compact_description(prov: dict) -> str: + """The provenance dict as compact JSON, guaranteed to fit the store's + 255-char description attribute — the only structured carrier the upload + exposes (C-15). The decorative prose prefix died here: it cost the run-0 + historical document (metadata rejected; file stranded as an invisible + orphan). Fails loud if even compact JSON cannot fit — never truncates + silently.""" + import json + + text = json.dumps(prov, separators=(",", ":")) + if len(text) > DESCRIPTION_MAX: + essential = { + k: prov[k] + for k in ("lookup_version", "region", "expected_cell_count", "actual_cell_count", "unmapped_count") + if k in prov + } + text = json.dumps(essential, separators=(",", ":")) + if len(text) > DESCRIPTION_MAX: + raise ValueError( + f"provenance description cannot fit the store's {DESCRIPTION_MAX}-char " + f"limit even compacted ({len(text)} chars) — refusing to truncate silently." + ) + return text diff --git a/views_postprocessing/unfao/managers/unfao.py b/views_postprocessing/unfao/managers/unfao.py index 92326ad..8d3ef80 100644 --- a/views_postprocessing/unfao/managers/unfao.py +++ b/views_postprocessing/unfao/managers/unfao.py @@ -13,7 +13,6 @@ from views_pipeline_core.managers.ensemble import EnsemblePathManager import pandas as pd import io -import json from datetime import datetime import os from dotenv import load_dotenv @@ -55,7 +54,7 @@ def download(self, file_id: str) -> bytes: ) def upload(self, file_path, *, filename, name, doc_type, category, loa, targets, description=None) -> None: - self._dsm.upload_data( + result = self._dsm.upload_data( file=file_path, filename=filename, name=name, @@ -65,6 +64,18 @@ def upload(self, file_path, *, filename, name, doc_type, category, loa, targets, targets=targets, description=description, ) + # The store module degrades gracefully (its ADR-046 policy) and only LOGS + # metadata failures — which strands an invisible orphan file (run-0 + # historical, 2026-07-27). The delivery fails loud instead. + success = getattr(result, "success", None) + if success is None and hasattr(result, "to_dict"): + success = result.to_dict().get("success") + if success is False: + error = getattr(result, "error", None) or "unknown store error" + raise RuntimeError( + f"upload of {filename!r} did not fully succeed (file may be an " + f"orphan without a metadata document): {error}" + ) class UNFAOPostProcessorManager(PostprocessorManager, ForecastingModelManager): @@ -582,11 +593,7 @@ def _historical_frame_description(self, table, timestamp: str) -> str: actual_cell_count=len(frame_extraction.cells_of(self._historical_frame)), unmapped_count=historical.unmapped_cell_count(table), ) - return ( - f"Enriched with geographic metadata on {timestamp} using precomputed GAUL " - f"lookup (ADR-011, version={self._enricher.lookup_version}). " - f"provenance={json.dumps(prov, separators=(',', ':'))}" - ) + return provenance.compact_description(prov) def _build_historical_artifact(self, directory) -> tuple: """Frame-built historical artifact staged into ``directory``; returns @@ -619,8 +626,4 @@ def _delivery_description(self, df: pd.DataFrame, timestamp: str) -> str: actual_cell_count=len(extraction.cells_of(df)), unmapped_count=extraction.unmapped_cell_count(df, METADATA_COLS), ) - return ( - f"Enriched with geographic metadata on {timestamp} using precomputed GAUL " - f"lookup (ADR-011, version={self._enricher.lookup_version}). " - f"provenance={json.dumps(prov, separators=(',', ':'))}" - ) + return provenance.compact_description(prov)