Skip to content

Expose matched strings on hunt-result resources - #322

Merged
kyle-buchmiller merged 16 commits into
developfrom
DN-8378-yara-matched-strings
Sep 10, 2026
Merged

kyle-buchmiller merged 16 commits into
developfrom
DN-8378-yara-matched-strings

Conversation

@kyle-buchmiller

@kyle-buchmiller kyle-buchmiller commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR

  • LiveHuntResult.matched_strings / HistoricalHuntResult.matched_strings — the yara strings behind a hunt hit, so a caller can see why a rule fired rather than only which one did.
  • matched_strings_dropped — a sibling count of strings the server withheld past a size limit, so a truncated list can't be read as complete.
  • Their …List subclasses inherit both, so all four hunt-result classes are covered.
  • Read with .get(), never a subscript: both keys are additive, so a server predating them omits them and a subscript would raise on every result.
  • Specs updated; tests are pure-unit plus live-e2e assertions pinned by re-recorded cassettes.

Three states, and consumers must not collapse them

Value Meaning
None Not reported — an older server, evidence since removed, or a list endpoint (those send an explicit null rather than fetch a blob per row). We don't know, not there was nothing.
[] The rule matched and there is no byte evidence to show: no strings: section, all-private strings, or a match on absence.
[…] The evidence — a lower bound, not a match count.

Each entry is {'offset', 'identifier', 'length', 'data', 'truncated'}. data is kept exactly as yara rendered it (hex strings as byte pairs, text as ASCII with escapes) because only yara knows which applies. length is the stored length, capped server-side, and truncated means "there was more than this" rather than an exact size.

Why it's a lower bound

any of them prints only the strings that hit; private strings never appear; and the server's byte budget may withhold the tail — which is what matched_strings_dropped reports.

Note this list previously included "fast-scan reports one offset per string". That is false and has been removed from the spec: measured against yara 4.5.2, -f collapses repeats of a single string only when the rule's condition doesn't need them ($a over ten hits reports 1, #a > 3 reports all 10), and it never limits how many distinct strings a rule reports.

matched_strings_dropped

A sibling int/None, not a key inside matched_strings — that stays a plain list, so this is additive to the shape rather than a change to it. It exists because a truncated list is otherwise indistinguishable from a complete one: a caller reading twelve entries would conclude the rule hit twelve times when it hit thirty-one. It can never accompany an empty list, since a match's first string is never withheld.

Evidence rides on the detail routes only

live_feed() and historical_results() page over list endpoints and always yield None for both fields — reading the payload is a per-row blob fetch on the server. Fetch a single result (live_result(id) / historical_result(id)) to get the strings.

Notes for review

  • No change to aio/api.py or the generated sync mirror — this is entirely in resources.py, which is hand-written, so the codegen-staleness check is not involved.
  • No pyproject.toml version bump, per the gitflow rules.
  • Live-e2e assertions, not just pure-unit. test_live / test_async_live assert the detail route carries evidence, that list rows don't, and that the server actually serves matched_strings_dropped — that last one asserts on result.json rather than the attribute, because is None can't tell a served null from an absent key. Cassettes re-recorded delete-driven against a stack running the matching server branch.
  • The pure-unit tier covers all four classes and pins that an absent key and an explicit null both read as None while [] stays [].

Requires

This is third of four in one change set:

  1. the analyzer-side producer change — emits the yara strings everything else carries
  2. the server change — persists them and serves both fields; this PR's cassettes were
    recorded against it
  3. this PR
  4. the CLI client that renders the block

Merging ahead of 1 and 2 leaves test_live asserting a field nothing serves. That reddens
develop's e2e, which gates the release stage — so :latest stops being promoted and
every downstream pipeline keeps testing a stale SDK, silently.

A hunt result says which rule matched and its tags, never why. The server
now returns the yara strings behind a hit; parse them onto LiveHuntResult
and HistoricalHuntResult, and so onto their list subclasses.

Read with .get() rather than a subscript. The key is additive, so a server
predating it omits it entirely and a subscript would raise on every result.

Three states reach callers and they are not interchangeable: None (not
reported -- an older server, removed evidence, or a list endpoint, which
omits it rather than fetch a blob per row), [] (the rule matched with no
byte evidence to show), and a populated list (the evidence, as a lower
bound rather than a match count).
Pure-unit: the resources parse a dict, so no HTTP and no stack is involved.

Covers all four affected classes -- the list subclasses inherit __init__ and
must behave identically -- and asserts the states stay distinguishable: an
absent key and an explicit null both read as None, while an empty list stays
an empty list rather than collapsing into it. Entries pass through verbatim,
since only yara knows whether `data` is a hex dump or escaped text.
Records what each state means, the per-entry dict shape, and the two
properties consumers get wrong: a populated list is a lower bound rather
than a match count, and `truncated` means "there was more than this" rather
than an exact size.

Also states that the evidence rides on the detail routes only -- the
paginated feed methods will always yield None -- so a caller looking for
strings knows to fetch a single result.
Comment text only -- the parsed AST is identical before and after. Both copies
edited identically, since the two resource classes carry the same note.

Keeps what the comment exists for: why .get() and not a subscript, and that
the three states are distinct.
@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md, specs/02-resources.md, specs/04-testing.md, specs/05-downstream-contract.md.

The code is correct: .get() on both hunt-result __init__s, both …List subclasses inherit, no aio/api.py/api.py change so the codegen-staleness check is genuinely uninvolved, no version bump (right — that belongs to the develop → master step), base is develop, commit messages carry no ticket IDs. Two things to action.

1. No live-e2e assertion pins the server shape (specs/04-testing.md inv. 1). The suite is pure-unit only, and neither test/vcr/test_live.vcr nor test/vcr/test_async_live.vcr contains the string matched_strings — so nothing in the repo asserts that the detail route actually emits the key, or that the list route omits it. The unit tests exercise dict.get; they'd pass identically if the server never grew the field and the whole attribute were a permanent None. That's precisely the gap invariant 1 is about ("a fabricated response asserts what we think the server returns; a cassette asserts what it actually returned"), and the canonical split named in the spec is a live-e2e test plus pure-unit — cf. test_known_good_lifecycle + test/known_good_test.py, the precedent this change otherwise mirrors.

The hook already exists and costs one line each: test_live (client_scan_test.py:496) and test_async_live (async_client_test.py:723) both already poll live_result(result_id) on a rule that matched an EICAR variant. Add there:

assert result.matched_strings, 'detail route should carry the yara evidence'
assert my_results[0].matched_strings is None, 'list rows omit the evidence'

then re-record per the delete-driven workflow (rm test/vcr/test_live.vcr && pytest test/client_scan_test.py::…::test_live against a stack running the matching server branch). That also converts the PR-description claim "exercised end to end against a locally running stack with a matching server branch" into something the suite keeps honest — and specifically pins the list-vs-detail split, which is currently an assertion made only in prose. Same idea for the historical side if there's an equivalent live path.

2. specs/02-resources.md isn't updated. That spec is scoped as the thing to read before "changing a resource's parsing", and it's where the two closest precedents live — §"Per-resource notes" → ArtifactInstance documents known_good / known_good_sources and state as additive .get()-parsed fields, in exactly this situation. matched_strings gets no note there, so a reader following the spec index to the resources spec won't find it. AGENTS.md: "Update the spec in the same PR as the code change." A short HistoricalHuntResult / LiveHuntResult note pointing at the three-state table in 05-downstream-contract.md is enough — no need to restate the table.

Nothing else. The three-state framing in 05-downstream-contract.md is well drawn, and calling […] a lower bound rather than a match count is the right thing to say out loud.

The suite was pure-unit only, so nothing asserted that the detail route
actually emits the key or that the list route omits it. Those tests exercise
dict.get and would pass identically if the server never grew the field and
the attribute were a permanent None -- which is the gap the e2e-first
invariant exists to close: a fabricated response asserts what we think the
server returns, a cassette asserts what it actually returned.

test_live and test_async_live already poll the detail route on a rule that
matched their own artifact, so the assertions cost two lines each and pin the
list-vs-detail split that was previously stated only in prose.

Cassettes re-recorded delete-driven against a live stack running the matching
server and analyzer branches. Both now carry real evidence -- the per-test
rule keys on the test's uid, so the recorded hit is `$u` at offset 69 with
the uid's own length -- and null on the list rows.

Verified the assertions are load-bearing: against the previous cassettes they
fail with "detail route should carry the yara evidence / assert None".
The resources spec is what to read before changing a resource's parsing, and
it carries the two closest precedents -- known_good / known_good_sources and
state are both documented there as additive, .get()-parsed fields in exactly
this situation. matched_strings had no note, so a reader following the spec
index would not find it.

Points at the three-state table in the downstream-contract spec rather than
restating it, and records the part a parser needs up front: which value means
"not reported" versus "matched with no evidence", and that the list endpoints
always yield the former by design.
@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md + specs/02-resources.md, specs/04-testing.md, specs/05-downstream-contract.md. The change itself is clean: .get() on an additive key matches the known_good / state precedent, resources.py is hand-written so no codegen mirror is involved, no version bump (correct per gitflow), base is develop, cassettes were freshly re-recorded (the detail route carries matched_strings, the list rows carry null) rather than hand-edited, and the pure-unit tier fits specs/04-testing.md for a parse-only change.

Two things worth fixing:

1. Spec says the list route omits the key; the recorded server actually sends an explicit null.

specs/05-downstream-contract.md:284 — "this came from a list endpoint — those omit it rather than fetch a blob per row" — and specs/02-resources.md:341 — "a list endpoint, which omits it rather than fetch a blob per row".

The re-recorded cassettes disagree: every /hunt/live/list row in test/vcr/test_live.vcr / test_async_live.vcr contains "matched_strings":null. The PR's own test docstring states it correctly — test_explicit_null_parses_as_none: "List endpoints send the key with a null value rather than omitting it" — so the specs contradict the test in the same PR. Both read to None either way, but "omit" is the load-bearing word in the paragraph explaining why .get() is used, and a downstream reader who takes it literally will conclude a key-presence check ('matched_strings' in result.json) distinguishes list rows from detail rows. It does not. Reword both to "sends it as null" and keep "omits the key" for the old-server case only, which is the case .get() actually protects against.

2. assert result.matched_strings is not polled, unlike the field next to it.

test/client_scan_test.py:507 / test/async_client_test.py — the preceding download_url assertion needed a 20-iteration poll because it lands asynchronously; matched_strings is asserted on that same result object with no poll of its own. Under VCR it always passes, so the cassette hides any latency here. If the server writes the evidence blob on the same transaction as the match row this is fine and no change is needed — but if it lands on a separate write, the e2e job (TESTS_VCR=off) is where it will flake, intermittently and only in CI. Worth confirming against the server branch; if it is a separate write, fold it into the existing download_url poll condition rather than adding a second loop.

Minor / non-blocking: the branch name DN-8378-yara-matched-strings carries an internal ticket ID. The commit messages, PR title and body are all clean, but a default merge commit ("Merge pull request #322 from polyswarm/DN-8378-...") would put it into this public repo's history, which is what AGENTS.md's "Don't reference ticket IDs or internal project codes in commit messages" is guarding. Squash-merge with a hand-written subject, or edit the merge message.

Nothing blocking on correctness, contract shape, or gitflow.

`matched_strings_dropped` on both hunt-result resources, inherited by their list
subclasses. The server bounds how many matched-string bytes one result may carry,
and a truncated list is otherwise indistinguishable from a complete one: a caller
reading 75 entries concludes the rule hit 75 times when it hit 400.

A sibling attribute rather than a key inside `matched_strings`, which stays a
plain list -- so this is additive to the shape already documented, not a change
to it. Parsed with .get() like the field beside it: None means nothing was
withheld, which is also what a server predating the bound reports, so callers
need no special case for the older shape. It can never accompany an empty list,
since a match's first string is never withheld.

Also corrects the downstream contract, which told consumers fast-scan reports
only the first offset per string and that this was one reason the list is a
lower bound. Measured against yara 4.5.2 that is false -- fast mode collapses
repeats of a single string only when the rule's condition does not need them,
and never limits how many distinct strings a rule reports. The remaining reasons
are sound and the byte bound is now a fourth.

195 tests pass.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Review

Core change (.get()-parsed additive attribute on LiveHuntResult / HistoricalHuntResult, inherited by the …List subclasses) is correct, matches the known_good / state precedent in specs/02-resources.md, targets develop, and correctly leaves pyproject.toml alone (minor bump belongs to the develop → master step per specs/05-downstream-contract.md §Versioning, invariant 6).

Three things need action.

1. matched_strings_dropped has no server evidence — and the PR applies its own standard unevenly

The cassettes were re-recorded in 74d0da95; matched_strings_dropped was added two commits later in 4d57f660. Neither cassette contains the key anywhere — grep -c matched_strings_dropped test/vcr/test_live.vcr test/vcr/test_async_live.vcr returns 0 for both. Not even as an explicit null on the detail route, where matched_strings is populated (test/vcr/test_live.vcr:636). So the recorded server — the one described as "a matching server branch" — does not emit this key at all.

That makes 74d0da95's own commit message the argument against the field as it stands:

Those tests exercise dict.get and would pass identically if the server never grew the field and the attribute were a permanent None — which is the gap the e2e-first invariant exists to close.

That is exactly the state matched_strings_dropped is in. specs/04-testing.md invariant 1 makes VCR-backed live-e2e the default for anything mirroring a server field, and specs/05-downstream-contract.md §Versioning classes it as "New field on a resource (mirror of a new server-side field)" — the parenthetical is doing work here.

Please confirm artifact-index emits that exact key, and if so re-record the cassettes against that build so at least the null arm is pinned. If it does not emit it yet, drop the attribute and its spec section and land them alongside the server change.

2. Both specs describe the list-route wire shape incorrectly

specs/02-resources.md and specs/05-downstream-contract.md both say the list endpoints "omit it rather than fetch a blob per row". The cassettes this PR records say otherwise — list rows carry the key with an explicit null (test/vcr/test_live.vcr:594: ..."malware_family":"EICAR","matched_strings":null,"md5":...).

test/hunt_matched_strings_test.py::test_explicit_null_parses_as_none documents the real behaviour ("List endpoints send the key with a null value rather than omitting it") and directly contradicts both specs. No functional impact — .get() handles both — but the specs are authoritative and currently wrong about the shape. Reword to: the list route sends the key as null; omitted is the older-server case.

Related, in the matched_strings_dropped section of 05: "None means nothing was withheld" is stated flatly, while the section immediately above is careful that None on matched_strings means "we don't know", not "there was nothing". Same conflation — and given item 1 it is None unconditionally today.

3. PR description is stale against the final diff

The TL;DR still asserts "Fast-scan reports one offset per string" — a claim 4d57f660 removed from specs/05 as false against yara 4.5.2 (grep -rn "fast.scan" specs/ is now empty). The description also never mentions matched_strings_dropped, which is the majority of the new spec text and the subject of the last commit. Worth refreshing before merge, since reviewers read the body first.

Minor

  • specs/04-testing.md §Files does not list test/hunt_matched_strings_test.py. (The inventory is already missing known_good_test.py, so this is pre-existing drift, not a regression.)

Checked and fine

  • No aio/api.py / api.py change, so no codegen-staleness concern — correct, resources.py is hand-written.
  • E2e assertions land after the download_url poll on a per-test ruleset keyed to the test's own uid, so the recorded $u hit is deterministic and xdist-safe; the list-vs-detail split is genuinely pinned rather than asserted in prose.
  • Cassettes are delete-driven re-recordings against a live stack (fresh ids, fresh Date headers, known_good / state present on instance payloads), not copies of the sibling — both sides recorded independently, per specs/04-testing.md invariant 3.
  • Pure-unit tier covers all four classes including the …List subclasses; _content()'s issubclass(cls, LiveHuntResult) branch routes HistoricalHuntResultList correctly.
  • HistoricalHuntResult.matched_strings has no e2e assertion, but test_historical_results documents that the e2e historical scan does not reliably populate results within a test window — defensible.

…e wire shape

The cassettes were recorded before this attribute existed -- two commits before
-- so neither carried the key. The pure-unit tests exercise dict.get and would
have passed identically if the server never emitted it, which is the exact gap
the earlier commit here argued the e2e-first invariant exists to close. Applied
unevenly is worse than not applied.

Re-recorded delete-driven against a stack running the matching server branch.
The assertion reads `'matched_strings_dropped' in result.json` rather than
checking the attribute for None, because `is None` cannot distinguish a served
null from an absent key -- and what needs pinning is that the server SENDS the
field. Its value is null there: the per-test rule is small and withholds
nothing, so the null arm is what a passing hunt actually looks like.

Also corrects both specs, which said list endpoints OMIT the key. They send an
explicit null; omitted is the older-server case. That wording survived from a
design that was reverted, and the recorded cassettes disagreed with it. And in
the new section, "None means nothing was withheld" was stated flatly while the
section above it is careful that None on matched_strings means "we don't know" --
the same conflation, now reading the same way in both places.

195 tests pass.
@claude

claude Bot commented Aug 29, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md, specs/02-resources.md, specs/04-testing.md, specs/05-downstream-contract.md. Base is develop, no version bump, no codegen involved (resources.py is hand-written) — gitflow is clean. Cassettes check out: both test_live.vcr and test_async_live.vcr carry a populated matched_strings on the detail row and an explicit null on the list rows, so the assertions added in commits 5 and 8 are load-bearing.

Four things to fix.

1. Commit 8's matched_strings_dropped correction landed in only one of the three places that state it

Commit 8 rewrote specs/05-downstream-contract.md:305-308 to say None is ambiguous:

None carries the same ambiguity as matched_strings itself … on a list route it means the route did not look, and on an older server it means the field did not exist. It is not a claim that the evidence is complete.

The flat claim it replaced is still live in two other spots, both introduced by commit 7 and untouched by 8:

  • specs/02-resources.md:345-346 — "None means none were, which is also what a server predating the budget reports."
  • src/polyswarm_api/resources.py:775-777 and :833-835 — "or None when nothing was dropped (also what every result predating the budget reports)."

That is the exact conflation commit 8's own message calls out — except it is not, in fact, "now reading the same way in both places." A reader following the spec index hits 02-resources.md first, which is the spec that states what a parser needs up front, and it currently says the opposite of 05. Worse, 02-resources.md:350-352 says the list values are "always None by design" three lines below telling you None means nothing was withheld — so for a list row the doc simultaneously asserts "nothing withheld" and "we did not look."

Fix spec 02 and both code comments to match the corrected 05 wording.

2. test_dropped_is_independent_of_the_strings_list never asserts the thing it is named for

test/hunt_matched_strings_test.py:104-110:

result = cls(_content(cls, matched_strings=_STRINGS, matched_strings_dropped=19))
assert isinstance(result.matched_strings, list)
assert len(result.matched_strings) == 2

The docstring says "the count is what says it is short," but nothing reads matched_strings_dropped. Drop the matched_strings_dropped=19 kwarg and the test passes identically — it duplicates test_populated_list_is_passed_through_verbatim. Add assert result.matched_strings_dropped == 19 so the pairing (populated list + non-null count coexisting, per 05-downstream-contract.md:311-313) is actually pinned.

While in there: test_raw_json_still_carries_the_key is tautological — BaseJsonResource.__init__ does self.json = content (core.py:442), so it asserts a dict equals itself. Harmless, but it does not cover what its docstring claims.

3. specs/04-testing.md module inventory is now stale

Lines 21-27 enumerate every test module and its tier. test/hunt_matched_strings_test.py is a new pure-unit module and is not in the list. AGENTS.md ("Update the spec in the same PR as the code change") puts that in scope here — add a line alongside known_good_test.py / jmespath_test.py.

4. HistoricalHuntResult gets no live pinning, but the specs speak for it

Both new spec sections assert the contract for "all four classes"; the e2e assertions cover only the live pair. test_historical_results (client_scan_test.py:567) tolerates an empty result set by design, so nothing verifies that the historical detail route emits either key, or that /hunt/historical/results/list sends the explicit null.

Not a blocker — the stack limitation is real and pre-existing. But commit 5's own argument is that a fabricated response only asserts what we think the server returns, and the specs currently state the historical half as established fact. Either soften them to say the historical routes follow by symmetry with live rather than being pinned, or record the gap in specs/99-open-questions.md.

Minor

The branch name carries a ticket ID. AGENTS.md scopes the prohibition to commit messages, PR titles and descriptions — all three are clean here — but a GitHub merge commit embeds the branch name, so it reaches public history anyway. Worth a squash-merge with a hand-written subject.

… their claims

The previous commit said the withheld-count conflation now "reads the same way
in both places". It did not: the flat claim -- None means nothing was withheld --
was corrected in one spec and left standing in specs/02-resources.md and in both
copies of the resources.py comment. specs/02 contradicted itself four lines
apart, telling a reader None means nothing was withheld and then that the list
endpoints always send None by design, so for a list row it asserted both
"nothing withheld" and "we did not look". All three now carry the same reading.

Two tests did not test what they were named for:

- test_dropped_is_independent_of_the_strings_list passed a count and never read
  it -- removing the kwarg left it passing identically, duplicating the test
  above it. Renamed for the property it actually pins and now asserts the count,
  so the populated-list-plus-count pairing is covered.
- test_raw_json_still_carries_the_key compared a dict to itself: __init__ does
  self.json = content, so the assertion could not fail. Replaced with the
  property that could -- parsing must not drop keys from .json, and the parsed
  attributes must agree with the raw payload.

Also records what is and is not pinned against a real server. The live pair is
verified end to end; the historical pair follows by SYMMETRY, because the e2e
stack does not reliably populate historical results inside a test window. Both
specs stated the contract for "all four classes" as established fact, which is
the same "asserts what we think the server returns" problem the e2e-first
invariant exists to prevent. Softened, with the gap recorded in
99-open-questions.md alongside what would close it.

specs/04-testing.md's module inventory gains this module, and known_good_test.py
which was already missing.

195 tests pass.
@claude

claude Bot commented Aug 29, 2026

Copy link
Copy Markdown

Review

Parsing change itself is clean: both fields are additive, read with .get(), no signature or transport surface touched, no codegen involved (resources.py is hand-written), no pyproject.toml bump, base is develop. Cassettes are delete-recorded, shrunk only by fewer poll iterations, and both carry the real matched_strings / matched_strings_dropped payloads. Three notes, none blocking correctness.

1. Test coverage — the per-entry dict shape is contract but is only pinned by a fixture. specs/05-downstream-contract.md documents {'offset', 'identifier', 'length', 'data', 'truncated'} and that data is passed through exactly as yara rendered it. The only assertion on that shape is test_populated_list_is_passed_through_verbatim, which asserts against _STRINGS — a hand-written dict, i.e. what we think the server sends (the thing invariant 1 in specs/04-testing.md exists to avoid). Both cassettes already contain a real entry ({"data":"test_live","identifier":"$u","length":9,"offset":69,"truncated":false}), so the live tests can pin it for free — e.g. alongside the existing assert result.matched_strings in client_scan_test.py:507 / async_client_test.py:734:

assert set(result.matched_strings[0]) == {'offset', 'identifier', 'length', 'data', 'truncated'}

Without that, a server-side rename of a per-entry key passes the whole suite VCR-off.

2. Spec drift (minor) — LiveHuntResultList is never a response parser, so the class framing misleads. specs/02-resources.md:355-357 ("Both …List subclasses inherit these … but on the list endpoints the values are always None") and the hunt_matched_strings_test.py docstring ("their list-endpoint subclasses") both imply class ↔ route. For the live pair that isn't so: live_feed() paginates resources.LiveHuntResult.list(...) (aio/api.py:517), which hits /hunt/live/list but parses rows as LiveHuntResult — already recorded in specs/03-endpoints.md:189. LiveHuntResultList is only ever used as a delete builder (aio/api.py:532) and is never instantiated from a response. Only historical_results() actually yields …List instances. So a LiveHuntResult with matched_strings is None may well have come from the list route — worth one sentence, since the whole point of the section is that None is ambiguous. (The parametrised unit case over LiveHuntResultList is harmless, just covering a class no response produces.)

3. Merge ordering. The new live assertions fail VCR-off until artifact-index#1949 is in the image the e2e job resolves — AGENTS.md ("New endpoints land there first; the SDK PR comes after") plus the invariant that every test passes against the live stack with VCR off. Already noted under ## Requires; flagging only so this doesn't merge to develop ahead of the server side and red the e2e pipeline every other repo tests against.

Nothing else: the .get()-vs-subscript reasoning, the three-state pure-unit matrix, the 'matched_strings_dropped' in result.json assertion (served-null vs absent key — the right call), and the honest 99-open-questions.md entry about the historical pair all hold up.

…oute framing

The per-entry dict shape is contract -- specs/05 documents offset, identifier,
length, data and truncated -- but the only assertion on it compared against a
hand-written fixture, which is what we THINK the server sends rather than what it
does. A server-side key rename would have passed the whole suite VCR-off. Both
cassettes already carry a real entry, so the live tests now assert the key set
against the recorded response, which costs nothing and closes the gap.

Separately, the spec framed the `…List` subclasses as the list-route parsers.
That is not true of the live pair: live_feed builds its request with
LiveHuntResult.list(...), which hits /hunt/live/list but parses rows as
LiveHuntResult. LiveHuntResultList is only ever a delete builder and is never
instantiated from a response; only historical_results yields …List instances.

The framing matters rather than being pedantry: the section exists to say that
None is ambiguous, and a reader who takes the class as the route concludes a
LiveHuntResult must have come from the detail route and reads its None as
"nothing to show". Corrected in the spec and in the test module's comment.

195 tests pass.
@claude

claude Bot commented Aug 30, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md, specs/02-resources.md, specs/04-testing.md, specs/05-downstream-contract.md.

The code change itself is clean: two .get()-parsed additive fields per class, resources.py only (no codegen involvement), no version bump (correct — specs/05 §Versioning puts "new field on a resource" at minor, taken at the develop → master step), base is develop, commit messages carry no ticket IDs or AI trailers. Cassettes are genuinely re-recorded (fresh UA 4.3.0, fresh ids) and do carry matched_strings populated on the detail row and null on the list rows, so the new live assertions are load-bearing rather than vacuous.

One thing to fix:

specs/02-resources.md:362 and test/hunt_matched_strings_test.py:40 state something false about …List instantiation.

LiveHuntResultList is only ever a delete builder and is never instantiated from a response; only historical_results() yields …List instances.

The first clause is right, the rest isn't. live_feed_delete() calls _single(resources.LiveHuntResultList.delete(...)) (aio/api.py:532), and _build_request sets result_parser=cls (core.py:621). DELETE /hunt/live/list returns a paginated body, so _single → _consume_results parses those rows into LiveHuntResultList instances. Your own new cassette shows it — test/vcr/test_live.vcr:751, the delete response, rows carrying "matched_strings":null,"matched_strings_dropped":null. Same for historical_results_delete via HistoricalHuntResultList.delete.

This isn't pedantry given what the section is for. The paragraph exists to stop a reader inferring the route from the class, and it currently teaches the inverse error: a caller who iterates live_feed_delete()'s return value gets LiveHuntResultList objects whose matched_strings is None, and the spec tells them …List only ever comes from historical_results(). The delete responses are a fourth source of None that neither the §"three-state" table in 05-downstream-contract.md nor the resources.py comments list — worth adding, since it's the one a consumer is most likely to hit while holding the wrong mental model.

Suggested correction: "LiveHuntResultList is only ever a delete builder — but delete responses are parsed through it, so live_feed_delete() / historical_results_delete() yield …List instances with both fields None. Only historical_results() yields …List instances from a read."

Nothing else. The test/known_good_test.py line added to the specs/04 inventory is unrelated to this PR but the file does exist and was missing — fine to keep.

The previous commit corrected the class-to-route framing and got the
replacement wrong. It said LiveHuntResultList "is never instantiated from a
response; only historical_results() yields ...List instances". Both halves are
false: _build_request sets result_parser=cls, so the paginated body returned by
DELETE /hunt/live/list is parsed through LiveHuntResultList. The cassette
re-recorded in this PR contains exactly that exchange.

That makes delete responses a FOURTH source of None -- alongside an older
server, a list route and removed evidence -- and none of the three places that
enumerate the causes listed it. A caller iterating live_feed_delete() holds
...List objects with both fields None while the spec told them that class only
comes from historical_results(), which is the worst combination: an ambiguous
value plus a mental model that resolves it wrongly.

Corrected in specs/02, the three-state table in specs/05, and both copies of
the resources.py comment.

195 tests pass.
@claude

claude Bot commented Aug 30, 2026

Copy link
Copy Markdown

Review — checked against AGENTS.md, specs/02-resources.md, specs/04-testing.md, specs/05-downstream-contract.md, specs/99-open-questions.md.

Verified clean:

  • Correctness. .get() on both keys in both __init__s; the …List subclasses inherit correctly. I confirmed the spec claims against the code rather than the prose: _list_endpoint appends /list, so LiveHuntResult.list() really does hit /hunt/live/list while parsing rows as LiveHuntResult; and _build_request sets result_parser=cls, so live_feed_delete() really does yield LiveHuntResultList instances — the "fourth source of None" correction in the last commit is right.
  • Cassettes. Genuinely re-recorded, not hand-edited or copied: consistent fresh timestamps (2026-08-29T00:11:*), new signed minio URLs, new ids throughout. test_live.vcr:708 carries a real detail-route entry — identifier $u, offset 69, length 9, data test_live, truncated false — while lines 666 and 755 carry a null matched_strings on the list row and on the DELETE body respectively. So all three live assertions are load-bearing. The −1732 is 19 stale hunt/live/list poll interactions dropping out on a fresh stack, not lost coverage.
  • Layering. resources.py only; no aio/api.py or generated-mirror churn, so the codegen-staleness check genuinely is not involved. Correct per specs/01-architecture.md (resources are pure).
  • Downstream contract. Purely additive attributes; no version bump, correct per AGENTS.md ("version bumps belong to the develop → master step").
  • Gitflow. Base is develop. Commit messages are conventional-prefixed, ticket-free, and carry no AI-attribution trailers.

1. test_parsing_does_not_mutate_the_raw_json — the last assertion still cannot fail (test/hunt_matched_strings_test.py)

assert result.json is content
...
assert set(content) <= set(result.json), "parsing must not drop keys from .json"

Given the identity assertion two lines above, this reduces to set(content) <= set(content). The docstring specifically argues that the previous version was tautological and this one is not — but the tautology moved rather than went away. (The two middle assertions, comparing parsed attributes against the raw dict, are fine and do carry the weight.)

Separately, nothing here tests the property the test is named for: because self.json = content keeps a live reference, "parsing did not mutate the raw json" is never actually checked.

Fix both at once:

raw = _content(cls, matched_strings=_STRINGS, matched_strings_dropped=19)
content = copy.deepcopy(raw)
result = cls(content)
assert content == raw, "parsing must not mutate the payload it was handed"
assert set(raw) <= set(result.json), "parsing must not drop keys from .json"

That makes both assertions falsifiable, and it survives a future change that defensively copies content — which the current assert result.json is content would fail on for the wrong reason.

2. Minor: the non-null matched_strings_dropped path is unpinned against a real server

test_live / test_async_live assert only the is None arm (correctly — the per-test rule withholds nothing). So the two strongest claims specs/05 makes about this field — that a non-null count means "short by this much", and that it can never accompany an empty list — rest entirely on fabricated pure-unit dicts. That is the same "asserts what we think the server returns" gap this PR is otherwise scrupulous about, and the historical-pair equivalent already got an entry in 99-open-questions.md. Producing an over-budget match on the e2e stack is probably not worth engineering, so an open-questions entry alongside the historical one seems like the right disposition rather than a test.

Neither blocks merge; (1) is a two-line change.

The previous commit replaced a tautological assertion and moved the tautology
rather than removing it: `assert result.json is content` followed by
`set(content) <= set(result.json)` reduces to `set(content) <= set(content)`.
Nothing checked the property the test is named for either, since self.json keeps
a live reference to the dict that was passed in.

Now compared against an independent deepcopy, so "parsing did not mutate the
payload" and "parsing did not drop keys" can both actually fail. It also survives
a future change that defensively copies content, which the identity assertion
would have failed for the wrong reason.

Also records that the non-null matched_strings_dropped path is not pinned against
a live server. The live tests assert only the None arm -- correctly, since the
per-test rule withholds nothing -- so the two strongest claims the contract makes
about the field rest on hand-written dicts. Producing an over-budget match on the
e2e stack is disproportionate to what it would pin, so this is recorded in
99-open-questions.md rather than tested, alongside the historical-pair gap.

195 tests pass.
@claude

claude Bot commented Aug 30, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md, specs/02-resources.md, specs/04-testing.md, specs/05-downstream-contract.md, specs/99-open-questions.md.

The parsing change itself is clean and matches the specs: .get() on both classes, resources.py is hand-written so no codegen mirror is involved, no pyproject.toml bump (correct — that belongs to develop → master), base is develop, commits are conventional and carry no ticket IDs. The route/class mapping the specs now assert (live_feed → LiveHuntResult, historical_results → HistoricalHuntResultList, deletes parsed through the …List classes) checks out against aio/api.py:517/620/532/633 and against the re-recorded cassettes. The 99-open-questions.md entries honestly scope what is and is not measured.

Three things:

1. Merge ordering — this will red the e2e job if it lands first (blocking).

test_live / test_async_live now hard-assert server behaviour:

assert result.matched_strings, ...
assert 'matched_strings_dropped' in result.json, ...
assert set(result.matched_strings[0]) == {'offset', 'identifier', 'length', 'data', 'truncated'}

The cassettes were recorded against the unmerged artifact-index#1949 branch, and the e2e job runs TESTS_VCR=off against the stack's :latest server image. AGENTS.md: "artifact-index — the server-side API the SDK talks to. New endpoints land there first; the SDK PR comes after." Worse, merging to develop promotes polyswarm-api:latest, and "every other repo's e2e pipeline resolves polyswarm-api:latest by default" — so a failing test_live here fans out to every downstream pipeline, not just this repo's. Hold this until the server side is merged and on the stack image.

2. The list-row assertion is the exact weakness the PR argues against, two lines above it.

assert my_results[0].matched_strings is None, \
    'list rows do not carry the evidence -- it is a per-row blob fetch'

The next comment reads "is None cannot tell a served null from an absent key" — and switches to .json for matched_strings_dropped on exactly that basis. The same argument applies here, and specs/05 makes the stronger claim that the list endpoints send an explicit null (the cassette confirms it: "matched_strings":null on the list rows). As written the assertion passes on a server that dropped the key entirely. Make it assert my_results[0].json['matched_strings'] is None in both client_scan_test.py:508 and async_client_test.py:735.

3. test_populated_list_is_passed_through_verbatim compares a list against itself.

_content(cls, matched_strings=_STRINGS) stores the module-level _STRINGS object by reference, so result.matched_strings is _STRINGS and assert result.matched_strings == _STRINGS (plus both index reads) cannot fail for any in-place reshape. It only catches a rebind to a new object. test_parsing_does_not_mutate_the_raw_json already got the copy.deepcopy treatment for precisely this reason — do the same here (matched_strings=copy.deepcopy(_STRINGS)) and the test starts measuring what its name claims.

…icated comments

Two assertions could not fail:

- The list-row check read `my_results[0].matched_strings is None`, two lines above
  a comment arguing that `is None` cannot distinguish a served null from an absent
  key -- which is why the count below it reads .json. The same argument applies
  here and specs/05 makes the stronger claim (an explicit null), so it now reads
  .json too.
- test_populated_list_is_passed_through_verbatim passed the module-level _STRINGS
  into the content dict, which stores it BY REFERENCE, so the assertions compared
  the object with itself and no in-place reshape could fail them. Deep-copied, the
  same fix test_parsing_does_not_mutate_the_raw_json already needed.

Also trims the two resources.py comment blocks, which restated the contract spec
nearly verbatim at both call sites. Three copies of one argument is three places
to drift, and several commits in this PR were spent reconciling exactly that. The
decision and a pointer are enough; specs/05 owns the reasoning.

195 tests pass.
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Review

The code change itself is clean: four .get() reads in resources.py, no aio/api.py touch (so no codegen involvement), base is develop, no pyproject.toml bump — correct per AGENTS.md §Gitflow. Cassettes verified: both test_live.vcr and test_async_live.vcr carry a real populated entry on the detail route with exactly the five documented keys, and an explicit matched_strings: null on the list row and on the delete response, so the new live assertions are load-bearing rather than vacuous.

Two things worth acting on.

1. Spec drift — the "fourth cause" only landed on one of the two fields

resources.py:770 and :823 say "None is AMBIGUOUS on both (four causes)" — both keys, four causes. The specs/05 matched_strings table (L284) agrees: list route, delete response, older server, deleted evidence.

But the three prose enumerations written before the delete-response commit still say three, and they are the ones a parser author reads first:

  • specs/05-downstream-contract.md:305-308 — for matched_strings_dropped: "on a detail route it means nothing was withheld, but on a list route it means the route did not look, and on an older server it means the field did not exist." No delete response — yet live_feed_delete() yields LiveHuntResultList with this field null (confirmed in the re-recorded cassette).
  • specs/02-resources.md:340-342 — "The short version a parser needs: None means not reported (an older server, removed evidence, or a list endpoint …)". Three. Then L368-369 of the same section says "So None reaches a caller from four places, not three" — the section contradicts its own summary a few lines up, which is the same shape of defect commit 8299433 was written to fix.
  • specs/02-resources.md:346-349 — same three-cause list, for matched_strings_dropped.

Given this PR spent three commits reconciling exactly this, worth finishing: make all four enumerations read the same four causes, or drop the enumerations from the summaries and point at the one table in specs/05 that owns them.

2. Merge ordering against the server PR

specs/05 (Companion repos) and AGENTS.md both say server changes land in artifact-index first. The two new live assertions (assert result.matched_strings, and the matched_strings_dropped key check on result.json) are hard failures against a server that does not serve the fields, and the e2e job runs TESTS_VCR=off — so this cannot merge to develop ahead of the companion PR without red e2e. Not a defect in the change; worth confirming the server side is in first.

Notes (no action)

  • Per specs/05 (Versioning), "New field on a resource (mirror of a new server-side field)" is a minor bump. Correctly deferred to the develop → master step rather than done here.
  • 99-open-questions.md records both real gaps (the historical pair, and the non-null matched_strings_dropped arm) rather than asserting them from hand-written dicts — right call, and it matches invariant 1 in specs/04.
  • The deep-copy fixes in test_populated_list_is_passed_through_verbatim and test_parsing_does_not_mutate_the_raw_json do make both assertions falsifiable; the compared objects are now independent.

@sbneto

sbneto commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Gives a hunt hit its yara evidence: the analyzer now scans with -s -L and reports each matched string (offset, identifier, stored length, rendered bytes, truncated), bounded by a per-task byte budget that reports what it withheld. The server stores that per match in a metadata container behind a nullable FK and serves matched_strings / matched_strings_dropped on the hunt detail routes only; this SDK parses both additively and the CLI renders a block between Tags: and Download Url:. 4 PRs on one shared branch; 31 files, +2486/−1780 across the set.

Severity: 0 HIGH · 5 MODERATE · 8 LOW. Prior feedback: 35 checked · 8 open.
Objective: met, with gaps — hunt results carry the yara strings that caused the hit.

  • The tracker was unreadable this run (the connector is unauthorized here), so this is measured against the four PR bodies rather than the acceptance criteria.
  • drift: a per-task byte budget with a user-visible withheld count, not in the original scope, and well argued

Fixes are proposed, not applied; nothing was run; no independent fix review in this run.

Cross-repo coordination

Repo PR State Role
polyswarm-analyzers the analyzer-side producer change CONFLICTING producer
artifact-index polyswarm/artifact-index#1949 CONFLICTING API
polyswarm-api #322 ← you are here MERGEABLE SDK
polyswarm-cli polyswarm/polyswarm-cli#267 CONFLICTING CLI

Merge order: analyzer producer → artifact-index#1949 → polyswarm-api#322 → polyswarm-cli#267 (§14: the API is the contract, and the analyzer produces the bytes). This PR is the only mergeable member of the four, and it must merge third — the path of least resistance is the wrong order. That is F4.

Surface Producer Consumer
strings / dropped on a yara match analyzer artifact-index#1949 → F4
matched_strings* JSON keys artifact-index#1949 polyswarm-api#322 ✓ verified both ways
SDK attributes + version floor polyswarm-api#322 polyswarm-cli#267 → F5

Coherence: F5's fix here is a develop merge, and it must land before the CLI's half of F5 — sequencing is in the entry.

Gaps: ## Requires never names the analyzer anywhere in the chain (F4); artifact-index#1949 has no ## Requires section at all.

Findings (round 1)

Every finding below is work for this change set; each entry's Lands in: names the repo whose PR carries the fix, on the branch name every member already shares (Rule 6).

[MODERATE] F4. The merge-order instructions never name the analyzer that produces the evidence

What happens: merging this PR before the analyzer change reddens develop's e2e, and because that job gates the release stage, polyswarm-api:latest stops being promoted — so every other repo's pipeline keeps testing a stale SDK, silently, until someone notices. The failure itself reads as a server bug.
When: whenever this merges before the analyzer's PR — the likely order, since this is the only member that can merge today.
Why:

  • test_live hard-asserts result.matched_strings (test/client_scan_test.py:507, twin at test/async_client_test.py:734), outside any conditional and after the download_url poll ends.
  • That value is None unless the analyzer emits strings: the server reads it with .get() and creates no container when absent, and the analyzer's master emits only rule_name and tags.
  • Post-merge the e2e cascade resolves siblings by :latest, and the analyzer repo has no develop branch — so the branch-slug match that makes all four work today disappears at exactly the moment it stops being checked.
  • ## Requires here names only the server PR. Nothing in the chain reaches the analyzer.

Lands in: polyswarm-api. Also touches: artifact-index (its PR body). Proposed fix (untested): restate ## Requires as an ordered list whose first entry is the analyzer-side producer change, referred to by category rather than by name — AGENTS.md forbids naming private companion repos in PR descriptions — and pointing at the server PR, which should carry the direct link. The load-bearing half is on the server PR: adding ## Requires: <the analyzer PR> there makes the dependency transitive, so anyone walking the chain from here reaches it. Do not soften the assertion: the pure-unit tier exercises dict.get and passes identically whether or not the server ever grew the field, so this assert is the only thing that distinguishes served evidence from a served null. Widen only its message, to say a null here against an otherwise-green stack means the analyzer image predates the producer change. Two lines, no behaviour change, and it turns a mystifying red pipeline into a one-line instruction. This review comments; it does not edit PR bodies.

[MODERATE] F5. The CLI's two conflicting files are exactly the ones develop fixed three minutes earlier

What happens: the CLI half of this pair adds runtime probes and shape-keyed test skips that develop deleted just before these heads were written; resolving its spec conflicts in favour of the branch would revert that work and leave text instructing future authors to probe again.
When: at the CLI's conflict resolution — its only two conflicts are those spec files.
Why:

  • specs/05-project-standards.md §16 bans probing a constructed object for a parsed field, and bans tests skippable on a dependency's shape. The CLI does both: four getattr reads and hasattr-driven skipif on 18 of 20 tests.
  • origin/develop already carries the conforming pattern in both repos — this repo declares 4.4.0 (commit 6333701, "release 4.4.0, the floor the CLI now pins"), and the CLI already pins polyswarm_api>=4.4.0 (commit 6cb030b, which deleted 22 such decorators). Both landed before these branch heads.
  • So this PR needs no version bump by hand. Its head still says 4.3.0 only because the branch predates that commit.

Lands in: polyswarm-cli. Also touches: polyswarm-api. Proposed fix (untested): merge origin/develop into this branch rather than editing the version — it brings pyproject.toml and __init__.py at 4.4.0 plus the corrected AGENTS.md exception bullet, and it avoids bump-my-version, whose .devN+sha serialisation sorts below the release and would silently fail the CLI's floor. 4.4.0 is unreleased, so two more additive fields on an already-open minor need no further bump. Then list matched_strings / matched_strings_dropped under the 4.4.0 surface set in specs/05-downstream-contract.md. Ordering: this merges to develop first — until it does, develop declares 4.4.0 without the fields and the CLI's floor is unsatisfiable from both the archive and PyPI. This repo's develop → master must also precede the CLI's, since that release is what puts 4.4.0 on the index.

  • [LOW] F10. Three of the four None-cause enumerations still list three causes while the same sections say "four places, not three" — specs/02-resources.md:340 and :346, and specs/05-downstream-contract.md:307; only the 05 table and the two resources.py comments were corrected. 02-resources.md therefore contradicts itself within one section, which is the same shape of defect the earlier commit was written to fix. Lands in: polyswarm-api. Fix (untested): make all four read the same four causes, or drop the enumerations from the summaries and point at the one table in 05 that owns them.

elsewhere: F1, F2, F3, F6 → polyswarm/artifact-index#1949 · F11, F12, F13 → polyswarm/polyswarm-cli#267 · F7, F8, F9 → the analyzer PR

Outstanding review feedback

Status Raised The ask Disposition
not addressed round 7 make all four None-cause enumerations agree → F10
open — not a defect round 5 merge ordering against the server PR → F4, now with the analyzer added as the missing third dependency
open — not a defect rounds 2–7 the head ref carries an internal ticket code, so a default merge-commit subject would land it in this public repo's history. Commits, title and body are all clean. Not fixable in the diff — squash-merge with a hand-written subject, or rename the branch first. —

Everything else from the seven earlier rounds is addressed, and I re-derived the ones worth re-checking: the cassettes are genuinely re-recorded and carry a populated detail row plus explicit nulls on the list and delete rows, so all four live assertions are load-bearing; both raw-json tests are falsifiable after the deep-copy change; the …List classes really do parse delete responses; and 99-open-questions.md scopes the two unmeasured arms honestly rather than asserting them.

Standards conformity

Set-level

  • §14 delivery order — clean, and unusually so. One externally-facing capability, not a layer: producer, API, SDK and CLI in one change set, exercised through this SDK against a running stack before the interface work. This is the case §14 describes.
  • Rule 6 name identity — clean. All four branches are byte-identical, and e2e's _tag_friendly_branch_name lowercases exactly like CI_COMMIT_REF_SLUG, so branch CI genuinely resolves and exercises all four together today. That is also why F4's hazard is invisible until the first merge.
  • ## Requires linkage — ✗ → F4.

Change-level (this repo): §16 cross-repo dependencies → F5 (the version-declaration half).

Project-level (non-clean rows only)

§ Evidence
16 ✗ the version declaration is absent from this branch; develop already has it (F5)

polyswarm-api / polyswarm-cli — Clean: §6, §10, §15. Not applicable: §3–§5, §7–§9, §11–§13.

I did not verify §2 beyond the release-job wiring F4 needed.

Checked and refuted, so not reported as a finding: that a red develop e2e here fans out and breaks every downstream repo's pipeline. It is the other way round — that job gates the release stage, so :latest freezes rather than breaking, and siblings keep passing against a stale SDK. F4 carries the corrected consequence, which is the quieter and more durable failure.

@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md + specs/02, 04, 05. The resource change is correct and minimal, the spec updates land in the right files, the cassettes are genuinely re-recorded (the 1.7k-line drop is just collapsed /hunt/live/list poll repeats — 31→12 sync, same shape async — and the detail interaction really does carry populated matched_strings with the list rows at null), commit messages are clean of ticket IDs, and the base is develop. Four things.

1. The per-entry key assertion is exact-set equality, which forbids the additive server growth this PR is built around.

client_scan_test.py:530 / async_client_test.py:888:

assert set(result.matched_strings[0]) == {
    'offset', 'identifier', 'length', 'data', 'truncated'}

The whole justification for .get() over a subscript in resources.py is that these keys are additive — a server that grows a field must not break the SDK. But if artifact-index later adds, say, a rule_string_index to each entry (a purely additive server change needing no SDK change), this assertion fails the suite VCR-off on develop — which is exactly the branch that gates :latest promotion. The stated intent in the comment is "a key rename cannot pass the suite"; a subset check gets that without the false positive:

assert {'offset', 'identifier', 'length', 'data', 'truncated'} <= set(result.matched_strings[0])

2. Merge order is a hard gate, not a note.

The PR body says it, but it is worth pinning as a merge condition rather than context: test_live / test_async_live assert result.matched_strings truthy and 'matched_strings_dropped' in result.json against the live stack. Until the analyzer (1) and server (2) changes are on the e2e stack images, the VCR-off e2e job on develop fails, the release job stops promoting polyswarm-api:latest, and every downstream pipeline silently keeps testing a stale SDK (per AGENTS.md, "The e2e test image is published from develop, not master"). Do not merge ahead of 1 and 2.

3. Version bump — one thing to confirm, not necessarily to change.

No pyproject.toml bump is the right default per AGENTS.md (bumps belong to the develop → master step). The standing exception fires only if the CLI's CI resolves this repo from source by branch name; if it does, the CLI's polyswarm_api>= floor has to name a version this repo has already declared, and that bump lands here, in this PR. If the CLI installs published artifacts only, the current no-bump is correct — please just confirm which, since the CLI PR is in the same change set.

4. Minor: one unit assertion is still a restatement.

hunt_matched_strings_test.py::test_parsing_does_not_mutate_the_raw_json — BaseJsonResource.__init__ does self.json = content (core.py:443), so result.json is content, and the preceding assert content == raw already establishes that the key sets are equal. assert set(raw) <= set(result.json) therefore cannot fail independently, despite the docstring claiming the tautology was moved out. The content == raw line is the one carrying the weight.

Also inconsistent, though harmless: the live tests membership-check one field ('matched_strings_dropped' in result.json) but subscript the other (my_results[0].json['matched_strings']). Against a server predating the change the subscript raises KeyError instead of failing with its message — the exact failure mode the resources.py comment argues against.

Nothing else: LiveHuntResult.list() resolves to /hunt/live/list parsed as LiveHuntResult, and the ...List delete builders parse the response through themselves — the new specs/02 "four places, not three" paragraph matches the code. The two 99-open-questions.md entries correctly record the historical pair and the non-null-count path as unpinned rather than asserting them from fabricated fixtures.

@sbneto sbneto left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. 1 LOW open — the per-entry key assertion in test_live / test_async_live is exact set equality, where this suite uses a subset check at five other sites; a subset catches renames and removals identically and would not red develop on a purely additive server key. Nothing fires today: the producer emits exactly those five keys. Verified this round: the cassettes are genuinely re-recorded, the four-cause None enumeration now reads the same in every place, and the Requires section states the merge order and its consequence.

02-resources.md states that None reaches a caller from four places, not three
-- and then two summaries in that same file enumerate three, as does the
matched_strings_dropped paragraph in 05. The file contradicted itself within
one section, which is the defect an earlier commit was written to fix.

Rather than restate the list a fourth time, the summaries now defer to the
one table that owns it. A pointer cannot drift out of step with what it
points at.
The live assertions are the only thing distinguishing served evidence from a
served null -- the pure-unit tier exercises dict.get and passes identically
whether or not the server ever grew the field. So the assertion stays exactly
as strict.

Only the message widens. The value is null unless the producer that emits the
strings is deployed, so the likeliest cause of this failing is an image that
predates it -- while everything else about the stack looks healthy, which
makes the failure read as a server bug. The message now names the real cause.
@kyle-buchmiller
kyle-buchmiller force-pushed the DN-8378-yara-matched-strings branch from ef3e02c to 1d2dab9 Compare September 8, 2026 17:00
@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Review

The code change itself is clean: 10 lines, .get()-parsed, additive, resources.py-only so no codegen involvement; base is develop, no version bump. Two things need action, both narrow.

1. Spec drift — "the other three causes" contradicts the four-cause table it points at

specs/02-resources.md:348 and specs/05-downstream-contract.md:309 both read:

on a detail route it means nothing was withheld; under any of the other three causes … it means nothing looked.

But the table in 05 enumerates four (list route, delete response, older server, deleted evidence), and 02:341 says "four distinct causes" seven lines above 02:348 saying three. 05:309 is the worse of the two, because it says "the other three causes in the table above" and the table directly above it has four. "Other" is off as well: the detail route is not one of the enumerated causes, so it should read "under any of the four causes in the table".

This is the same defect commit 13c191c ("let the four-cause table own the None enumeration") was written to fix — it caught the two summaries and the matched_strings_dropped paragraph in 05, but not this sentence in either file. Same fix: defer to the table rather than restating a count.

2. Test coverage — the newly-documented fourth None cause is pinned nowhere

The four-cause table is new contract in this PR, and two of its arms have no assertion in either tier:

  • matched_strings_dropped on a list row. test_live / test_async_live assert my_results[0].json["matched_strings"] is None, but nothing asserts the sibling, and the table in 05 claims list rows send an explicit null for both.
  • Delete responses (live_feed_delete). This is the cause commit 024449d was written to document, on the grounds that a caller holding …List objects with a mental model that resolves the None wrongly is "the worst combination". The pure-unit tests parametrise over LiveHuntResultList, but that pins __init__, not route behaviour — exactly the "would pass identically if the server never grew the field" gap the live assertions exist to close.

Both are already in the recorded cassettes: test/vcr/test_live.vcr carries three matched_strings occurrences (list row null, detail populated, delete-response null), each paired with a matched_strings_dropped. So these are assertion-only additions, no re-record needed — roughly assert my_results[0].json["matched_strings_dropped"] is None, and capturing the currently-discarded return of live_feed_delete([result_id]) to assert deleted[0].json["matched_strings"] is None. The second also converts 024449ds claim from prose into something a server-side change can break.

Notes, no action

  • Merge ordering: test_live asserting result.matched_strings truthy hard-requires items 1 and 2 of the change set to be deployed. You already flag this; restating only that it is a merge gate, since a red develop e2e stops :latest promotion and every downstream pipeline then silently keeps testing a stale SDK.
  • Version bump: correctly absent under the default rule. Worth revisiting only if the CLI PR (item 4) resolves this repo from source by branch name and raises a polyswarm_api>= floor — per AGENTS.md that floors bump would have to land here, in this PR.

@kyle-buchmiller
kyle-buchmiller merged commit 697763c into develop Sep 10, 2026
2 checks passed
@kyle-buchmiller
kyle-buchmiller deleted the DN-8378-yara-matched-strings branch September 10, 2026 17:35
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