Skip to content

feat: typed exception for a withheld known-good binary - #316

Merged
sbneto merged 20 commits into
developfrom
dn-8425-known-good-status-signal
Aug 3, 2026
Merged

feat: typed exception for a withheld known-good binary#316
sbneto merged 20 commits into
developfrom
dn-8425-known-good-status-signal

Conversation

@sbneto

@sbneto sbneto commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

TL;DR

  • New KnownGoodWithheldException(NotFoundException), raised from the shared 404 arm when the error envelope's code says the artifact is a known-good binary whose bytes are withheld — so "withheld by design" is separable from "deleted" without matching prose.
  • Live coverage of the refusal and of the HEAD existence probe against the real server, replacing fabricated bodies.
  • Two rendering fixes in the failure-message path, and one behavioural doc correction on exists().
  • No code changes were needed for the server-side known-good model rework — the wire contract it kept is exactly the one this SDK depends on. Documented what the model means for consumers.

Context

A download refused because the sample is a known-good binary used to be indistinguishable from one refused because the artifact was deleted — both a 404 with prose. The server now sends a stable machine-readable code in the error envelope, so a caller can branch on it.

Requires

  • artifact-index #1919traceability, not a merge dependency. CI installs this branch's SDK archive when the branch names match, so a reviewer can see what is being tested against; but the surface this PR needs is already released, and the server's errors.code strings are unchanged by #1919.

Changes

  • exceptions.pyKnownGoodWithheldException, with the flagging feeds exposed as .sources. Subclassing NotFoundException is the point: the status is unchanged and every existing except NotFoundException handler keeps catching it, which specs/05-downstream-contract.md invariant 3 requires. _normalise_sources guarantees the one documented shape (always a list of strings) and logs what it discards.
  • core.py — the shared 404 arm inspects the already-extracted errors payload and raises the subclass when code == 'KNOWN_GOOD'; every other 404 falls through unchanged. Both the JSON and the streaming-download paths go through this mapper, so they behave identically. Also fixes _bad_status_message for dict- and string-shaped errors payloads — a dict used to render as its repr and a string as one character per line.
  • aio/api.py + the regenerated mirror — exists()'s docstring corrected: require_scan narrows what counts as found, and the reason a non-2xx does not raise is that parse_response short-circuits HEAD before the status mapping (not a missing parser). 404 is documented as also meaning absent. Every download* method gained a :raises: for the new exception, with the nothing-is-written guarantee.
  • specs/03, specs/04, specs/05 — the endpoint catalogue's download rows, the testing tiers, and the downstream contract.

Tests

  • The refusal and the probe are asserted against the real server, on resources the tests provision themselves: download() raises the typed exception, .sources names the flagging feeds (re-checked after a second feed extends the entry), and the probe's 200/204 boundary is exercised in both the plain and require_scan forms. That is specs/04-testing.md invariant 1, and it matters here: an earlier commit on this branch exists only because a fabricated body said KNOWN_GOOD_WITHHELD while the server said KNOWN_GOOD, and nothing in the suite noticed. Cassettes recorded against a live stack, and re-recorded when the bodies changed — an earlier pair had been recorded from a previous version of the test and only replayed because VCR matches by request identity rather than order.
  • test/exists_probe_mapping_test.py — the arms the e2e stack cannot produce (404False, plus the 5xx fabricated-negative case recorded as a decision, not an accident), on the shared ClientTestCase harness so both transports are covered. The 200/204 arms stay live, because only those can catch a server-side flip.
  • test/_client_harness.py — the parametrised harness extracted so any respx module can import it (and last_request_url fixed to read the last call, matching last_request_body).

pytest — 163 passed. The sync mirror is byte-identical after regeneration, so CI's staleness check is clean. No version bump: that belongs to the develop→master step.

What artifact-index's model rework means here (no code change)

#1919 replaced the server's known-good design with a two-predicate model. Two consequences are worth knowing as a consumer, and are now in specs/05-downstream-contract.md:

  • The refusal fires on the server's current understanding — a catalogue entry exists for the sha256 and that entry's extension passes an executable allow-list — evaluated live on every request. So a refused download can start working again with no action on your part, when the entry is deleted or the policy narrows.
  • ArtifactInstance.state can carry a new value, NOT_STORED: a submission the server declined as known-good at the time, whose hash is no longer currently known-good. Nothing was ever stored for it and a fresh submit of the same file works. state is a plain string the SDK does not enumerate, so a new member needs no SDK release.

The errors.code strings (KNOWN_GOOD / DELETED / EXPIRED / NOT_STORED) are unchanged, and the known-good lifecycle test already catalogues with an eligible filename (kg-sample.exe), so its assertions hold as written.

A download refused because the sample is a known-good binary now carries a stable
machine-readable code in the error envelope, so a caller can tell it apart from a
deleted, expired or never-uploaded artifact instead of matching prose.

`KnownGoodWithheldException` subclasses `NotFoundException` and is raised from the
shared 404 arm when the payload's code says so, exposing the flagging feeds as
`.sources`. Subclassing is the point: the status is unchanged and every existing
`except NotFoundException` handler keeps catching it, which the downstream
contract requires. Every other 404 is unaffected.

Also documents the server's `require_scan` semantics: a known-good hash counts as
present, because it is a decided terminal record — the platform will never scan it
and never stores its binary. The 200-vs-204 contract is unchanged.
@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md, specs/01, specs/02, specs/04, specs/05.

Correctness / architecture / downstream contract / gitflow: clean. The 404 arm lives in _raise_for_status, which is the one mapper both parse_response and _execute_download go through, so the "both paths behave identically" claim in the PR body holds by construction. Subclassing NotFoundException satisfies specs/05 invariant 3, no call site in src/ catches NotFoundException in a way that would swallow the subclass, HEAD still short-circuits before the non-2xx dispatch (so exists() keeps reading 404 as absent), and deferring the version bump is right per invariant 6. Base is develop. Two things need action:


1. New server-contract behaviour is only covered by fabricated bodies — and the path that actually raises it is untested. (test/core_test.py)

specs/04-testing.md invariant 1: "E2e-first: endpoint behaviour is tested against the real server, not a mocked response. […] A fabricated respx response asserts what we think the server returns; a cassette asserts what it actually returned." Invariant 7 rightly endorses the pure-unit tier for parse logic, so the three new core_test.py cases belong — they just aren't sufficient on their own here, because the e2e stack can produce this exact scenario. test_known_good_lifecycle (test/client_scan_test.py:763) already creates a known-good from its own EICAR variant's sha.

Specific missing cases, both recordable as VCR lifecycle tests (sync in client_scan_test.py, async sibling in async_client_test.py):

  • After known_good_create(sha256=sha, source='nsrl'), v3api.download(out_dir, sha) raises KnownGoodWithheldException with .sources == ['nsrl']. This is the only test that would exercise the streaming-download path (_execute_downloadresponse.read()_raise_for_status) — the new unit tests all enter through parse_response, which is not the path a refused download takes in production. It also pins the errors-is-a-dict-with-code envelope shape against the real server rather than against our assumption of it.
  • exists(sha, require_scan=True) is True for a known-good sha. The require_scan semantics this PR newly documents in three places (aio/api.py + mirrored api.py docstring, specs/02 exists_hash, specs/03 exists) currently have zero coverage anywhere — the assertion is documentation-only.

2. Confirm the server side is merged first, and link it properly.

AGENTS.md, companion repos: "artifact-index — the server-side API the SDK talks to. New endpoints land there first; the SDK PR comes after." The ## Requires block reads artifact-index AI_PR — a placeholder, not a link. Until the server emits code == 'KNOWN_GOOD_WITHHELD' the branch is dead and no cassette for (1) can be recorded, which is presumably why the tests are fabricated. Please point ## Requires at the real PR and confirm the code string + errors dict shape are final on that side before merging.


Minor: exists — which cannot raise the new exception — got a docstring note, while download / download_to_handle / download_id / download_sandbox_artifact, which can, say nothing about it. Worth a :raises: line on the download methods since they're the public surface a caller branches on.

The server renamed the withheld-binary cause from KNOWN_GOOD_WITHHELD to
KNOWN_GOOD — every member of that closed set is a cause of a refusal, so the
suffix restated what the set already implies. The 404 arm has to match the value
the server actually sends, or a withheld-binary refusal quietly degrades to a
plain NotFoundException.

The exception class keeps its name: it describes the outcome a caller catches, not
the cause code it was dispatched from.
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review

Mechanics are right: base is develop, no version bump (correct — that belongs to develop → master), the subclass keeps invariant 3 in specs/05-downstream-contract.md, both the JSON and streaming paths share _raise_for_status, HEAD still short-circuits before the mapping, and .sources as a list of feed-name strings matches KnownGood.sources (['nsrl']). Two things to act on.

1. No test touches the path the exception exists for, and nothing pins the server envelope (test coverage).

The refusal happens on a download, i.e. _execute_downloadaread()_raise_for_status (session.py:152 / aio/session.py:149). Every new test is pure-unit against hand-authored bodies in core_test.py, so the streaming arm is unexercised and the shape this feature keys off (errors a dict with code / sources) is only asserted against a body this PR wrote itself. specs/04-testing.md decision tree, step 2: "Endpoint behaviour? → VCR-backed live-e2e test — the default … the cassette pins the actual contract", with respx / pure-unit reserved for what e2e can't produce. This scenario is producible: test_known_good_lifecycle (test/client_scan_test.py:763) already creates a known-good from its own EICAR sha. Extend it (and test_async_known_good_lifecycle) right after known_good_create(sha256=sha, source='nsrl'):

with pytest.raises(exceptions.KnownGoodWithheldException) as ei:
    v3api.download(tmpdir, sha)
assert ei.value.sources == ['nsrl']

Same test is the place to pin the other new claim: specs/03-endpoints.md and specs/02-resources.md now assert require_scan=True reports a known-good sha as present (200), and nothing tests it — assert v3api.exists(sha, require_scan=True) is True. Re-record the cassette per the AGENTS.md workflow rather than hand-editing it.

2. _bad_status_message still assumes errors is a list (core.py:397).

errors = '\n'.join(str(error) for error in request.errors)

The specs now call list-shaped errors "legacy", so the dict envelope is the shape going forward. Any status landing in the else arm (400, 500, …) with a dict errors renders as bare keys — code, known_good, sources — silently dropping every value from the message the caller sees. Pre-existing, but this PR is what makes the dict shape expected; worth handling both shapes here in the same change.

…streaming refusal

_bad_status_message assumed `errors` was a list, so it joined over it directly.
Iterating a dict yields its keys, so any non-404 status carrying the new
dict-shaped envelope rendered as "code\nknown_good\nsources" — every value
silently dropped from the message the caller sees. The specs now call the list
shape legacy and the dict shape the way forward, so this change is what makes the
dict expected; both shapes are handled.

The exception exists for a refused download, which flows through the streaming
arm rather than parse_response, and no test drove that path — the coverage was
pure-unit against a body this branch wrote itself. There is now a test that
refuses a download at the transport and asserts the typed exception with its
sources.
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review

Mechanics are clean: base is develop, no version bump (correctly deferred to the develop→master step), specs updated in the same PR, KnownGoodWithheldException subclasses NotFoundException so specs/05-downstream-contract.md invariant 3 holds, and the _bad_status_message mapping fix is a genuine pre-existing bug caught with a regression test on both shapes. Three things need action.

1. The server contract is only asserted against fabricated responses — specs/04-testing.md invariant 1

Everything new here is pinned by a respx mock (test_async_download_known_good_refusal_raises_withheld) and hand-written pure-unit bodies. Invariant 1 is explicit about what that buys:

A fabricated respx response asserts what we think the server returns; a cassette asserts what it actually returned.

And this scenario is not one the e2e stack cannot reasonably produce — it is already produced. test_known_good_lifecycle (test/client_scan_test.py:763) creates a known-good record for its own EICAR-variant sha via known_good_create(sha256=sha, source="nsrl"). Adding a download() against that sha is a small extension of a test that already exists and already has a cassette workflow.

The commit log shows why this matters: fix: match the server's refusal code, now KNOWN_GOOD — the code string was already wrong once, and no test in this PR could have caught it. Concrete failure modes that leave the whole suite green:

  • server emits list-wrapped errors: [{"code": "KNOWN_GOOD", ...}] → falls through isinstance(errors, dict) → plain NotFoundException, and test_404_other_error_code_stays_plain_not_found actively asserts that list-shaped errors stay plain.
  • server names the feed field feeds / known_good rather than sources.sources == [], and test_404_known_good_withheld_without_sources asserts [] is a legitimate outcome, so nothing fails.
  • server sends the code on a different status (e.g. 403 withheld) → RequestException, no mapping at all.

Please add the cassette-backed slice (sync client_scan_test.py + async mirror): create a known-good for the test's own malicious_artifact(uid) sha, then assert download() raises KnownGoodWithheldException with .sources == ["nsrl"].

2. require_scan semantics are documented in five places and tested in none

The new claim — a known-good sha reports present under require_scan=True — lands in specs/02-resources.md, specs/03-endpoints.md, and both exists() docstrings (aio/api.py:1645, api.py:1992). It is a pure statement about server behaviour with zero coverage. exists(sha, require_scan=True) is True on the known-good sha the test just created is one assertion in the same e2e test as item 1. As it stands, if the server disagrees, the SDK's published documentation is wrong and CI is silent.

3. specs/04-testing.md now contradicts its own invariant 5

The new paragraph legitimises a third slice on the respx tier and names an async-only test as the pattern. Invariant 5, unchanged, says:

respx tests (where justified under invariant 1) use the parametrised ClientTestCase harness. ... Do not write parallel sync / async respx bodies.

The consequence is that the sync _execute_download refusal arm has no coverage. Risk is low (the mapper is shared in core.py, session.py is generated), but the spec should not assert two conflicting rules. Either run the body through ClientTestCase_MockBoundary covers both transports and would cost nothing here — or amend invariant 5 to carve out streaming-transport tests explicitly.

Minor

core.py:365sources=errors.get("sources") passes the server value through unvalidated, while specs/05-downstream-contract.md promises .sources is a list of feed names. A bare string ("nsrl") or the feed-dict shape ArtifactInstance.known_good uses would both survive and turn for s in exc.sources into something surprising. An isinstance/list() guard makes the documented contract true regardless of what arrives.

….sources

The streaming-refusal test added last round was an async-only respx body, which
the testing spec's harness invariant forbids — and the consequence was that the
sync transport's refusal arm had no coverage at all. It now runs on the
parametrised harness so one body covers both, and the spec paragraph no longer
sanctions a parallel async-only body. The harness moved to its own module rather
than being imported from a collected test file.

`.sources` was passed through from the wire unvalidated while the downstream
contract promises a list of feed names, so a bare string or the feed-dict shape
would have survived and made iteration surprising. It is normalised at the
boundary: a list of strings stays, a bare string becomes one element, anything
else becomes empty.
@sbneto

sbneto commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Re: the cassette-backed live-e2e coverage for the refusal and for exists(require_scan=True) — I agree with the finding and I attempted the recording rather than deferring it again. It is blocked on environment, not on the change, so recording it here is not possible today.

What recording needs, and where it stops. The refusal only exists once the server half of the paired artifact-index change is running, so the cassette has to be recorded against an e2e stack built from that branch. Building that image fails locally:

#8 39.39 WARNING: 401 Error, Credentials not correct for https://pypi.ci.polyswarm.network/simple/uv/
#8 40.22 ERROR: No matching distribution found for uv

docker/Dockerfile points PIP_INDEX_URL at the private index and then runs pip3 install -U uv. That index 401s for uv and pip under both credential sets available here (the PIP_INDEX_URL userinfo and ~/.netrc), because it serves PolySwarm packages only — in CI the configured index also proxies upstream PyPI, which is why the same build succeeds there. Working around it would mean editing a production Dockerfile to suit one machine's secrets, so I stopped.

What that leaves. The refusal mapping is covered on both transports through the parametrised harness (test/download_refusal_test.py, added in the latest commit — the previous async-only respx body violated the harness invariant and left the sync arm uncovered), and .sources is now normalised at the boundary so the documented list-of-strings contract holds whatever arrives. What is still unpinned is exactly what you identified: the envelope's real shape, and the require_scan=True claim now documented in four places.

Concretely, to close it — on any host that can build the artifact-index image (or in CI with TESTS_VCR=off):

  1. rm test/vcr/test_known_good_lifecycle.vcr test/vcr/test_async_known_good_lifecycle.vcr
  2. add, between the first known_good_create and the trailing delete:
    with pytest.raises(exceptions.KnownGoodWithheldException) as ei:
        v3api.download(tmpdir, sha)
    assert ei.value.sources == ['nsrl']
    assert v3api.exists(sha, hash_type='sha256', require_scan=True) is True
  3. run both tests against a freshly booted stack to re-record, and commit the cassettes.

Placing them after the first create (before the commercial extend) is what makes sources == ['nsrl'] deterministic. Happy to do this if someone can hand me a working image, or to leave it as the follow-up.

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Base is develop, no version bump, no ticket refs, no AI trailers — gitflow clean. The NotFoundException subclassing decision, the .request-attachment behaviour, and the _bad_status_message mapping fix all hold up: _extract_json_body populates request.errors before the 404 arm reads it on both paths (parse_response and the streaming _execute_downloadaread()_raise_for_status), so the two transports really do raise identically, and the empty-errors ({} / [] falsy) and legacy-list cases fall through unchanged. Three things worth acting on.

1. The server-side contract is only asserted against bodies this branch wrote itself

Every assertion on the new envelope — errors["code"] == "KNOWN_GOOD", sources as a list of feed-name strings — comes from a fabricated dict in core_test.py / download_refusal_test.py. specs/04-testing.md invariant 1 is explicit about why that is not enough:

A fabricated respx response asserts what we think the server returns; a cassette asserts what it actually returned.

This PR's own history is the failure mode: commit 2 exists solely because the fabricated body said KNOWN_GOOD_WITHHELD and the server said KNOWN_GOOD. Nothing in the suite would have caught that, and nothing in the suite would catch the next rename either.

The respx transport-arm test is well justified and should stay — _execute_download genuinely is unreachable from a parse test, and putting it on the shared harness (invariant 5) is right. But its docstring argues a cassette test "asserts only the caller-visible outcome", and here the caller-visible outcome is the contract: the typed exception plus .sources.

The slot already exists. test/client_scan_test.py:763 test_known_good_lifecycle creates a real known-good record against the live stack for its own EICAR sha. Two additions there (and in the async twin) pin both new behaviours against the real server:

  • download(tmp, sha)KnownGoodWithheldException, asserting .sources == ["nsrl"] (and after known_good_create(..., source="commercial"), both feeds).
  • exists(sha, require_scan=True) is True — the require_scan semantics this PR newly documents in aio/api.py:1645, api.py:1992, specs/02-resources.md:68 and specs/03-endpoints.md:93 currently have no test at all, sync, async, mocked, or recorded. It is a released docstring claim about server behaviour with nothing holding it.

Both need the companion server PR merged before the cassettes can be recorded, which is fine — that PR is already listed under Requires.

2. _normalise_sources silently empties the other shape the server uses for the same concept

exceptions.py:47-51 maps [{"tool": "nsrl"}][]. That is the exact shape the server sends for this same concept on the instance response, which resources.py:275 unpacks via feed["tool"] into ArtifactInstance.known_good_sources. If the error envelope reuses that serialiser, .sources becomes [] and the exception's entire added value disappears with no diagnostic — the caller cannot tell "server named no feeds" from "server named feeds in a shape we dropped".

KnownGood.sources (resources.py:888) is a list of strings, so the current guess is probably right — but that is a guess, which is finding 1 again. Cheap hardening: extract feed["tool"] from dict entries too, mirroring known_good_sources, so both server shapes yield feed names. Failing that, logger.debug the dropped payload so the shape mismatch is not invisible.

3. Stale spec pointer after the harness move

specs/99-open-questions.md:11 still reads "The parametrised ClientTestCase harness in test/metadata_field_properties_test.py". It moved to test/_client_harness.py; 04-testing.md was updated, this one was not. (01-architecture.md:290 says "metadata_field_properties_test.py shape", which still scans as a reference to the canonical example — fine either way.)

The suite asserted the error envelope only against bodies it fabricated
itself, which asserts what we think the server sends. This PR's own history
is the failure mode: the fabricated body said KNOWN_GOOD_WITHHELD while the
server said KNOWN_GOOD, and nothing in the suite noticed. Both known-good
lifecycle tests (sync + async) now drive the real endpoint:

- download() raises KnownGoodWithheldException and .sources names the
  flagging feeds, re-checked after a second feed extends the entry
- exists(require_scan=True) is True — semantics documented in four places
  with no coverage until now

Cassettes re-recorded against a live stack; the recordings confirm the
server's shape is a list of feed-name strings.

Also harden _normalise_sources: extract feed['tool'] from dict entries
instead of dropping them. That is the other shape the platform uses for
this concept (the instance-level known_good field), and silently emptying
.sources would take the exception's whole added value with it.
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md + specs/0005. The substance is clean: the 404 arm lives in the shared _raise_for_status, so both the buffered JSON path and _execute_download map identically (specs/01 §parse_response); subclassing NotFoundException holds invariant 3 in specs/05; no internal except NotFoundException sites exist to be perturbed by the new subclass; base is develop and pyproject.toml version is untouched with the decision stated in the PR body (specs/05 invariant 6). Cassettes are delete-recorded against a live stack, and the two identical consumer/download/... interactions are recorded in call order, which replays correctly under vcrpy's default allow_playback_repeats=False. Test tiers match specs/04: respx for the transport arm (justified in the module docstring per invariants 1/5), pure-unit for the normalisation matrix and the plain-404 fallthrough, live-e2e for the caller-visible contract on both transports.

Two small things:

1. test/_client_harness.py:38last_request_url reads calls[0], last_request_body reads calls[-1].

@property
def last_request_url(self) -> str:
    return str(self._router.calls[0].request.url)

Carried over verbatim from metadata_field_properties_test.py, but this PR promotes it from one module's private helper to the shared harness that specs/04 now names as the import site for every respx module. A multi-request test that reads last_request_url gets the first request's URL while last_request_body gets the last one's. Either make it calls[-1] or rename it first_request_url.

2. core.py:410 — the non-dict branch still iterates a bare string character-by-character.

The new dict branch is right, and the reasoning in the comment ("iterating a mapping yields only its KEYS") applies verbatim to a string — which is exactly why _normalise_sources special-cases isinstance(sources, str) two files over. An "errors": "some prose" envelope on a 500 renders as s\no\nm\ne\n \np.... Cheap to close while you are in here:

if isinstance(request.errors, dict):
    errors = '\n'.join(f'{k}={v}' for k, v in request.errors.items())
elif isinstance(request.errors, (list, tuple)):
    errors = '\n'.join(str(error) for error in request.errors)
else:
    errors = str(request.errors)

Non-blocking: the prose is heavy relative to the code. The specs/05 addition is a single ~350-word paragraph where the rest of that spec is terse tables plus short statements, and several test comments are longer than the assertions they precede — including one that narrates review history (client_scan_test.py: "KNOWN_GOOD_WITHHELD → KNOWN_GOOD, which really happened mid-review"), which will not mean much to a reader six months out. The content is all correct; consider trimming the specs/05 paragraph to the contract (subclass, .sources is always a list of feed-name strings, raw envelope at exc.request.errors) and letting _normalise_sources' docstring carry the shape rationale it already carries.

…r per line

Review follow-ups:

- The dict branch added earlier reasoned that iterating a mapping yields only
  its keys; the same applies to a bare string, which iterates as characters.
  An `"errors": "some prose"` envelope rendered one letter per line. Only
  list/tuple payloads now get the line-per-entry treatment.
- _client_harness: last_request_url read calls[0] while last_request_body
  read calls[-1]. Invisible in a single-request test, silently wrong in a
  multi-request one — and this is now the shared harness every respx module
  imports, not one module's private helper.
- Trim the specs/05 paragraph to the contract and let _normalise_sources'
  docstring carry the per-shape rationale; drop a test comment that narrated
  review history rather than the risk.
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md + specs/01, 02, 03, 04, 05. Structurally clean: base is develop, no version bump (correct per the develop→master rule), the sync mirror change is the identical docstring text so the codegen staleness check is fine, the exception subclasses NotFoundException as invariant 3 of specs/05 requires, and the cassettes agree with the code on the KNOWN_GOOD code string (both .vcr files, both download interactions). Only low-severity items.

1. exceptions._normalise_sources leaves the exact hole its own docstring argues against (src/polyswarm_api/exceptions.py:55-65)

The docstring justifies the feed-dict branch on the grounds that silently dropping a recognisable shape would empty .sources "with no diagnostic, and no way for a caller to tell 'the server named no feeds' from 'the server named feeds in a shape we discarded'". The final return [] then does precisely that for every other shape — e.g. if the envelope ever serialises sources as a mapping keyed by feed name rather than a list, .sources is [] and nothing anywhere says why. A logger.warning on the fall-through arm (or on any non-str / non-list payload) makes the discard visible and matches the stated rationale. exceptions.py has no logger today, so this is a real if small addition rather than a one-liner.

2. The live-e2e tests do not pin the "plain miss stays plain" half of the contract (test/client_scan_test.py:805, test/async_client_test.py:436)

The post-delete assertion is pytest.raises(exceptions.NotFoundException), which KnownGoodWithheldException also satisfies — so the one place in the suite where the real server returns a non-known-good 404 (a null errors slot, cassette line 320) never checks that it stays the base class. test_404_other_error_code_stays_plain_not_found covers this, but only against fabricated bodies, which is the exact gap the PR description says it is closing. One extra line in each lifecycle test:

with pytest.raises(exceptions.NotFoundException) as ei:
    v3api.known_good_get(sha256=sha)
assert not isinstance(ei.value, exceptions.KnownGoodWithheldException)

No re-record needed — the cassette already has the interaction.

3. Spec overstates where the mapping-shaped errors actually reaches the caller (specs/01-architecture.md:274, specs/02-resources.md:394)

Both now say the mapping shape "is not 404-only — the server forwards it on every arm (400 / 401 / 403 / 413 / 5xx)". True of the server, but _bad_status_message has exactly one call site (core.py:374, the else arm). The 404 / 422 / 429 arms build their messages from request._result alone, so a 422 or 429 carrying a mapping-shaped errors drops the whole payload out of the message a caller sees — the same lossiness the new branch was added to fix, just on a different status. Pre-existing, and the payload is still reachable at exc.request.errors, so either narrow the spec wording to the RequestException arm or route the other arms through _bad_status_message too; as written the spec reads as though the render applies everywhere.

Nothing blocking.

Review follow-ups.

- _normalise_sources' docstring argues that a silent discard is the failure
  mode it exists to prevent, then its fall-through did exactly that for every
  unrecognised shape. It now logs the discard at debug level.
- Both live-e2e lifecycle tests asserted the post-delete 404 with
  pytest.raises(NotFoundException), which the subclass also satisfies — so the
  one place the real server returns a plain miss never checked that it stayed
  the base class. No re-record needed; the cassettes already hold the
  interaction.
- specs/01 and specs/02 said the mapping-shaped errors render applies on every
  status. True of what the server sends, not of what the caller sees:
  _bad_status_message has one call site, so the 404/422/429 arms build their
  messages from _result alone. Narrowed to the RequestException arm, with the
  payload's reachability at exc.request.errors stated separately.
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md + specs/01, 02, 03, 04, 05.

The core mapping is right: _raise_for_status is the single shared arm, so the buffered and streaming paths behave identically; errors is populated by _extract_json_body before the 404 dispatch on both; no SDK-internal except NotFoundException site swallows the new subclass; the "_bad_status_message has a single call site" claim in specs/01 is accurate; and the two re-recorded cassettes are genuine (distinct EICAR-derived sha per test, real KNOWN_GOOD envelopes, sequential replay of the two identical download GETs works under the documented matcher). Base is develop, no version bump — both correct. Three things:

1. download_refusal_test.py's stated justification is contradicted by this same PR (spec drift).

Its docstring and the new paragraph in specs/04-testing.md both argue the respx tier is required because a refusal is "reachable by neither a pure-unit parse test nor a cassette-backed endpoint test that only asserts the caller-visible outcome." But this PR adds exactly that cassette-backed test, on both transports — test_known_good_lifecycle and test_async_known_good_lifecycle now call download() against the live server and assert KnownGoodWithheldException + .sources, which runs _execute_download's 404 arm end to end in sync and async. So the mapping is already covered at the e2e tier that specs/04 invariant 1 makes the default; the only assertion unique to DownloadRefusalTestCase is os.listdir(tmp_dir) == [].

Pick one: move that single assertion into the two live bodies and drop the respx module, or keep the module and rewrite both the docstring and the specs/04 paragraph to claim what it actually adds (a refusal writes nothing to the destination) rather than a coverage gap that no longer exists. As written, the next person reads the spec as licence to reach for respx on any transport arm, and finds the stated reason false.

2. _normalise_sources's feed-dict branch is speculative.

exceptions.py unpacks source['tool'] for a shape the error envelope has never sent — both re-recorded cassettes carry plain string lists (["nsrl"], ["commercial","nsrl"]), and nothing routes ArtifactInstance.known_good's feed dicts into this exception. The docstring concedes it: "if the envelope ever reuses that serialiser." Five of the nine rows in test_404_known_good_sources_normalised_to_feed_names exist only to exercise it. The str -> [s] and non-list -> [] guards earn their keep (they stop .sources iterating as characters, a real shape hazard); the dict branch is for a hypothetical, and specs/05-downstream-contract.md now commits the published surface to supporting it. Suggest dropping the branch and its test rows — or at minimum not writing it into the downstream contract.

3. Comment density, and "regressions" that never shipped.

exceptions.py was bare pass classes before this PR; it now carries a ~25-line helper docstring arguing through each rejected shape. Separately, several new comments are labelled # Regression: for defects that only ever existed in this branch's intermediate commits — core_test.py ("the raise site passed the envelope's sources payload straight through"), the PR body's "an earlier commit on this branch exists only because...", and download_refusal_test.py's "Regression: the refusal arrives on the streaming arm." None of that was ever released, so a future reader will mine those notes for released-behaviour history that doesn't exist. Trim to what the code needs and let the branch history hold the rest.

… respx tier

Review follow-up, and a spec contradiction this branch introduced.

download_refusal_test.py's docstring and the specs/04 paragraph that
legitimised it both claimed the streaming refusal was "reachable by neither a
pure-unit parse test nor a cassette-backed endpoint test". A later commit on
this same branch made that false: both known-good lifecycle tests now drive
download() against the live server on both transports, which runs
_execute_download's 404 arm end to end. The only assertion unique to the respx
module was that the refusal writes nothing to the destination, so that moved
into the two live bodies and the module is gone. Keeping it would have left a
tier whose stated justification reads as licence to reach for respx on any
transport arm.

specs/04 now says what actually covers that arm, and when respx is still the
right tool (a connection reset, a retry ladder, a truncated body).

Also, on prose: specs/05 committed the published surface to accepting the
feed-dict sources shape the envelope has never sent. The defensive branch stays
(it costs three lines and prevents a silent empty), but the contract is narrowed
to the one promise consumers can rely on — always a list of strings — with the
per-shape reasoning left in the helper's docstring. Trimmed that docstring and
dropped the "Regression:" label from a comment describing a defect that only
existed between commits on this branch.
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md + specs/01, 02, 03, 04, 05. The mapping itself is correct: _raise_for_status reads request.errors after _extract_json_body, so both the buffered path and _execute_download (which read()/aread()s first) see the dict; the subclass keeps invariant 3 of specs/05; the dict / list-tuple / else split in _bad_status_message is right and its single call site is accurately documented in specs/01. Cassettes contain the real KNOWN_GOOD envelope and a HEAD …require_scan=true → 200, and the plain-miss 404 (errors: null) is pinned in both cassettes, so the "stays the base class" assertion is real. No version bump — correct per AGENTS.md §Gitflow / specs/05 invariant 6. Base is develop. Five items, none blocking correctness.

1. test/client_scan_test.py:783 — the comment states the opposite of what the code does.

# A refused download leaves nothing behind: the streaming path opens its destination before the response is read, so a truncated or empty file here would look to a caller like a download that worked.

_execute_download does the non-2xx check before open_destination (aio/session.py:146-149 vs :164-167), and the finally: await response.aclose() covers the raise. Nothing is opened on the refusal path, so the stated rationale for the os.listdir(out_dir) == [] assertion is wrong — a future reader will believe the SDK creates the file first. The assertion is still worth keeping (it pins that the ordering never inverts); just say that instead. The wording is not duplicated in async_client_test.py, which only says "see the sync twin", so fixing it here fixes both.

2. specs/04-testing.md decision tree contradicts itself in one bullet.

Step 3 now reads (transport failures, retry exhaustion, cursor pathologies, external systems, an SDK transport arm)? and the next sentence says A transport arm is **not** automatically such a scenario. Adding "an SDK transport arm" to the list of things respx is for is exactly the licence the new §The transport arm section was written to remove. Drop it from the parenthetical, or narrow it to "a transport arm the stack genuinely cannot drive".

3. The internal ticket ID is in the PR description — AGENTS.md forbids that.

Do not reference ticket IDs or internal project codes in commit messages, PR titles, or PR descriptions.

The §Merging note spells out polyswarm/dn-8425-…, which puts the ID in this public repo PR body — the same leak the note is trying to prevent in the merge commit. The squash-merge request is right; make it without the literal ID ("the branch name carries an internal ID, so squash-merge rather than letting the default merge-commit subject land in history").

4. Stale Changes list. It credits test/download_refusal_test.py for the streaming arm, but no such file is in the diff — per the new specs/04-testing.md §The transport arm it was deliberately deleted in favour of the live coverage. And test/_client_harness.py, the one genuinely new file, is not listed at all. Worth correcting so reviewers are not looking for a file that is not there.

5. Record the bump size. "No version bump: that belongs to the develop→master step" is the right call, but specs/05 §Versioning — which this PR edits — has no row covering a new exception class, so the release PR has to infer it. Add one (New exception class (additive subclass) | minor) and state "minor" in the body; the 404 mapping change is only not major because the subclass preserves except NotFoundException, and that reasoning is worth pinning in the table rather than in prose.

Minor, take or leave: _normalise_sources docstring says "Anything unrecognised becomes [] and is logged, so a discard is never silent" — per-entry drops inside a list do not yield [], and logger.debug is off by default for consumers, so "never silent" overreaches. Soften it, or log the drop at warning. Similarly _client_harness.py "Shared by every respx test module" is currently one module (metadata_field_properties_test.py).

…ontradicted

Review follow-ups, both about statements that were wrong rather than code.

- The new os.listdir assertion was justified with "the streaming path opens its
  destination before the response is read". It does not: _execute_download
  checks the status before calling open_destination. The assertion is still
  worth keeping — it pins that the ordering never inverts, since an empty file
  is indistinguishable from a successful download — so it now says that.
- specs/04's decision tree still listed "an SDK transport arm" among the
  scenarios respx is for, one sentence before the new section saying it is not.
  That parenthetical was the licence the section exists to remove.
- specs/05's Versioning table had no row for a new exception class, so the
  release PR would have had to infer the bump. Added it, with the reasoning that
  makes it minor rather than major (the subclass preserves every existing
  `except NotFoundException`).
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review

Clean against the documented conventions — no action items found.

What I checked:

  • Correctness. _raise_for_status reads request.errors after _extract_json_body populates it, so the new 404 arm works on both routes into it (parse_response at core.py:269 and _execute_download at aio/session.py:149, which aread()s first). The streaming path still aclose()s in its finally, and the status check stays ahead of open_destination — which the os.listdir(out_dir) == [] assertion pins. KnownGoodWithheldException(request, request._result, sources=…) threads correctly through RequestException.__init__(request, *args). The _bad_status_message dict/list/str split is right: iterating a mapping would have dropped every value, and it's the only call site (the 404/422/429 arms build from request._result).
  • Spec drift. None. 00/01/02/03/04/05 all updated in-PR; _bad_status_message's single-call-site claim in 01-architecture.md:270 and the _client_harness.py entry against invariant 5 of 04-testing.md both hold. Nothing stale — no KNOWN_GOOD_WITHHELD string survives anywhere.
  • Downstream contract. Additive: new NotFoundException subclass, one new attribute, no signature changes. Invariant 3 holds (except NotFoundException still catches), and the versioning table now records the minor rule for this shape. No pyproject.toml version touch — correct for a feature PR per invariant 6.
  • Test coverage. Live-e2e covers both transports' 404 arm plus the plain-miss half (the not isinstance(…, KnownGoodWithheldException) assertion is the load-bearing one there, since the subclass would satisfy raises(NotFoundException)). Pure-unit covers the sources-normalisation matrix and both errors render shapes. Cassette interaction order matches call order in both files, the repeated download URLs rely on vcrpy's play-next-unplayed semantics (correct — they return different sources), and the require_scan=true HEAD records 200. Deleting the respx body for the transport arm is the right call and 04-testing.md §The transport arm records why.
  • Gitflow. Base is develop. ✓
  • Merge. Worth honouring the description's note: squash-merge, so the branch name doesn't land in this public repo's history via a merge-commit subject.

Not verified: I couldn't execute the suite in this environment, so the 157-pass and byte-identical-mirror claims rest on your run.

… the server

artifact-index reverted the change that made its hash existence probe answer
"found" for a known-good sha256 (PR #1919, commits 5c9268a8 / 0b90f54c). Those
status codes are a frozen contract, documented there in
specs/09-hash-search-head-contract.md: 200 = found, 204 = not found, anything
else = the request was wrong. A KnownGood row is a fact about the sample, not a
searchable record of an artifact, so widening the probe to include it moved a
caller's case from 204 to 200 — which this SDK reads as a bare status code with
no error channel, i.e. a wrong boolean and nothing else.

This side documented and asserted the widened behaviour, which is what turned
artifact-index's e2e job red: the live suite asserted
exists(..., require_scan=True) is True against a server that now correctly
answers 204.

Corrected in all four places (both exists() docstrings, specs/02, specs/03) and
in both lifecycle tests, which now assert what the server actually does for a
hash catalogued through the CRUD:

  plain          -> True   (create_known_good builds a searchable reference
                            instance, so a real record exists)
  require_scan   -> False  (nothing was ever scanned for it)

Cassettes re-recorded against a live stack running the reverted server, so they
now carry the first HEAD interactions in test/vcr/ — 200 for the plain probe and
204 for require_scan. That matters beyond this fix: every other assertion about
this endpoint in the suite is respx-mocked, so it pins the SDK's own mapping and
would stay green through a server-side flip. That is exactly how the 4.0
exists() inversion survived to ship in 4.0.0 and 4.1.0. These two tests would
not.
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review — mostly clean against AGENTS.md / specs/: base is develop, no version bump (correct; the bump decision is documented in the body and deferred to develop→master), KnownGoodWithheldException subclassing NotFoundException preserves invariant 3 in specs/05-downstream-contract.md, both cassettes are freshly recorded live and the two identical /consumer/download/... interactions replay in order under vcrpy defaults (allow_playback_repeats=False), and the specs moved in the same PR. Two things to fix.

1. The exists() docstring states the wrong causeaio/api.py:1655, mirrored at api.py:1993: "Because the probe carries no result parser, a non-2xx status never raises here." The absence of a result parser is not why. parse_response returns early on the HEAD branch (core.py:253-261) before the status dispatch; the non-2xx check at core.py:265-269 is explicitly annotated "map to the appropriate exception regardless of whether result_parser is set", and test/core_test.py::test_500_raises_even_without_parser is the regression test pinning exactly that. exists_hash happens to have both properties (resources.py:307-321 — HEAD, no parser), so the conclusion is right and the reason is wrong. It generalises falsely: a reader would infer that other parser-less endpoints (notification_webhook_test and friends) also swallow non-2xx, which is the bug that regression test exists to prevent. Reword to attribute it to the method being a HEAD — parse_response sets the status code as the result and suppresses the non-2xx mapping. Edit aio/api.py and regenerate.

2. The new bare-string arm of _bad_status_message is untestedcore.py:414-415. test_500_mapping_errors_keep_every_value_in_the_message and test_500_list_errors_still_render_one_entry_per_line cover the dict and list arms. The third arm has none, even though it is the one both specs/01-architecture.md ("A bare string is rendered as-is for the same reason: iterating one yields characters") and specs/02-resources.md ("anything else renders as a plain string") now promise. A 500 whose errors is the string some prose, asserting it renders on one line rather than one character per line, closes it alongside the two existing cases.

Minor — specs/04-testing.md:22 describes _client_harness.py as what "every respx test module imports" and says "the _ prefix keeps it out of python_files". Neither is quite right: client_scan_test.py and async_client_test.py both use respx directly without the harness, and the exclusion comes from not matching *_test.py / test_*.py, not from the underscore. Cosmetic, but this is the file the next contributor reads to pick a tier.

Review follow-ups.

- The exists() docstring said a non-2xx never raises "because the probe carries
  no result parser". Wrong cause, and it generalises to a falsehood:
  parse_response maps non-2xx to an exception whether or not a parser is set —
  test_500_raises_even_without_parser pins exactly that. What actually happens is
  the HEAD short-circuit, which sets the status as the result and returns before
  the mapping. exists_hash has both properties, so the conclusion held while the
  reason did not.
- Cover _bad_status_message's third arm: a 500 whose errors is a bare string
  renders on one line. Both specs promise it; the dict and list arms had tests
  and this one did not.
- specs/04 described _client_harness as what "every respx module imports" (two
  modules use respx directly) and credited the `_` prefix for keeping it
  uncollected (it is not matching *_test.py / test_*.py that does).
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review

Correctness looks sound. _raise_for_status is the single 404 mapper for both the buffered path (parse_response_raise_for_status) and the streaming path (_execute_downloadaread()_raise_for_status), so the subclass really does reach both transports; the cassettes carry real {"errors":{"code":"KNOWN_GOOD",...}} envelopes from the server rather than a fabricated body, and the plain-miss half of the branch is asserted with an explicit not isinstance(...) rather than relying on pytest.raises(NotFoundException). Subclassing NotFoundException satisfies invariant 3 of specs/05-downstream-contract.md; base is develop and no version bump, both per AGENTS.md. The _bad_status_message dict/list/str split has a single call site (core.py:374), which the spec note correctly bounds.

Three small things:

1. specs/04-testing.md contradicts itself, in text this PR added. Line 22: "Not every respx user needs it: client_scan_test.py / async_client_test.py drive respx directly". Line 57: "every respx test module imports ClientTestCase from it". The second is also just false today — metadata_field_properties_test.py is the only importer. Same overstatement in test/_client_harness.py's module docstring ("Shared by every respx test module"). Separately, the line-22 carve-out is a genuine exemption to invariant 5 ("respx tests … use the parametrised ClientTestCase harness. Don't write parallel sync / async respx bodies") whose current parenthetical only exempts live/VCR tests — the direct-respx cases in client_scan_test.py / async_client_test.py aren't covered by it. Either amend invariant 5 to name that exemption or drop the line-22 claim; right now the two readings disagree about whether those modules are compliant.

2. _normalise_sources drops at logger.debug, but the contract says the drop is visible. exceptions.py:60,63 log at DEBUG; the docstring says "Anything unrecognised becomes [] and is logged, so a discard is never silent", and specs/05-downstream-contract.md says "which are dropped and logged". A library that doesn't configure logging under an application at INFO makes those lines invisible — an unrecognised sources payload is a server-contract violation that silently degrades .sources to [] (or to a short list, in the mixed-list case), which is exactly the failure mode the normaliser exists to make legible. logger.warning matches the stated promise; DEBUG doesn't.

3. exists()'s new docstring leaks a test name and slightly overreaches. The .. note:: in aio/api.py:1651 (and the mirror) says "see test_500_raises_even_without_parser" — that ends up in help() / Sphinx output for a public method, where a consumer can't resolve it. Also "200 means found, 204 means not found, and anything else means the request was wrong" reads against specs/03-endpoints.md, which says 404 "also maps to absent, so both are False". Point the note at the spec and drop the test reference.

Merging: the "squash-merge or rename" instruction is correct but depends on a human honouring it — worth setting the merge method on the PR now rather than at merge time, since the branch name is the one internal reference AGENTS.md's public-history rule can't retroactively scrub.

…mock

The probe's 200/204 arms were covered only by a respx mock, which asserts this
SDK's mapping and not the server's behaviour — so a server-side flip left the
suite green. That is exactly how the 4.0 exists() inversion shipped in 4.0.0 and
4.1.0, and artifact-index's specs/09 named the gap.

test_hash_existence_probe_against_the_real_server (+ async twin) now provisions
its own resources the way every other e2e test here does — two deterministic
EICAR variants, one submitted and scanned, one nothing ever submits — and
asserts:

  never submitted   -> False   (204), plain and require_scan
  submitted+scanned -> True    (200), plain and require_scan

Deriving the absent case from its own uid rather than probing the sha about to be
submitted is what keeps the test re-runnable against a reused stack. Cassettes
recorded live, so test/vcr/ now carries HEAD interactions for both codes and both
run VCR-off against the stack in every e2e job.

The mock keeps exactly one arm — 404 -> False — because a well-formed probe never
produces a 404 (artifact-index reserves it for the request being wrong), and its
docstring says so rather than implying e2e could not cover the rest.

Also from review:
- exists()' note leaked a test name into public help() output and said "anything
  else means the request was wrong", which reads against specs/03 (404 also maps
  to absent). Rewritten.
- _normalise_sources logged its discards at DEBUG while the docstring and
  specs/05 promise a discard is never silent. A library under an app configured
  at INFO would show nothing. WARNING matches the promise.
- specs/04 claimed every respx module imports ClientTestCase (one does) and
  invariant 5's only exemption was live/VCR tests, which left the direct-respx
  bodies in the two client modules non-compliant by its own text. Invariant 5
  now names the single-transport exemption, and the harness docstring agrees.
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review

Correctness of the SDK change itself looks right: the 404 arm reads the already-extracted errors, both transports funnel through _raise_for_status (aio/session.py:146-149 reads the body first), the subclass preserves except NotFoundException per specs/05-downstream-contract.md invariant 3, _bad_status_message has a single call site so the mapping-render is correctly scoped to the RequestException arm, and the cassettes carry a real KNOWN_GOOD envelope from the server rather than a fabricated one. Gitflow is clean (base develop, no version bump, conventional commits, no ticket IDs in the messages). Four things to fix.

1. test_known_good_lifecycle asserts the probe with no poll, in the same PR that documents why that needs one

client_scan_test.py:802 and async_client_test.py:566 fire exists(sha, hash_type='sha256') is True immediately after known_good_create, unpolled. The sibling test added in this same PR states the opposite at client_scan_test.py:860 — "the search row is written by an async task, so allow for index lag" — and wraps its equivalent assertion in a 30x poll. known_good_create returns an artifact_instance_id, so it goes through that same row write. Under TESTS_VCR=off (the e2e job) this is a flake by the PR's own reasoning. Wrap it the same way, or say in the comment why the known-good path is synchronous where the submit path is not.

2. The probe test polls the broad form and then asserts the narrow one

client_scan_test.py:862-867 (and the async twin) polls until exists(sha) flips, then asserts the require_scan=True form with no wait of its own. require_scan=True narrows on scan state, which is written no earlier than the search row — so the strictly-later condition is the unpolled one. Poll on the require_scan=True form and assert both after it.

3. specs/04-testing.md makes a claim about the repo that is not true

The amended invariant 5 says of the direct-respx bodies in client_scan_test.py / async_client_test.py: "each one states in its docstring why it is not on the harness." test_async_not_found_raises, test_async_rate_limit_raises and test_async_no_results_raises (async_client_test.py:872/882/892) have no docstring at all — and they are transport-agnostic status mappings, i.e. exactly the shape the new exemption is meant to cover, so they are the ones that most need the sentence. Several others (test_async_download, test_async_context_manager) describe the scenario without arguing harness exemption. The same paragraph calls them "a handful"; there are ~27 respx bodies in that module. Either soften the claim to describe intent going forward, or add the justifications the spec now promises. Per AGENTS.md ("if a PR drifts from the spec, the spec is wrong until proven otherwise"), this is the spec asserting something the code does not honour.

4. The two new tests are long-pole but invisible to the scheduler

Both probe tests submit an artifact and wait for a scan settle plus up to 30s of index lag, but test_hash_existence_probe_against_the_real_server matches none of _LONG_POLE_FRAGMENTS in test/conftest.py:116-126hash_search does not substring-match hash_existence_probe. On the live xdist run they will backfill the tail, which is the straggler pattern that list exists to prevent. Add a fragment (existence_probe).

Minor

  • KnownGoodWithheldException.sources and the pre-existing ArtifactInstance.known_good_sources (resources.py:275) are the same concept under two names with different normalisation — the latter is sorted and deduped, the new one preserves wire order and can repeat. The tests already work around it (sorted(ei.value.sources)). Worth one sentence in specs/05-downstream-contract.md relating the two, so a consumer does not assume parity.
  • The bump decision (new subclass = minor, deferred to develop→master) matches AGENTS.md. The two new rows added to the bump-policy table are a policy edit rather than documentation of this change — flagging only so a maintainer signs off on them deliberately.

…ndex

Review follow-ups on the tests added last commit.

- test_known_good_lifecycle asserted exists() unpolled immediately after
  known_good_create, in the same PR whose sibling test explains that the search
  row is written by an async task. known_good_create returns an
  artifact_instance_id, so it goes through that same write — the assertion was a
  flake under TESTS_VCR=off by this PR's own reasoning.
- The probe test polled the BROAD form and then asserted the NARROW one.
  require_scan filters on the scan state of the row the plain form only needs to
  exist, so it can become true no earlier — polling the loose condition and
  asserting the strict one is the wrong way round. Both now poll the strict form
  and assert the captured value, which also keeps each test's request count at or
  below what its cassette recorded, so no re-record was needed.
- Registered `existence_probe` in _LONG_POLE_FRAGMENTS: both tests submit, wait
  for a settle and then tolerate index lag, but neither substring-matched, so on
  the live xdist run they would have backfilled the tail — the straggler pattern
  that list exists to prevent.
- specs/04's amended invariant 5 claimed every direct-respx body states why it is
  not on the harness. Most predate the harness and say nothing, so the claim is
  now forward-looking, and the count is honest (~27, not "a handful").
- specs/05 relates KnownGoodWithheldException.sources to
  ArtifactInstance.known_good_sources: same concept, different normalisation (the
  latter sorted and deduped), so a consumer does not assume parity.
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review

Core change is sound: KnownGoodWithheldException(NotFoundException) raised from the shared _raise_for_status 404 arm is the right place (both parse_response and _execute_download route through it), the subclass keeps invariant 3 of specs/05-downstream-contract.md, _normalise_sources guards the documented list-of-strings shape, the _bad_status_message mapping/string arms are a real bug fix with tests for all three shapes, base is develop, and no version bump — correct per AGENTS.md. KNOWN_GOOD and the {code, known_good, sources} envelope are confirmed by the committed cassettes, so the code-string mismatch the description mentions is genuinely fixed.

Three things.

1. Both new probe cassettes were recorded from an earlier version of the test body.

test/vcr/test_hash_existence_probe_against_the_real_server.vcr records the HEAD sequence

...&require_scan=false   -> 200   (line 701)
...&require_scan=false   -> 200   (line 741)
...&require_scan=true    -> 200   (line 781)

and the async twin records the same false, false, true. But the committed body polls the narrow form first and asserts the broad one once — order true, false, two calls:

for _ in range(30):
    scanned = v3api.exists(sha, hash_type='sha256', require_scan=True)
    ...
assert v3api.exists(sha, hash_type='sha256') is True

The recorded shape is poll(false) -> assert(false) -> assert(true) — exactly the 'polling the broad form and then asserting the narrow one is a race' pattern the new comment argues against. So the committed cassettes are a recording of the previous body, and the sequence the comment says is correct was never actually run live. Replay passes only because VCR matches by request identity rather than order and leaves the extra interaction unplayed.

specs/04-testing.md invariant 3 — 'Cassettes are always produced by running the test against the live stack' — wants a delete-and-re-record of both against a fresh stack. The two known_good_lifecycle cassettes do match their bodies (false then true), so only these two are stale.

2. download() — the method that actually raises the new exception — says nothing about it.

All the docstring work landed on exists(), which never raises KnownGoodWithheldException. aio/api.py:1595 download (and download_id / download_to_handle / download_sandbox_artifact) is unchanged, and the download* rows in specs/03-endpoints.md (lines 109-113) still carry only 'Closes the handle before returning.' A consumer reading the endpoint catalogue or the docstring for the refused call has no pointer to the new class. Worth a :raises: line on download in aio/api.py plus regeneration, and a note on the download row in specs/03-endpoints.md.

3. test_async_exists_maps_404_false stays async-only in the same PR that extracts the harness to avoid that.

The 404 -> False mapping is transport-independent, and this PR extracts test/_client_harness.py specifically so one respx body covers both clients. The amended invariant 5 says a new transport-agnostic respx body goes on the harness and one that stays single-transport 'should say why in its docstring' — this docstring argues why the arm is mocked, not why it is one-transport. Net effect: the sync client's exists() 404 -> False path has no direct coverage (the pure tier covers parse_response's HEAD short-circuit, not the generated int(result) == 200). Either move it onto ClientTestCase (it needs no async-specific machinery) or state the exemption.

Nothing blocking beyond (1).

sbneto added 2 commits July 29, 2026 22:48
Both were recorded before the body was restructured to poll the strict
require_scan form, so they carried the old order (false, false, true) — the
exact 'poll the broad form, assert the narrow one' race the comment above them
argues against. Replay passed only because VCR matches by request identity, not
order, and left the extra interaction unplayed. Recorded fresh against a live
stack: 2 plain + 2 require_scan, matching what the body now runs.
All the docstring work landed on exists(), which never raises it. Every
download* method can, and the endpoint catalogue said only 'closes the handle'
— so a consumer reading either had no pointer to the new exception. Adds the
:raises: (with the NotFoundException-subclass note, .sources, and the
nothing-is-written guarantee) and the catalogue rows, including that only a
dropped file can be withheld out of the sandbox-artifact route.
The mapping is transport-independent and the sync client reaches it through
the same generated `int(result) == 200`, but the test sat in the async module
— in the PR that extracted the harness so one body could cover both. Moved
onto ClientTestCase and added the require_scan form plus the 5xx case, which
is worth pinning as a recorded decision: with no error channel on a HEAD, a
server error also collapses to False, i.e. a fabricated negative.
@sbneto

sbneto commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

All three addressed.

1. Stale probe cassettes — re-recorded (d51542c). Correct diagnosis: both carried false, false, true from the body before it was restructured to poll the strict form, and replay passed only because VCR matches by request identity rather than order, leaving the extra interaction unplayed. Deleted both and recorded fresh against a live local stack (artifact-index built from its paired branch, full e2e run -pine). Each now holds exactly the four HEADs the committed body runs — 2 plain, 2 require_scan — so the sequence the comment argues for is the one that actually ran.

2. download() said nothing about the exception it raises — fixed (b85af8d). :raises KnownGoodWithheldException: on download, download_id, download_sandbox_artifact and download_to_handle in aio/api.py (regenerated mirror committed), covering the NotFoundException-subclass note, .sources, and the nothing-is-written guarantee. specs/03-endpoints.md's download rows now carry it too, plus the detail that only a dropped file can be withheld out of the sandbox-artifact route — evidence is exempt server-side.

3. The 404 arm staying async-only — fixed (9901922). Moved onto ClientTestCase as test/exists_probe_mapping_test.py, so both transports cover it (6 tests). Added the require_scan form, since a regression that dropped the HEAD short-circuit for the query-carrying form only would have passed the bare one; and a 5xx case, pinned as a recorded decision rather than an accident — with no error channel on a HEAD, a server error also collapses to False, which is artifact-index contract invariant 6's fabricated negative. specs/04 lists the module and drops the "currently the only one" claim about the harness.

163 passed.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md + specs/0105. The core change is sound: KnownGoodWithheldException lands in core._raise_for_status (Layer 1, shared by both transports — specs/01), subclassing NotFoundException satisfies the specs/05 invariant-3 compat rule, the _bad_status_message dict/list/str arms are correct and the single-call-site narrowing in specs/01/02 is accurate, _normalise_sources matches the feed[.tool.] shape used at resources.py:275, base is develop with no version bump plus new rows in the specs/05 versioning table. Live cassettes verified against the test bodies (interaction order and counts line up; the recorded 404 body confirms the real server sends the dict envelope with code KNOWN_GOOD and sources ["nsrl"]), and _execute_download does check status before open_destination, so the empty-dir assertion pins a real ordering.

Three things to fix.

1. specs/03-endpoints.mddownload_archive cannot raise this, so the blanket claim is wrong.

The new paragraph says “Every download* method can refuse with KnownGoodWithheldException, and the download_archive row still reads Same. — which, now that the sibling download_id row was changed to Same. meaning “raises it”, reads as including it. But LocalArtifact.download_archive (resources.py:638) is a GET straight at a pre-signed S3 URL with Authorization forced to None; S3 returns XML, so a 404 there hits the JSONDecodeError branch and yields a plain NotFoundException with “The requested endpoint does not exist.” It can never carry a PolySwarm KNOWN_GOOD envelope.

Notably the code already gets this right — the :raises: block was added to download, download_id, download_sandbox_artifact and download_to_handle but deliberately not download_archive. So the spec is the side that needs narrowing: scope the sentence to the four API-served routes and give download_archive its own note (off-domain S3, no error envelope).

2. test/conftest.py — the lifecycle tests now poll but still rank last in _LONG_POLE_FRAGMENTS.

This PR adds a 30x1s poll loop to test_known_good_lifecycle and test_async_known_good_lifecycle, and registers existence_probe for exactly that reason (“neither substring-matched, so on the live xdist run they would have backfilled the tail — the straggler pattern that list exists to prevent”). The same reasoning applies to the lifecycle tests, and no fragment matches them: known_good_lifecycle does not contain live (lifecycle is l-i-f-e), hash_search, sample, or any other entry, so _long_pole_rank falls through to the default rank and they backfill the tail with up to 30s of real sleep under TESTS_VCR=off. Add a known_good fragment (or lifecycle).

3. test/exists_probe_mapping_test.py:44 — the test name asserts the opposite of the test.

test_a_server_error_is_not_reported_as_absent_silently asserts self.api.exists(_SHA256) is False on a 500, and its own docstring says the point is that a 5xx does collapse to False — “a fabricated negative”. The name reads as the guarantee rather than the hazard; anyone grepping for the 5xx behaviour will conclude the opposite of what the file records. Something like test_a_server_error_collapses_to_absent_a_fabricated_negative matches the assertion.

artifact-index reworked known-good to its authoritative model (its specs/05,
DN-8425): the refusal now fires on the CURRENT understanding — a catalogue entry
for the sha256 AND that entry's extension passing an executable allow-list —
evaluated live per request. Two consumer-visible consequences, now in the
downstream contract: a refused download can start working again with no action
(entry deleted, or the policy narrows), and `ArtifactInstance.state` can carry
the new `NOT_STORED` value for a submission the server declined as known-good
whose hash is no longer currently known-good.

No code change: `state` is a plain string the SDK does not enumerate, the 404
`errors.code` strings are unchanged, and KnownGoodWithheldException keeps its
NotFoundException base. The lifecycle test already catalogues with an eligible
filename ('kg-sample.exe'), so its assertions hold unchanged.
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Solid change overall: the 404 narrowing is correctly a NotFoundException subclass (so invariant 3 in specs/05 holds), _raise_for_status is the right place for it — one arm covers both the JSON and the streaming paths, per specs/01 — the sync mirror carries the same docstrings, the errors-shape fix in _bad_status_message is a genuine bug fix with all three arms (list / mapping / string) pinned, and the new coverage is live-e2e against cassettes holding real recorded KNOWN_GOOD envelopes rather than fabricated ones. Base is develop, no version bump — both correct per AGENTS.md.

Three things to fix.

1. Internal ticket ID committed into a public spec — specs/05-downstream-contract.md:183

The new paragraph reads: ...as of artifact-index's two-predicate model (its specs/05, DN-8425)...

AGENTS.md: "Don't reference ticket IDs or internal project codes in commit messages, PR titles, or PR descriptions. This repo is public; published artefacts shouldn't leak internal references." A checked-in spec file is more permanently published than a commit subject, and this PR's own merge note argues the branch name must not reach history for exactly that reason. Drop the ID — the artifact-index specs/05 pointer alone carries the reference.

2. specs/03-endpoints.md over-claims the refusal for download_archive

The new prose says every download* method can refuse with KnownGoodWithheldException, and the download_archive row's "Same." now reads as inheriting that too.

download_archive is not an artifact-index call. resources.py:644-657 builds it against the caller-supplied S3 URL with the Authorization header suppressed — the stream() feed's pre-signed object-store URL (the cassettes show http://minio:9000/...). It can never carry artifact-index's coded error envelope; an S3 error body is XML, so it lands in the JSONDecodeError branch of _raise_for_status and surfaces as a plain NotFoundException / RequestException.

The code has this right — download_archive is the one download* method that got no :raises docstring. So it is the spec that drifted. Scope the claim to the four artifact-index-served downloads (download, download_id, download_to_handle, download_sandbox_artifact) and restore that row's "Same." to referring only to handle-closing.

3. Test name asserts the opposite of its body — test/exists_probe_mapping_test.py

test_a_server_error_is_not_reported_as_absent_silently mocks a 500 and then asserts self.api.exists(_SHA256) is False. The body pins that a 5xx does collapse to False — i.e. it is silently reported as absent — and the inline comment says exactly that ("a fabricated negative"). Anyone grepping test names for this probe's guarantees reads the inverse of the recorded decision. Rename to something like test_a_server_error_also_collapses_to_absent.

Nothing else. errors extraction stays dict-guarded; the KNOWN_GOOD branch leaves every other 404 (different code, legacy list, no errors at all) on the base class and that half is asserted live too; _normalise_sources never hands back a non-list; the .sources-vs-known_good_sources asymmetry is documented rather than accidental; the harness extraction is behaviour-preserving apart from the intended calls[0] to calls[-1] fix (no existing user is multi-request); and the version-bump table additions match what the change actually does.

Review fixes, plus one drift the review missed:

- Drop the internal ticket reference from the downstream-contract spec — this
  repo is public, and the artifact-index specs/05 pointer alone carries it.
- specs/03 claimed every download* method can raise KnownGoodWithheldException.
  download_archive cannot: it fetches the caller-supplied pre-signed object-store
  URL from the stream() feed (Authorization suppressed), so an error there is the
  store's own XML body — no coded JSON envelope — and surfaces through the
  generic arms. The code had this right (it is the one download* method with no
  :raises docstring); the spec drifted. Scoped the claim to the four
  artifact-index-served downloads and gave the archive row its own honest note.
- The download_sandbox_artifact row still described the retired sample-vs-evidence
  exemption; the server-side model gates every sandbox artifact by its own sha256.
- Renamed test_a_server_error_is_not_reported_as_absent_silently — its body pins
  the opposite (a 5xx does collapse to a fabricated negative, as a recorded
  decision), so anyone grepping test names read the inverse of the guarantee.
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review

Code side is clean: _raise_for_status reads errors after _extract_json_body populates it, so both the JSON path and _execute_download (aread → shared mapper) hit the same 404 arm; the subclass keeps except NotFoundException working per specs/05-downstream-contract.md invariant 3; the _bad_status_message dict/str arms are correct and only reachable from the RequestException branch, which spec 01 now states explicitly. Base is develop, no version bump — both per AGENTS.md. Cassettes carry the real coded envelope and the recorded poll counts match what the bodies replay.

Four things to fix, all documentation:

  1. download_sandbox_artifact: the spec and the shipped docstring say opposite things.
  • specs/03-endpoints.md:111 — "the gate applies to every sandbox artifact by its own sha256 (dropped file, screenshot, report, …); the server-side model has no sample-vs-evidence carve-out."
  • src/polyswarm_api/aio/api.py:1644 (and the mirror at api.py:1988) — "Sandbox evidence (report / raw_report / screenshot / recording / pcap / memory_dump) is exempt from the known-good policy server-side, so in practice only a dropped file raises below."

Only one can be true, and the docstring is the surface a consumer actually reads: if evidence is not exempt, that docstring tells callers not to handle a refusal they will get. Pick one against the real server behaviour and make the two agree (both mirrors + the spec row).

  1. state == KNOWN_GOOD is described as the withheld signal, but this PR also introduces NOT_STORED.

specs/02-resources.md:306 — "state == KNOWN_GOOD … is also the signal that the artifact bytes are withheld … there is deliberately no separate withheld field" (same claim at specs/05-downstream-contract.md:181). But the paragraph added just below in spec 05 says NOT_STORED means "nothing was ever stored for it" and that its hash is no longer currently known-good — so such an instance has no bytes either, and its download 404s without the KNOWN_GOOD code, i.e. a plain NotFoundException. A consumer following spec 02 literally (branch on state to predict availability) gets NOT_STORED wrong. Qualify it: KNOWN_GOOD is the signal for the typed refusal, not for "bytes are unavailable" in general.

  1. src/polyswarm_api/resources.py:284-286 — orphaned comment fragment. The rewrite left "# additive alongside the numeric bounty_state. Optional — older servers omit it…" stranded after a blank comment line as a lowercase fragment; it used to continue the "Friendly bounty-state NAME (…)," sentence.

  2. PR description names the internal ticket code. DN-8425 v2 appears twice in the body. CLAUDE.md: "Do not reference ticket IDs or internal project codes in commit messages, PR titles, or PR descriptions. This repo is public." The commits are clean — only the description needs an edit.

Nothing blocking on tests: the live refusal + probe coverage is the right tier per specs/04-testing.md invariant 1, and the 404 / 5xx arms on the shared harness are justified in the module docstring.

… withheld signal

- download_sandbox_artifact's docstring (both mirrors) still described the
  retired server-side sample-vs-evidence exemption, telling callers not to
  handle a refusal they will get; specs/03 already had it right. The gate
  applies to every sandbox artifact by its own sha256.
- specs/02 and specs/05 called state == KNOWN_GOOD "the signal that bytes are
  withheld" in general. Qualified: it signals the *typed refusal*; a NOT_STORED
  instance has no bytes either but 404s plainly, because nothing is being
  withheld by policy any more. A consumer branching on state to predict
  availability would have gotten NOT_STORED wrong.
- resources.py: rejoined a comment fragment the rewrite orphaned.
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/0105. Verified: base is develop, no version bump (correct per AGENTS.md — and the new bump-policy rows in specs/05 classify this as minor for the develop→master step), commit messages carry no ticket IDs, the sync mirror in api.py carries the same additions as the canonical aio/api.py, and the new/updated cassettes match the committed test bodies interaction-for-interaction and in order (test_known_good_lifecycle.vcr records the two refusals with sources: ["nsrl"] then ["commercial","nsrl"], and the probe cassette holds real 200/204 HEADs — not fabricated bodies).

Spot checks that hold up:

  • The KnownGoodWithheldException subclassing keeps specs/05 invariant 3 — existing except NotFoundException still catches, and it is reachable as polyswarm_api.exceptions.KnownGoodWithheldException via the package's from . import exceptions.
  • The "nothing is written" guarantee is real: _execute_download checks status_code // 100 != 2 and calls _raise_for_status before open_destination (aio/session.py:146), so the sync mirror inherits it too. download_archive correctly stays outside the typed refusal — resources.py:643 sends Authorization: None to the object store, and its XML error body takes the JSONDecodeError arm.
  • Dropping the respx exists 200/204 test does not lose the 4.0-inversion guard offline: the lifecycle cassette replays 200 → True and 204 → False, so a return to // 100 == 2 still fails on a cassette-only run. The new exists_probe_mapping_test.py arms (404, 5xx) would not have caught it alone, which the module docstring says.
  • _normalise_sources order-preserving-and-possibly-duplicating divergence from ArtifactInstance.known_good_sources (sorted, deduped) is a real behavioural difference and is explicitly called out in specs/05 rather than left implicit.

One thing to fix:

download_to_handle docstring is inaccurate about why nothing is written. aio/api.py:947 (and the mirror at api.py:1147) ends the shared :raises: block with "the status is checked before the file is opened". For download_to_handle the destination is the caller's already-open fh — the SDK never opens it, so the stated reason is wrong on the one method where a reader is most likely to want the guarantee restated. The guarantee itself holds (the status check precedes any handle.write); it is the justification that is copy-pasted from the folder-destination methods. Suggest "nothing is written to the destination — the status is checked before any write" for this method (or for all five, since it is accurate everywhere).

Nothing else needs action.

The shared :raises block said 'the status is checked before the file is
opened' — copy-pasted from the folder-destination methods. download_to_handle
never opens anything: the handle is the caller's and arrives open. The
guarantee holds (the status check precedes any write); only the justification
was wrong, on the one method where a reader most wants it restated.
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/0105. Clean — no actionable findings.

Verified: the 404 arm reads request.errors only after _extract_json_body populates it, so both the buffered (parse_response) and streaming (_execute_downloadaread()_raise_for_status) paths reach the subclass identically; _bad_status_message has the single call site the new spec/01 text claims, so the dict/list/str branching only affects the RequestException arm; download_archive is correctly excluded (off-domain, XML body); exists() is unaffected (HEAD short-circuits before the mapping) and the docstring's 200/204/404 claims match int(result) == 200 and the recorded cassettes (require_scan=false/true on the wire matches the exists_probe_mapping_test URL assertions); the sync mirror's added lines are the unasync-identical twin of aio/api.py's; base is develop with no version touched, correct per invariant 6 for an additive-subclass (minor) change; and the new cassettes' interaction counts/order line up with each test's call sequence, so the poll loops replay deterministically under allow_playback_repeats=False.

@sbneto
sbneto merged commit f791724 into develop Aug 3, 2026
2 checks passed
@sbneto
sbneto deleted the dn-8425-known-good-status-signal branch August 3, 2026 15:40
@claude claude Bot mentioned this pull request Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants