fix(delivery): verify every uploaded artefact by name, not two legs by category (#312) - #314
Conversation
…y category (#312) The first live UN-FAO delivery, 2026-09-29, uploaded 109 of 110 objects and reported success. views-faoapi refused it. The missing object was the §5 GAUL sidecar: its bytes matched the previous run's, the content-addressed store correctly declined a second copy, update_document ran against the OLD file, and _ContractStorePort.upload returned a real file id for the wrong document. The consumer resolves by filename, found nothing, refused. The C-94 guard did not catch it, and could not have. It checked two entries, {"forecast": manifest_id, "historical": hist_id}, via latest_file_id({"name": ..., "category": category}). Every wire object is uploaded with category="forecast" (sink.py:227), so the newest forecast document is always the manifest, uploaded last by design. Adding the sidecar to that dict would have resolved the manifest and passed. The lookup key was wrong, not the dict. So the guard now asks two different questions, because neither implies the other: legs — does the consumer's SELECTION land on this run? (unchanged) objects — does each artefact resolve by its own filename AT ALL? (new) Per object rather than by count, and the decisive reason is not the one I started with. Pooling upstream is deterministic — measured in views-models on 2026-09-29, 25 of 25 anchor cells byte-identical across an accidental re-pool — and naming.py embeds the run id in every filename. A re-run therefore writes NEW names over IDENTICAL bytes, so the dedup path that took the sidecar takes all 110 objects at once: the store creates the documents, the count is right, nothing is servable. A count-plus-spot-check passes that. Re-running is what C-105 and C-22 tell an operator to do after a torn run, which makes it the realistic case rather than the exotic one — our own remedy is the trigger for the worst version. The query loop moved OUT of both partner managers into delivery/findability.py. Not incidental: the partner packages are under a ratcheting line budget whose own comment says the response to it binding is to move code out of the package rather than raise the number, and crafd/ sat at 699 of 700. Delegating paid for the wider call site exactly — both partners are unchanged at 690 and 699. It also leaves one copy of the rule instead of two that can disagree (the C-75 shape). An unqueryable filename degrades to UNVERIFIED, never to "invisible". filename is a declared collection attribute (pipeline-core provisioning.py:101), but if a store cannot be queried on it the honest answer is "could not ask" — a guard that manufactured an outage on every delivery would be deleted within a week (ADR-014 §3). Five mutations, each caught: report only the first missing object; accept any id as evidence; pass only the two legs (the shipped bug); stop exporting the sink's ledger; swallow a failed query as not-found. Complementary to views-pipeline-core's separate fix for the upload that reports success having written nothing. Neither waits on the other: theirs stops the lie at the source, this one stops us believing a lie from any source. Noted, not acted on: this is the first bug requiring an identical hand-patch in both partner managers, which is one of the two triggers C-33 names for extracting the partner seam. Recorded on #312; extracting it here would be a different change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ANY1CCy9Xo7zjMY4XJ69v9
|
Non-hosting review from One finding worth acting on before merge, on the soft edge you asked me to attack. The seam I think is right. The change is otherwise good and I'd merge it with the finding addressed. FINDING —
|
…lookup (#312 review) Review finding from the views-models seat, verified here before acting. The per-object check queried `filename` directly. Three facts say it would not work: 1. `Query.equal("filename", ...)` appears NOWHERE on the platform. The hits in pipeline-core, faoapi and crafdapi are `Query.equal("name", filename)` against the storage BUCKET — a different call on a different object. 2. `filename` is declared in pipeline-core's collection schema (provisioning.py:101) and **no index is declared anywhere in that file**. 3. views-faoapi — the consumer being modelled — deliberately does not query it. `resolve_artifact_file_ids` (prediction/manager.py:277-295) runs a type-scoped query on {category, type} and matches `filename` in Python. Its docstring says so, and that shape ran against the real store during the 2026-09-29 incident. Left as written, the guard would return UNVERIFIED on every delivery forever while its presence read as coverage — a guard that structurally cannot fire (views-models C-155), which is worse than the bug it replaces. The failure direction was right and the query was wrong. So the check now uses faoapi's shape: scope to {name, category, type}, match filename in Python. Better on its own merits, index question aside — it asks the question the consumer actually asks, which is C-94's thesis rather than an approximation, and it costs one query per artefact TYPE rather than one per object (110 -> 3). Carrying it needed the sink's ledger to record doc_type, and the port to expose `documents()` alongside `latest_file_id`. Both partner packages came in UNDER where they started: unfao 690 -> 688, crafd 699 -> 697. The guard's docstring and success log moved into `findability.verify` with the rule they describe — one home per fact, and the budget's own instruction is to move code out of the package rather than raise the number. Moving `store_port.py` out was considered and rejected: it is byte-identical in both partners and would free 111 lines each, but it is referenced by six register entries, an anti-drift test and ADR-015, and that blast radius does not belong inside an incident fix. Six mutations, each caught: revert to a filename query; accept any id as evidence; report only the first missing object; pass no objects (the shipped bug); swallow a failed query as not-found; stop exporting the ledger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ANY1CCy9Xo7zjMY4XJ69v9
|
Re-reviewed. Approve. The finding is addressed and the change is better than the one I suggested. Verified rather than accepted
You were right that it is better on its merits, not just a workaround"It asks the question the consumer literally asks, which is C-94's thesis rather than an approximation of it" — that is the argument I should have made and did not. I proposed it as a way around an index dependency; you took it because it is the correct question. 110 lookups collapsing to 3 is a consequence, not the reason, and framing it that way in the code is right. One thing I checked and withdrawI was going to raise that the manifest might not be covered by On the budget, which you volunteeredBoth packages came in under where they started (690→688, 697→695 equivalent) and you rejected moving What this PR does not close, for the recordIt makes a silent non-write visible at the postprocessor. It does not stop the non-write — that is views-pipeline-core#551, and the two are complementary as you said. And neither closes #313, which is the only one of the three that would remove the failure class rather than detect it. Nothing further from me. Good change, and the review genuinely improved it in both directions. 🤖 Generated with Claude Code |
…oved Three findings from /review-diff on this branch, two of them self-inflicted by the rework that answered the review. 1. WARNING — findability.py said "asked for that exact filename", "Why by filename and not by id", and "NOT FOUND by filename". After the rework the store is NOT queried on filename; it is a type-scoped query matched in Python, precisely because that attribute has no index. A reader would have concluded the opposite, and anyone simplifying would have restored the broken form. The test guards the filters; the prose was inviting the change. 2. WARNING — the CIC still said "After both legs are uploaded" and "Two distinct refusals". Both wrong: every artefact is checked now, and there are four refusals across two questions. Updated, with why the per-object half resolves the way faoapi does rather than by filename. Review date moved. 3. SUGGESTION — `objects` carried no annotation while `legs: dict` sat beside it. Annotated in verify and in both managers. Checked and NOT findings, recorded so they are not re-raised: blank-line hygiene is correct, `logger` remains used in both managers, and `assert_findable` is still reachable through the legs loop. Both partner packages remain under budget at 690 and 699. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ANY1CCy9Xo7zjMY4XJ69v9
|
Both of your follow-up questions answered from the code rather than from having watched it. One is clean, one is a real finding. 1. Pagination — no truncation. Your guard is safe.
The walk is now careful in the two ways that matter, and its own comment says why:
2. "First wins" is arbitrary, not newest — this one needs a decisionThe walk issues: queries + [Query.limit(DEFAULT_PAGE_LIMIT), Query.offset(offset)]No ordering. No That matters in exactly the case you were right to flag. If two documents can share a filename, "first wins" is not a tie-break rule — it is a coin flip whose bias you cannot see, and it may differ between runs against an unchanged store. Three options, and I do not think this blocks the merge:
I lean to the second, for the same reason your Otherwise: approve, unchanged from my last passThe six mutations, the regression test carrying the index reasoning in its failure message, and the budget outcome (both packages under where they started) all hold. Nothing else from me. One thing I should say plainly, since you have taken every correction I have offered today: this PR is now materially better than the change I reviewed this afternoon, and both improvements came from you pushing back on your own first answer rather than from the review. The filename-query shape and the 🤖 Generated with Claude Code |
…(/code-review) Six findings from /code-review medium on PR #314. It also traced the new query down through pipeline-core to Appwrite and confirmed the mechanism holds: the scope set matches faoapi's query exactly, newest-first is guaranteed by the $createdAt sort so first-wins reproduces the consumer's selection rather than approximating it, pagination is handled upstream (MAX_METADATA_PAGES 1000 x 100) so 110 objects are not truncated, and pipeline-core's own comment at file.py:1216 independently corroborates that `filename` carries no index. MEDIUM 1 — a polarity bug, and the worst kind. The parse sat OUTSIDE the try. If the store returned objects rather than dicts, or renamed its keys, `doc.get` raised, `seen` stayed empty, and EVERY object was reported not found — firing the full "NOTHING in this run is servable" refusal on a healthy delivery. A failure of the check must never quarantine the delivery; this module argues that twice, in C-99's and C-103's words, and then did the opposite. The parse is now inside the try. Mutation-proven: moving it back out fails the new test. MEDIUM 2 — silent fallback. `summary.get("uploaded_objects", [])` would have let the guard quietly shrink to the single historical object and log "preflight passed" while 108 shards, the sidecar and the manifest went unasked — the precise shape of the bug being fixed. The key is guaranteed on every path that reaches the guard (the interlock-closed path returns before it), so its absence is a defect and now reads as one (ADR-003). LOW 3 — `documents()` had no test anywhere. It is the only method the per-object check depends on, and the findability tests inject callables, so a pipeline-core rename would have surfaced during a live delivery. Now asserted at the port, which is the seam whose job is absorbing exactly that. LOW 4 — the port documented itself as four-method; `documents` makes it five. A datastore satisfying the documented contract would have failed with AttributeError mid-delivery. Also fixed, reported as not-a-finding: `unverified()` hardcoded the word "leg", so a scope rendered as "'forecast/sampled_forecast_shard objects' leg". It now takes the phrase. And the module docstring claimed "no store types" while verify reads two document KEYS — the boundary is now stated honestly rather than overclaimed. LOW 5 (SEARCH_INCOMPLETE noise growing ~110 documents per run) and LOW 6 (the budget-driven inline dict at the call site) are recorded on #312 rather than changed here: 5 needs a C-94 note and no code, and 6 costs lines neither partner package has. 522 passing. Both packages unchanged at 690 and 699. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ANY1CCy9Xo7zjMY4XJ69v9
|
Re-reviewed after the The polarity bug is the best self-catch of the dayThe parse sitting outside the A guard that quarantines a good delivery is worse than no guard, because it trains the operator to route around it. Catching it before merge, and mutation-proving the fix by moving the parse back out, is exactly right. So is the Correction — I do not think pipeline-core sorts by
|
…#312 re-review) The views-models seat could not find the `$createdAt` sort I had twice asserted as fact. They were right and I was wrong, and the way I was wrong is the worse part: an automated review asserted it, I relayed it to them twice without checking, and it became a rationale I was about to put in the code. Verified here. `search_files_by_metadata` builds `queries = []` and appends only `Query.equal(attribute, value)` per filter, then `limit` + `offset` (pipeline-core `modules/appwrite/file.py:1045-1050`). No `order_desc`, no `order_asc`, no sort field. The only ordering calls in that module are at `2643-2645`, inside `list_files` — a STORAGE listing, not a collection query — where `order_field` defaults to None. So document order is unspecified and "first match wins" was a coin flip dressed as a tie-break. Rather than record the caveat, the assumption is gone. `seen` now maps filename to the SET of ids carried under it, and an object is findable when the id THIS run uploaded is among them. That is the question we actually have, and it needs no ordering guarantee at all. The refusal now shows every id the store holds under a name instead of one, which is strictly more useful for an operator auditing a bucket. Two tests: the same delivery passes under three different document orders including a stale same-filename document returned first, and order-independence does not become permissiveness — a name carrying only other runs' ids is still refused. Mutation-proven by restoring first-wins, which fails both. NOT fixed here, because they are not ours, and both are now on #312: pipeline-core's `get_latest_file_id` DOCUMENTS "the newest matching file based on creation timestamp" and implements `files_list[0]` over that unsorted result — a stated guarantee with no mechanism, and the C-94 leg check has depended on it since August. views-faoapi's `resolve_artifact_file_ids` carries the same "Newest-first" assumption in its docstring. 524 passing. Both partner packages unchanged at 690 and 699. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ANY1CCy9Xo7zjMY4XJ69v9
Closes #312. Fixes the defect behind the 2026-09-29 unservable UN-FAO delivery.
What happened
109 of 110 objects uploaded, every call reported success, views-faoapi refused at ingest. The §5 GAUL sidecar's bytes matched the previous run's; the content-addressed store correctly declined a duplicate,
update_documenthit the old file, and the port returned a real file id for the wrong document. The consumer resolves byfilename, found nothing, refused.Why the guard could not have caught it
It checked two entries via
latest_file_id({"name": ..., "category": category}). Every wire object carriescategory="forecast"(sink.py:227), and the manifest is uploaded last — so that query always returns the manifest. Adding the sidecar to the dict would have resolved the manifest and passed. The lookup key was wrong, not the dict.The fix — two questions, neither implying the other
legsobjectsWhy per-object and not a count
Pooling upstream is deterministic — measured in views-models, 25/25 anchor cells byte-identical across an accidental re-pool — and
naming.pyembeds the run id in every filename. A re-run writes new names over identical bytes, so the dedup path that took the sidecar takes all 110 at once: documents created, count correct, nothing servable. A count-plus-spot-check passes that.And re-running is our own documented remedy (C-105, C-22). The prescribed response to a failed delivery is the trigger for the worst version of this bug.
On the line budget
The query loop moved out of both managers into
delivery/findability.py. The partner budget's own comment says the response to it binding is to move code out of the package, not raise the number — andcrafd/sat at 699 of 700. Delegating paid for the wider call site exactly: both partners unchanged at 690 and 699. One copy of the rule instead of two that can disagree (C-75).Deliberate soft edge
An unqueryable
filenamedegrades to UNVERIFIED, never to "invisible". It is a declared collection attribute (pipeline-coreprovisioning.py:101), but a guard that manufactured an outage on every delivery would be deleted within a week (ADR-014 §3).Verification
517 passed, ruff clean. Five mutations, each caught:
Noted, not acted on
This is the first bug requiring an identical hand-patch in both partner managers — one of the two triggers C-33 names for extracting the partner seam. Recorded on #312.
Complementary to views-pipeline-core's fix for the upload that reports success having written nothing. Neither waits on the other.
🤖 Generated with Claude Code
https://claude.ai/code/session_01ANY1CCy9Xo7zjMY4XJ69v9