Skip to content

Hunt-page ruleset tracking: favorites, rule counts, hunt provenance - #321

Merged
sbneto merged 33 commits into
developfrom
DN-8480-hunting-schema-migration
Aug 31, 2026
Merged

sbneto merged 33 commits into
developfrom
DN-8480-hunting-schema-migration

Conversation

@vhmartinezm

@vhmartinezm vhmartinezm commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR

SDK support for the hunt-page ruleset tracking the internal artifact API now serves: favorites with a server-owned budget, ruleset-list filters, per-live-hunt result counts, a per-hunt feed scope, and source-rule provenance on historical hunts. Sync and asyncio clients both; all field parsing is additive (an older server leaves the new attributes None).

Requires

  • The internal artifact API change on the same branch name (private repo) — push/merge order: server first (its release publishes :latest), then this PR to develop.
  • Render hunt-page ruleset tracking and hunt provenance fields polyswarm-cli#266 renders these fields and merges after this PR. It pins polyswarm_api>=4.4.0, so its release waits on a develop → master PR here, not on this merge: PyPI publishes on a version change on master, so develop carrying 4.4.0 uploads nothing and that floor stays unsatisfiable from PyPI until the release lands. The CLI's CI is unaffected — it installs this repo from git by branch name.

What's new

Resources:

  • YaraRuleset: favorite, favorited_at, rule_count (None means the server had no answer — distinct from 0), historical_hunt_count, and the STORED new_results_count with its staleness marker new_results_counted_at (the server refreshes the counter on a schedule; None = not yet refreshed / no live hunt, never 0). The per-request count surfaces from the earlier revision (LiveHuntResultCounts, live_results_count(), ruleset_list(include_counts=, since=)) are withdrawn per the query-design standard (§13) — no aggregate rides a request path.
  • HistoricalHunt: rule_id (the source ruleset), rule_modified (freeze-time audit value), source_rule_changed — tri-state: has the source ruleset's body changed since the hunt froze it? None = unknown, not "unchanged"; the create response answers the knowable False directly.
  • New YaraRulesetFavorite (the toggle's response: star state + favorites_used/favorites_limit). RESOURCE_ID_KEYS = ['community']: the server reads id/favorite from the PUT body (the default ['id'] would move id into the query — a 400), while community rides the query to match the ruleset GET/list placement.

Methods (sync + asyncio):

  • ruleset_favorite(ruleset_id, favorite=True) — idempotent star/unstar; over-budget refusals carry a machine-readable FAVORITE_LIMIT error.
  • ruleset_list(name=, status=, favorites_only=, has_new_results=) — has_new_results selects on the stored counter; there is no per-request window parameter.
  • live_feed(livescan_id=), plus live_feed(max_results=) — bounds how many results the generator yields and nothing else: the request is unchanged, so paging continues in the server's own chunks until the total is reached. None/0/negative all mean no bound, which is the historical behaviour, so no existing caller's results change.
  • Version bumped to 4.4.0, in this PR. The CLI pins polyswarm_api>=4.4.0 rather than probing the installed SDK at runtime, and a floor cannot name a version this repo has not declared — so the bump lands here instead of at the release step. Minor, because every addition is additive. AGENTS.md and specs/05 record the exception, the release ordering it forces, and the PEP 440 trap (4.4.0.dev0 sorts below 4.4.0 and would silently fail the CLI's floor).
  • live_feed(since=) stays SECONDS. The docstring said minutes for years and was simply wrong; the server has always read seconds, so this is a documentation correction, not a behaviour change, and it forces no bump. Moving the wire to minutes was considered and rejected — the endpoint takes ~197k requests per 30 days carrying since from clients outside our control, and re-reading those as minutes widens each 60x with no error. specs/05 §Documentation corrections records the measurement.
  • since absent or 0 means no time filter at all — the feed pages over everything. That is the server's contract (it applies the filter on a truthiness test), now stated in specs/03.

Tests

The two rules live-tests build a uid-namespaced single-rule ruleset (unique name on the shared stack, deterministic rule_count) and exercise the favorite round-trip, the name/favorites filters, the stored-counter contract (null until the server's refresh job runs — it doesn't on the e2e stack — and excluded from has_new_results), hunt provenance, the counter increment, and the changed-since-freeze flip. Read-after-write assertions whose write is line-adjacent poll (replica-lag tolerant; sleeps are free on VCR replay); the list reads that sit several calls after their create do not. Cassettes re-recorded against a live stack running the paired server branch. A new dual-transport respx suite (ruleset_favorite_respx_test.py, on the ClientTestCase harness) pins the FAVORITE_LIMIT envelope and the toggle's query/body split; pure-unit builder tests pin the request shapes. The sync client is regenerated via scripts/regenerate_sync.py.

…nance

New fields parsed on existing resources (all additive; an older server
leaves them None):

- YaraRuleset: favorite, favorited_at, rule_count (None means the server
  had no answer, distinct from 0), historical_hunt_count, and
  new_results_count (only when the list is asked to include counts).
- HistoricalHunt: rule_id (the source ruleset), rule_modified (freeze-time
  audit value), and source_rule_changed — a tri-state answering "has the
  source ruleset's body changed since the hunt froze it?" (None = unknown,
  not 'unchanged').

New endpoints and filters:

- ruleset_favorite(id, favorite): idempotent star/unstar; the response
  carries favorites_used/favorites_limit; over-budget refusals surface a
  machine-readable FAVORITE_LIMIT error.
- ruleset_list(name=, status=, favorites_only=, has_new_results=, since=,
  include_counts=): the hunt-page filters, conjunctive and optional.
- live_results_count(since=): per-live-hunt result counts in a window,
  one aggregate for every 'new results' badge.
- live_feed(livescan_id=): scope the feed to one live hunt.

Sync and asyncio clients both. The rules live-suite tests now create a
uid-namespaced single-rule ruleset (deterministic rule_count, no name
collisions on the shared stack) and exercise the favorite round-trip,
provenance, counter increment, and the changed-since-freeze flip; their
cassettes are removed to re-record against a stack that serves the new
fields.
The sync api.py was hand-edited; scripts/regenerate_sync.py places
live_results_count in aio's order and applies ruff's formatting, which
is what the unasync-mirror CI gate diffs against.

The rules live-tests' three read-after-write assertions (the counter,
and both sides of the changed-since-freeze flip) now poll: those GETs
read the replica, and on a real-replica stack the stale read of the
flip is a silent False. Sleeps are free on VCR replay.
Ids exceed JavaScript's safe-integer range; the counts entries carry the
same digit string YaraRuleset.livescan_id does.
Recorded against the branch server image (both tests green live first);
the offline suite replays them — 163 passed with no stack.
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md + specs/02-resources.md / 03-endpoints.md / 04-testing.md / 05-downstream-contract.md. Gitflow is clean (base develop, pyproject.toml untouched — correct, the bump belongs to the develop → master step per specs/05), and the surface changes are additive: new kwargs are appended at the end of live_feed, so positional callers are unaffected. Four things need action.
1. No spec update — AGENTS.md requires one in the same PR

Update specs/03-endpoints.md (and any other relevant spec) in the same PR.

Nothing under specs/ is touched. Concretely stale after this PR:

  • specs/03-endpoints.md:86-89 and :192 — ruleset_list() is catalogued with an empty signature; ruleset_favorite and live_results_count are absent entirely; the live_feed(since=None, …) row (:189) predates livescan_id.
  • specs/02-resources.md — the class-hierarchy tree and the per-domain catalogue have no YaraRulesetFavorite (/hunt/rule/favorite) or LiveHuntResultCounts (/hunt/live/results/count). YaraRulesetFavorite also sets RESOURCE_ID_KEYS = [], a deliberate deviation from the documented convention (the key list routes the identifier to the query string for GET/DELETE/PUT — here it is emptied so id rides in the PUT body instead). That belongs in the spec, not only in a code comment.
  • specs/05-downstream-contract.md:150-152 — the 'commonly imported' resource list.
    2. live_results_count / LiveHuntResultCounts ship with zero coverage

No cassette in test/vcr/ contains results/count, include_counts, or livescan_id=. So none of the following is exercised anywhere, live or replayed:

  • LiveHuntResultCounts.RESOURCE_ENDPOINT = '/hunt/live/results/count' — a typo in the path ships green.
  • since routing to the query string, and the counts / since parse (including the documented counts or [] fallback).
  • live_feed(livescan_id=…) — the new feed scope.
  • ruleset_list(status=, has_new_results=, since=, include_counts=) — only name= and favorites_only= are recorded.
  • YaraRuleset.new_results_count is null in every recorded response, so the 'only present when the list was asked to include counts' path is never observed non-None.

Per specs/04-testing.md these are all e2e-reachable — they want VCR lifecycle coverage in test_rules / test_async_rules, not a follow-up.
3. Missing the pure-unit builder tier for the two new resources

AGENTS.md step 4 asks for the VCR lifecycle test plus pure-unit builder tests asserting the PolyswarmRequest shape. test/known_good_test.py is the pattern. Two things nothing in the repo currently pins:

  • YaraRulesetFavorite.update(...) with RESOURCE_ID_KEYS = [] puts id, favorite, and community in the JSON body of a PUT rather than the query string. That is entirely a consequence of core._params (method != GET and key not in param_keys → body); emptying the key list is the only thing holding it.
  • favorite serialises as 1/0, not true/false — core.py:549-550 coerces bools to int before they reach the body, and _normalise_bool_params only touches query params. The cassettes confirm the server accepts {"favorite": 1}, but that int-vs-bool body contract is invisible and untested.
    4. The favorite is unstarred in the try body, not in finally

test/client_scan_test.py / test/async_client_test.py: ruleset_favorite(rule.id, False) sits between assertions inside try. If anything in between fails — most likely the favorites_only presence assertion, which reads a list that can lag — the star is never released. The test itself documents that the budget is team-wide and asserts favorites_limit == 5, so a handful of failed runs could wedge the rules tests on the shared e2e stack with FAVORITE_LIMIT until someone unstars by hand. Move the unstar into finally ahead of ruleset_delete, or confirm (and comment) that ruleset_delete releases the star.

Related, minor: assert fav.favorites_limit == 5 pins a server-side config constant, while the line immediately below deliberately bounds rather than pins favorites_used for shared-stack reasons. Worth being consistent about which of the two is a contract.
Minor

  • test/eicar.yara is no longer referenced by any test — both call sites moved to uid_yara(uid). Only a doc comment in _e2e_helpers.py:79 mentions it now. Delete it or say why it stays.

Review findings, all four:

- specs updated in the same PR as required: 03-endpoints gains
  ruleset_favorite / live_results_count rows, the real ruleset_list
  signature and live_feed's livescan_id; 02-resources catalogues both
  new resources — including why YaraRulesetFavorite empties
  RESOURCE_ID_KEYS (the server reads the toggle from the PUT body; the
  empty key list is the only thing routing id there) and the bool→int
  body serialisation; 05's commonly-imported list carries both.
- the rules live-tests now exercise every previously-uncovered surface
  against the real stack (and the cassettes record it): live_start →
  status=active filter → include_counts observed as a computed 0
  (distinct from null) → live_results_count (our zero-result hunt
  ABSENT from counts, keyed by the same digit strings ruleset_get
  renders) → the livescan_id-scoped feed → live_stop, with the stop in
  a finally because a running hunt blocks ruleset deletion.
- pure-unit builder tests (hunt_tracking_builder_test.py, the
  known_good_test pattern) pin the request shapes: the favorite PUT's
  body routing incl. 1/0 bools, counts query routing + None omission,
  the list filters' int bools and byte-compatible no-filter request,
  and livescan_id stringification.
- the unstar stays a contract assertion with slot hygiene documented:
  ruleset_delete soft-deletes and the budget counts only deleted=false
  rows, so a failed run's star frees itself with the rule. The limit
  pin vs used bound is now commented as deliberate.

Also: test/eicar.yara deleted (no test references it since the uid_yara
move; the helper docstring no longer names the file).
@vhmartinezm

Copy link
Copy Markdown
Contributor Author

All four addressed in 40bc463: specs 02/03/05 updated in-PR (including the RESOURCE_ID_KEYS=[] rationale and the 1/0 body bools); the live tests now exercise every flagged surface against the real stack and the cassettes record it — live_start → status=active → include_counts observed as a computed 0 → live_results_count (zero-result hunt absent, digit-string keys matching ruleset_get) → livescan_id feed → live_stop-in-finally; pure-unit builder tests added (hunt_tracking_builder_test.py, the known_good pattern); the unstar/slot question is answered in a comment (ruleset_delete soft-deletes and the budget counts deleted=false only, so a failed run self-heals) with the limit-pin-vs-used-bound distinction made explicit; test/eicar.yara deleted.

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md + specs/01–05. The implementation is sound — builders route as documented (RESOURCE_ID_KEYS = [] → PUT body, confirmed by the recorded body {"id":"71359438369584055","favorite":1,"community":"gamma"}), the sync mirror matches the canonical async source, every new field is an additive .get() parse, the surface change is additive-only (no version bump — correct, bumps belong to develop → master), and the base branch is develop. Four items, all spec/coverage:

1. live_results_count is filed in the wrong classification table (spec drift).
specs/03-endpoints.md:190 puts it under ## Classification — _paginate (returns iterable / async iterable), but both implementations call _single and return a single LiveHuntResultCounts (aio/api.py / api.py). _single vs _paginate is the organizing invariant of that document — as written it tells a caller to iterate a resource object. Move the row to the ### Live hunts _single table alongside live_result.

2. specs/04-testing.md:29 still lists a fixture this PR deletes.

test/eicar.yara, test/malicious — fixture files for upload tests.

The last commit correctly notes nothing references test/eicar.yara any more, but the spec inventory wasn't updated in the same PR. Drop it from that line (and, while there, test/hunt_tracking_builder_test.py is a new module the inventory doesn't mention).

3. The counts entry shape is documented three times and asserted nowhere.
Both cassettes record {"result":{"counts":[],"since":86400},"status":"OK"} (test/vcr/test_rules.vcr:503). So the {livescan_id, count} entry shape and the "digit string, the same join key YaraRuleset.livescan_id carries" claim — stated in resources.py LiveHuntResultCounts.__doc__, specs/02-resources.md:350 and specs/03-endpoints.md:191 — are never exercised, and neither is the content.get('counts') or [] coalescing. hunt_tracking_builder_test.py pins request construction only, not parsing. A pure-unit parse test with a canned non-empty payload (counts: [{"livescan_id": "119…", "count": 3}], plus a counts: null case) would pin all three claims cheaply and needs no stack.

4. livescan_id feed scoping isn't distinguished from an ignored param.
The e2e asserts list(api.live_feed(livescan_id=livescan_id)) == [] against a hunt with zero results — recorded as a 204. A server that dropped livescan_id entirely would produce a different (non-empty) result only if some other hunt had results in the window, which on this fresh-ruleset path it doesn't. So the assertion passes whether or not the filter works; only the query-string shape is actually pinned (test_list_routes_livescan_id_to_the_query_as_digit_string). Same gap for has_new_results, which has no coverage above the builder tier. If a matching submission is too expensive here, that's a fair call — but the test comments read as if the scoping is verified, and it isn't.

5. (minor) FAVORITE_LIMIT is promised but unreachable-by-documentation and untested.
Both docstrings plus specs/02-resources.md:349 and specs/03-endpoints.md:89 advertise "a machine-readable FAVORITE_LIMIT error". Today that surfaces as a generic RequestException/FailedInstanceException whose only machine-readable path is exc.request.errors['code']. Compare KNOWN_GOOD, which got a typed KnownGoodWithheldException, an explicit .sources contract, and specs/05-downstream-contract.md:181 spelling out the raw-envelope fallback. Either mirror that treatment or add one line to specs/05 saying where the code is read from — otherwise "machine-readable" is a promise with no documented API behind it. No test covers the refusal path either.

…ew 2)

All five follow-ups:

- live_results_count moved to the _single Live-hunts table in
  specs/03 — it returns one resource, and _single-vs-_paginate is that
  document's organizing invariant.
- specs/04's fixture inventory drops the retired test/eicar.yara and
  names the new pure-unit module.
- Parse-side pins for the counts resource: the cassettes only carry
  EMPTY counts (fresh zero-result hunt), so the {livescan_id, count}
  entry shape, the digit-string join key and the null-counts coalesce
  now have canned-payload tests.
- The livescan_id feed assertions no longer read as if they verify the
  scoping: with a zero-result hunt they pin the wire shape and the
  empty pass-through only, and the comments now say so (the scoping
  semantics are pinned by the server's own HTTP suite).
- FAVORITE_LIMIT's machine-readable contract is now documented
  (specs/05: no typed exception by design; the path is
  exc.request.errors with the code plus the same counters a successful
  toggle returns) and pinned by a respx refusal test — mocked because a
  genuinely full budget on the shared stack would race every other run.
@vhmartinezm

Copy link
Copy Markdown
Contributor Author

All five addressed in b80f825: the counts row moved to the _single Live-hunts table (it returns one resource); specs/04's inventory drops the retired fixture and names the new module; parse-side pins added for the counts entry shape, the digit-string join key and the null-counts coalesce (canned payloads — the cassettes only carry empty counts by construction); the feed assertions' comments now say exactly what they pin (wire shape + empty pass-through — the scoping semantics are pinned by the server's own suite); and FAVORITE_LIMIT's machine-readable contract is documented in specs/05 (no typed exception by design; the path is exc.request.errors with the code plus the same counters a successful toggle returns) and pinned by a respx refusal test, mocked because a genuinely full budget on the shared stack would race every other run.

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md + specs/01–specs/05. Architecture, gitflow, and spec updates look clean: canonical async edited with a regenerated sync mirror, resources stay pure, _single/_paginate routing is right, specs/02–specs/05 all updated in-PR, no version bump (correct — that belongs to the develop → master step), base is develop, commit messages carry no ticket IDs or private repo names. Builder shapes verified against core._params (empty RESOURCE_ID_KEYS → PUT body; bool → 1/0; *_id → digit string), and both re-recorded cassettes match their tests interaction-for-interaction.

Three things worth acting on, all in tests/docs.

1. The favorite round-trip is not actually pinned on VCR replay. Star and unstar are both PUT /hunt/rule/favorite with no query string — by design, everything rides the body (id / favorite 1|0 / community). The suite uses vcrpy default matchers ([method, scheme, host, port, path, query], per the AGENTS.md convention), so on replay these two requests are indistinguishable and resolve purely by recording order. Reorder the two ruleset_favorite calls, or drop one, and test_rules / test_async_rules still pass against the wrong recorded response — the favorite is True / favorite is False assertions are only load-bearing on a live run. This is the one endpoint in the suite where the body is the request identity; adding body to match_on for these cassettes (or a scoped use_cassette with a body matcher) would make replay assert what the test claims.

2. since unit disagreement across the hunt surface. live_feed documents minutes (src/polyswarm_api/aio/api.py:510), while the new live_results_count and ruleset_list document seconds (src/polyswarm_api/aio/api.py:532 and :159; the tests pass 86400 = 24h). These are all hunt-window params a consumer will wire from one UI control. If the server genuinely differs per endpoint, please state that explicitly in the docstrings and in the specs/03 rows — as written, a CLI author reading the two adjacent methods will pass the wrong magnitude. If it does not differ, one of the docstrings is wrong.

3. Latent: the empty RESOURCE_ID_KEYS applies to every builder on YaraRulesetFavorite, not just update. Only update is used today, so this is inert — but a future favorite get/delete on this class would silently send id in a DELETE body instead of the query string. The specs/02 note explains the why for update; worth half a sentence there that the class is deliberately update-only.

Minor / no action needed: has_new_results is exercised only at the builder tier, and counts is non-empty only in the hand-built parse pin — both are honestly called out in the test comments, and producing a results-bearing second hunt on the shared stack is not worth the flake. ruleset_favorite docstring claims idempotency that nothing double-stars, but the server owns that behaviour.

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Review

Src side is clean: _params routing is right (RESOURCE_ID_KEYS = [] → {id, favorite, community} in the PUT body, confirmed by both cassettes), LiveHuntResultCounts.get is correctly non-paginated (has_more absent → _single returns the resource), the new livescan_id kwarg is appended after community so no positional caller breaks, all new parses are .get()-additive, and the sync mirror matches what regenerate_sync.py + ruff would emit. Specs 02/03/04/05 were all updated in-PR. No version bump — correct per AGENTS.md §Gitflow / specs/05 invariant 6. Base is develop — correct.

Four things worth action.

1. ruleset_list(has_new_results=…, since=…) never reaches the server.

Every recorded /hunt/rule/list request in both cassettes is one of: ?community=gamma, ?name=<uid>&community=gamma, ?status=active&community=gamma, ?favorites_only=1&community=gamma, ?include_counts=1&community=gamma.

has_new_results and since appear only in hunt_tracking_builder_test.py, which asserts what the SDK sends. A misspelled param name or a wrong unit would be silently ignored server-side and the whole suite would still pass. The "same for has_new_results" note explains why the semantics are not pinned, but not why the params are never transmitted at all.

Both are cheap to add inside the existing running-hunt block, and one is an assertable negative: the hunt has zero results, so has_new_results=True should exclude it — assert rule.id not in {r.id for r in api.ruleset_list(has_new_results=True)}, wrapped in the same NoResultsException guard already used for status='active' after live_stop. And since=86400 can just ride the existing include_counts=True call.

2. since means minutes on live_feed and seconds on the two new surfaces.

live_feed(since=…) is documented "Fetch results from the last since minutes" (src/polyswarm_api/aio/api.py:510); live_results_count(since=…) and ruleset_list(since=…) are documented as seconds. Three since params on the same hunt page, two units. If that is genuinely what the server does, fine — but please confirm, and state the unit in the specs/03-endpoints.md rows for live_results_count / ruleset_list, which currently say "window"/"seconds" without tying either to live_feed. A CLI author reading these side by side will get one wrong.

3. test_favorite_limit_refusal_is_machine_readable is off-tier.

specs/04 invariant 7: "Prefer the pure-unit tier for builder + parse logic … Use this tier for any bug that can be reproduced without network involvement." This test asserts exactly one thing — a 400 envelope populates request.errors and raises RequestException — which is pure parse_response / _raise_for_status behaviour. core_test.py::TestParseResponseErrors already does this shape with _FakeResponse (see the KNOWN_GOOD 404 arm); the same assertion is ~5 lines there, with no respx and no sync-only asymmetry.

If it stays a respx body, invariant 5 requires a new off-harness respx body to "say why in its docstring." The comment argues respx-over-e2e (fair — you cannot hold five team slots on a shared stack) but not why it is sync-only rather than on ClientTestCase. Add that sentence, or move it to core_test.py.

4. Ticket ID in the branch name.

DN-8480-hunting-schema-migration lands in the public merge commit subject (Merge pull request #321 from polyswarm/DN-8480-…). AGENTS.md bans ticket IDs from commit messages / PR titles / descriptions; the commits and title here are clean, so squash-merge with a clean subject (or rename the branch) to finish the job.

Minor. test_rules / test_async_rules now start and stop a live hunt and run up to three 30x1s poll loops, but neither matches anything in _LONG_POLE_FRAGMENTS (test/conftest.py:117), so they schedule into the fast tail of the live -n 8 run. Adding "rules" to the tuple keeps them off the critical path.

@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Review

Checked against AGENTS.md and specs/02-resources.md, 03-endpoints.md, 04-testing.md, 05-downstream-contract.md.

Verdict: the src-side change is clean. All four touched specs are updated in the same PR, the resource/builder plumbing is right (RESOURCE_ID_KEYS = [] genuinely is what routes id / favorite / community into the PUT body — verified against core._params), the FAVORITE_LIMIT path really does land in exc.request.errors (_extract_json_body populates it before _raise_for_status raises the generic 400 RequestException), base is develop, and there is correctly no version bump (spec 05 invariant 6). Everything below is test-side.


1. favorites_limit == 5 pins a server product constant into the SDK suite

test/client_scan_test.py:635, plus the same assertion in test_async_rules.

assert fav.favorites_limit == 5

Under TESTS_VCR=off this runs live in e2e CI, so a server-side cap change breaks this suite for a reason that has nothing to do with the SDK contract. The comment calls it a deliberate PIN of "the fixed product cap, no plan scaling" — but that constant lives in the server repo, and nothing here fails if it drifts except this assert. The SDK contract is only "the field is present and is the denominator favorites_used is measured against." Suggest:

assert fav.favorites_limit >= 1
assert 1 <= fav.favorites_used <= fav.favorites_limit

which still pins the meaningful relationship and stops the SDK suite from being a tripwire for someone else's config.

2. The additive-parse invariant is asserted nowhere

Three places claim it — specs/02 ("All additive .get() parses; an older server leaves them None"), specs/05, and the resources.py docstrings — plus the documented tri-states (source_rule_changed=None is "unknown, never unchanged"; rule_id=None for raw-yara hunts; new_results_count=None when the list was not asked for counts). The cassettes only carry a new server, and hunt_tracking_builder_test.py adds parse pins for LiveHuntResultCounts only. Specific missing cases, all cheap and belonging in the new pure-unit file:

  • YaraRuleset constructed from a payload carrying none of the tracking keys → favorite is None, rule_count is None, historical_hunt_count is None, new_results_count is None.
  • HistoricalHunt on a pre-tracking payload → rule_id is None, rule_modified is None, source_rule_changed is None.
  • In the live test, the plain ruleset_list() result already in hand could assert new_results_count is None for the un-counted case — right now only the include_counts=True branch (== 0) is checked, so "None otherwise" is untested.

Without these, a future refactor that changes content.get('rule_count') to content['rule_count'] (which the neighbouring HistoricalHunt.__init__ already does for progress / results_csv_uri) breaks every older-server consumer with a green suite.

3. Cleanup ordering can leak the ruleset on the shared stack

test/client_scan_test.py:700-704 and the async twin:

finally:
    if hunt is not None:
        api.historical_delete(hunt.id)
    api.ruleset_delete(rule.id)

If historical_delete raises, ruleset_delete never runs. That specifically undermines the hygiene argument stated a few lines up — "the finally's ruleset_delete soft-deletes and the server's budget counts only deleted=false rows, so a failed run's star frees itself with the rule." It only frees itself if the delete actually executes. Nest it:

finally:
    try:
        if hunt is not None:
            api.historical_delete(hunt.id)
    finally:
        api.ruleset_delete(rule.id)

4. Minor — the favorite cassette cannot tell star from unstar

Both PUTs to /hunt/rule/favorite are identical under the [method, scheme, host, port, path, query] matcher, because the whole toggle rides the body and the body is not matched. On replay VCR serves them in recorded order, so assert fav.favorite is True / assert unfav.favorite is False pass regardless of what the SDK actually put on the wire. Acceptable as-is (hunt_tracking_builder_test.py is what really pins the body), but worth a comment at the call site: reordering or dropping one of the two toggles silently replays the wrong response rather than failing.

5. Nit — branch name carries an internal ticket ID

DN-8480-hunting-schema-migration. AGENTS.md bans internal refs in "commit messages, PR titles, or PR descriptions" — all three are clean here — but a non-squash merge writes the branch name into public history. Squash-merge with a clean subject, or extend the rule to branch names.


Not verified: I could not execute scripts/regenerate_sync.py --check in this environment. The sync mirror reads as a faithful unasync of the async source by inspection; CI's staleness gate is the authority.

@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown

Review

Base (develop), no version bump, specs 02/03/04/05 updated in-PR, pure-unit builder tests + re-recorded live cassettes — the AGENTS.md "adding a new resource" checklist and the gitflow rules are all satisfied. Four things worth acting on.

1. community rides the PUT body on the favorite toggle — is that actually honored server-side?

resources.py — YaraRulesetFavorite.RESOURCE_ID_KEYS = [] routes every kwarg into json, so the recorded request is:

PUT /hunt/rule/favorite   body: {"id":"71359438369584055","favorite":1,"community":"gamma"}

id and favorite in the body is the documented intent. But community landing there too is a side effect, not a choice — every other ruleset call sends it as a query param. The cassette proves the server returns 200; it does not prove the community was read, because the e2e stack has exactly one community. If the server resolves community from query args (as the rest of the API does), a cross-community star silently targets the default and nothing in the suite catches it.

RESOURCE_ID_KEYS = ['community'] yields exactly the shape you want — community to the query, id/favorite to the body — while keeping the deviation-from-['id'] note in specs/02 true. Either switch, or confirm the server reads it from the body and say so in the spec note.

2. New respx body is transport-agnostic but sync-only — specs/04 invariant 5

test/client_scan_test.py:360 test_favorite_limit_refusal_is_machine_readable. specs/04 invariant 5:

a new respx body that is transport-agnostic goes on the harness, and one that does not should say why in its docstring.

This one is transport-agnostic by construction — the mapping under test is core._raise_for_status / _extract_json_body, pure shared Layer-1 code, identical on both transports. The comment argues respx-over-e2e (fine, and correct under invariant 1) but never argues sync-only. It should be a ClientTestCase subclass so the Sync/Async siblings both run, or the docstring should state the exemption.

3. poll_equals applied to two read-after-writes but not the other four

The rationale written for adding the poll —

replica-backed GETs: poll so a lagging replica (real stacks, not e2e) can't flake these

— applies verbatim to the list assertions that were left unpolled, in both suites:

  • client_scan_test.py:618 — ruleset_list(name=uid) immediately after ruleset_create
  • client_scan_test.py:631 — ruleset_list(favorites_only=True) immediately after the star
  • client_scan_test.py:648 — ruleset_list(status='active') immediately after live_start
  • client_scan_test.py:672 — ruleset_list(status='active') immediately after live_stop

(and their async_client_test.py twins). Same replica, same lag window, and these run live on every e2e CI job (TESTS_VCR=off). 648/672 are the worst: live_start/live_stop are writes whose effect is read back one line later. Either wrap them in poll_equals (e.g. poll_equals(lambda: rule.id in {r.id for r in api.ruleset_list(status='active')}, True)) or add a comment saying why these four are lag-immune when the other two aren't.

4. Ticket code in the branch name

DN-8480-hunting-schema-migration. AGENTS.md bans internal ticket IDs in commit messages, PR titles and PR descriptions on this public repo; a branch name is just as public, and the PR body points readers straight at it ("the same branch name"). Not worth rebasing now — flagging so the next branch does not carry one.


Minor, no action needed: LiveHuntResultCounts correctly stays unpaginated (_paginated is only set when a GET envelope carries has_more; the recorded /hunt/live/results/count envelope does not), and the favorite=False to 0 coercion round-trips through core._params as the builder test pins.

@vhmartinezm
vhmartinezm requested a review from sbneto August 24, 2026 16:53
@sbneto

sbneto commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Review — hunt-page tracking, measured against the platform query-design and delivery-order standards

Reviewed against our org-wide project standards — §13 Query design (no aggregate computed on a request path; client-visible counts are stored columns refreshed by a scheduled job with an observable staleness marker) and §14 Delivery order (a capability ships API → SDKs and CLI → UI, and a unit of work is a capability, never a layer) — plus the design decisions settled on the server side of this change.

What's clean. The cross-repo mechanics are right: identical branch name, base develop, no version bump (correct — that belongs to the develop → master step), ## Requires present, and the private repo referred to obliquely with no internal ticket id anywhere in the title, body or commits. Every new field is an additive .get() parse, so an older server leaves them None and no released client breaks. _single/_paginate routing is right and the sync mirror tracks the canonical async source. Nothing in this repo computes an aggregate client-side.


Findings

[MODERATE] F1. Two of the new surfaces wrap a server aggregate that is being withdrawn

What happens: live_results_count() / LiveHuntResultCounts and ruleset_list(include_counts=…, since=…) are SDK surfaces over a per-request COUNT … GROUP BY. That aggregation is a design flaw under §13 — the cost of serving the read grows with result volume, on every page visit — and it is being replaced server-side by a stored counter column refreshed by a scheduled job. The endpoint and both parameters are going away, so these surfaces would ship pointing at nothing.

When: On merge, if the server change lands as designed. The SDK is the published contract, so a surface shipped here is one we then have to support or break.

Why:

  • §13's first rule: a number a client reads MUST be a stored column, refreshed asynchronously — COUNT/SUM/MIN/MAX and their windowed forms belong to scheduled work.
  • The count endpoint's own predicate never constrained the column it grouped by, so no index served it — §13's second failure mode, an aggregate whose WHERE does not match the index that looks like it should serve it.
  • since disappears with it: the window becomes a property of the refresh job, not of the request, which is what removes the caller's ability to disagree with it.

Proposed fix (untested). Remove, on this branch:

  • LiveHuntResultCounts from resources.py, and live_results_count() from both api.py and aio/api.py.
  • The include_counts and since kwargs from ruleset_list() on both clients.
  • Their rows in specs/02-resources.md, specs/03-endpoints.md and specs/05-downstream-contract.md, their pins in hunt_tracking_builder_test.py, and the recorded interactions in both cassettes.

Keep name, status and favorites_only. Keep has_new_results too — it stays a server-side filter, re-implemented as a column predicate rather than an EXISTS, so this SDK surface is unchanged. live_feed(livescan_id=…) also stays; the feed scope is unaffected.

Then add new_results_counted_at to the YaraRuleset parse. §13 requires every client-visible count to carry an observable staleness marker, and without it a consumer cannot tell a fresh badge from one the refresh job stopped updating an hour ago.

[MODERATE] F2. live_feed(since=…) is documented in minutes; the server reads seconds

What happens: A caller passing since=60 to live_feed expecting the last hour gets the last minute. The window is 60× narrower than the docstring promises, silently — the request succeeds and returns a short list.

When: Any live_feed call that passes since, today.

Why:

  • aio/api.py:510 reads "Fetch results from the last since minutes".
  • The server converts the same parameter with timedelta(seconds=since).
  • This PR documents two adjacent windows in seconds — live_results_count at :532 and ruleset_list at :715 — so a consumer wiring one UI control to the hunt page reads three since params with two stated units.

Proposed fix (untested): correct :510 to seconds, and state it in the specs/03-endpoints.md row for live_feed. Worth noting in the same docstring that the server is tightening since from a truthiness test to is not None, so since=0 moves from "all time" to "empty". stream(since=…) at :1006 also says minutes — a different endpoint that I did not verify; someone should.

[MODERATE] F3. community rides the PUT body on the favorite toggle

Raised in the 24 Aug review and still open — the last commit predates that comment. RESOURCE_ID_KEYS = [] routes every kwarg into json, so community lands in the body where every other ruleset call sends it as a query param. The single-community e2e stack cannot catch it: a cross-community star would silently target the default and every assertion would still pass. RESOURCE_ID_KEYS = ['community'] yields exactly the intended split — community to the query, id/favorite to the body — and keeps the deviation note in specs/02-resources.md true. Otherwise confirm the server reads it from the body and say so in that note.

  • [LOW] F4. new_results_count's documented contract — "only present when the list was asked to include counts" in resources.py, repeated in specs/02-resources.md — becomes wrong once the field is a stored column that is always rendered. Fix (untested): reword in both; the field is always present and None means "not yet refreshed".
  • [LOW] F5. The new respx body is transport-agnostic but sync-only, against specs/04-testing.md invariant 5. Open from the 24 Aug review. Fix (untested): move it onto ClientTestCase so both siblings run, or state the exemption in its docstring.
  • [LOW] F6. poll_equals guards two read-after-writes and not the other four, two of which read back live_start/live_stop one line later on the same replica. Open from the 24 Aug review. Fix (untested): wrap them, or comment why those four are lag-immune when the other two are not.
  • [LOW] F7. The branch name carries an internal ticket id, which a non-squash merge writes into public history. Worth noting the tension: the cross-repo CI seam matches on branch name, so the prefix is load-bearing there, while this repo's AGENTS.md keeps internal refs out of public history. A squash merge with a clean subject satisfies both and needs no rebase.

Standards conformity

§14 — conformant. This is the SDK leg of a capability landing with its API, on the identical branch name, with ## Requires and the correct merge order stated. That is exactly the pattern §14 requires.

§13 — F1 is the gap, and it is a gap in the server design this PR faithfully wrapped rather than a mistake made here. Withdrawing the two surfaces and adding the staleness marker brings this repo into line.

One §14 note that lands on the CLI rather than here: ruleset_favorite() ships in this PR with no corresponding CLI command, so that capability currently has two of its three legs. Raised on the CLI PR.

Fixes are proposed, not applied; nothing was run.

@vhmartinezm

Copy link
Copy Markdown
Contributor Author

Review applied — head 050de01

  • F1: LiveHuntResultCounts, live_results_count() (both clients) and the include_counts/since kwargs are gone; name/status/favorites_only/has_new_results and live_feed(livescan_id=) stay; new_results_counted_at is parsed beside the counter (specs 02/03/05, builder tests and both cassettes updated — cassettes re-recorded against the server branch's image).
  • F2: live_feed's since is documented in seconds, including the truthiness→is not None tightening; the specs/03 row says it too. (stream()'s docstring makes the same minutes claim — left alone here as a different endpoint nobody verified.)
  • F3: RESOURCE_ID_KEYS = ['community'], with one correction to the rationale after verifying against the server: the middleware reads community from the query or the body (never both), so a body-riding value was honored, not defaulted — the real hazard of the default ['id'] is id moving to the query (a 400). The comment/spec/tests state that verified reason; the split is pinned by a new dual-transport respx suite.
  • F4: reworded — the counter is always present on list rows; None means "not yet refreshed".
  • F5: the FAVORITE_LIMIT respx test moved onto ClientTestCase (both transports), joined by the query/body-split test; specs/04's harness list updated.
  • F6: every remaining read-after-write in both rules tests polls (poll_equals/_async), including the two one-line-later live_start/live_stop read-backs.
  • F7: agreed — squash-merge with a clean subject on the day.

@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

Review

Base is develop, no pyproject.toml version touched — gitflow clean per AGENTS.md §Gitflow / specs/05 invariant 6. RESOURCE_ID_KEYS = ['community'], the _params body/query split, the 1/0 bool coercion, and the FAVORITE_LIMIT → request.errors path all check out against core.py, and both cassettes carry the wire shapes the tests assert. Four things need action.

1. poll_equals(..., want=None) turns a 404 into a passing assertion

test/_e2e_helpers.py:376 — "not-found during the lag window counts as not yet" only holds while want is not None:

except (NotFoundException, NoResultsException):
    value = None
if value == want:
    return value

Two call sites pass want=None:

  • test/client_scan_test.py:523 — assert poll_equals(lambda: api.ruleset_get(rule_id).livescan_id, None) is None
  • test/async_client_test.py test_async_live — same shape via _stopped_livescan

If ruleset_get 404s (ruleset gone, wrong id, community mismatch), the helper returns None on the first iteration and the assertion passes. The code this replaced (stopped = api.ruleset_get(rule_id); assert stopped.livescan_id is None) would have raised. That is a silent coverage regression on the live-stop teardown contract. Either let the exception propagate when want is None, or use a sentinel for "not found" so it can never compare equal to want.

2. The PR description advertises a public surface that is not in the diff

The body promises LiveHuntResultCounts, live_results_count(since=), and ruleset_list(..., since=, include_counts=) with new_results_count "only when the list was asked to include counts". None of those exist — grepping live_results_count, LiveHuntResultCounts, include_counts across src/ test/ specs/ returns nothing, and the shipped model is the opposite one (a server-refreshed stored counter, no request-side window), which is what the code, both docstrings, and specs/02+03 consistently describe. polyswarm-cli#266 only needs ruleset_favorite / YaraRulesetFavorite, so nothing downstream breaks — but the description is what a reviewer and the eventual release notes read. Please update it to match the stored-counter design.

3. live_feed(since=) minutes → seconds is a documented-contract change with no compat note

src/polyswarm_api/aio/api.py:505 now documents since as SECONDS ("it previously documented minutes here") and notes the server is tightening truthiness to is not None, so since=0 becomes an empty window rather than all-time. specs/03 was updated; specs/05 was not. Per specs/05 §Versioning, "Behaviour change on a documented contract" is the major-bump row — the SDK is a passthrough here so I do not think it forces one, but a consumer who read the old docstring and passed 60 meaning an hour now gets a minute. It belongs as an explicit line in specs/05 §"Backward compatibility — what changes" so the develop → master bump decision sees it, instead of living only in a docstring.

4. The source_rule_changed=False poll does not do what its comment claims

test/client_scan_test.py:674 and the async twin:

# ... poll so a lagging replica ... can't flake these — especially the
# changed-since-freeze flip, whose stale read is a silent False
assert poll_equals(lambda: api.historical_get(hunt.id).source_rule_changed, False) is False

Polling for False returns on the first False read, stale or not — zero protection against the silent-stale-False case the comment names. The rationale is correct for the True poll further down; here either drop the poll or drop the claim.


Minor: the branch name carries an internal ticket ID. AGENTS.md scopes the ban to commit messages / PR title / description (all clean), but GitHub's default merge-commit subject bakes the branch name into public history — the same leak the rule exists to prevent. Worth a squash-merge with a rewritten subject.

@vhmartinezm
vhmartinezm force-pushed the DN-8480-hunting-schema-migration branch 2 times, most recently from 13f4e48 to e292cc1 Compare August 25, 2026 22:38
@vhmartinezm

Copy link
Copy Markdown
Contributor Author

All four applied at head:

  1. poll_equals/poll_equals_async now REFUSE want=None (ValueError naming the trap), and both stop read-backs poll a boolean — a vanished ruleset fails the assertion again.
  2. The description was updated to the stored-counter design in the same push the code landed (the review snapshot predated the edit).
  3. The seconds clarification is recorded in specs/05 §Backward compatibility — what changes, distinguishing the docstring correction (wire never changed) from the server's since=0 tightening, so the develop → master bump decision sees both.
  4. The False-poll comment now states what the poll actually defends (the 404 window on a fresh hunt) and concedes it cannot defend against a stale False; the TRUE poll keeps the stale-read rationale.

On the branch name: agreed, squash-merge with a clean subject on the day (same note as the CLI PR).

@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md + specs/01–05. Base is develop, no pyproject.toml version bump (correct for a feature PR), commit messages carry no ticket IDs, specs 02/03/04/05 are updated in-PR, and both transports were regenerated consistently.

Verified in detail:

  • YaraRulesetFavorite.RESOURCE_ID_KEYS = [community] produces exactly params={community} / json={id, favorite} under core._params (PUT → non-param_keys go to the body), matching the builder test and the recorded cassette. Same pattern as the existing [hash] / [name] / [sha256] deviations, so no new mechanism.
  • FAVORITE_LIMIT: _raise_for_status runs _extract_json_body before raising, so exc.request.errors is populated on the 400 — the respx assertion holds, and specs/05's "no typed exception" note is consistent with core.py only special-casing KNOWN_GOOD on 404.
  • Cassettes look genuinely re-recorded (uniform 4.3.0/Darwin UA, no hand-edits, test key only), and the recorded request multiset matches the new test bodies request-for-request, so replay ordering under the [method,scheme,host,port,path,query] matcher is sound.
  • poll_equals/poll_equals_async sleeps are neutralised by the autouse _skip_poll_sleep_on_replay fixture (patches time.sleep/asyncio.sleep on the modules, which is what the helpers resolve at call time) — the docstring reference is accurate.
  • historical_create(int(rule.id)) / live_start(int(rule.id)) route correctly through _parse_rule's int branch.

One item, low severity:

specs/99-open-questions.md §2 is now contradicted by this PR and should be updated here. It states: "live_start returns a LiveYaraRuleset with livescan_id=None because the local e2e has no microengines processing submissions … livescan_id doesn't get assigned." The new test_rules / test_async_rules assert the opposite as a hard contract (poll_equals(lambda: api.ruleset_get(rule.id).livescan_id is not None, True)), and test/vcr/test_rules.vcr records "livescan_id":"72927285313305230" against the live stack. Per AGENTS.md ("Update the spec in the same PR as the code change; if a PR drifts from the spec, the spec is wrong until proven otherwise"), the stale half of §2 should be narrowed to what is still true on the e2e stack (no microengines → the feed stays empty, hence the acknowledged zero-result livescan_id feed check) rather than left claiming the id is never assigned.

Nothing else blocking.

@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

Review

Correctness, spec alignment, and the downstream contract all check out. Verified against the code rather than the prose:

  • YaraRulesetFavorite.update(id=…, favorite=…, community=…) → core._params("PUT", "community", …) really does put {"id": "<str>", "favorite": 1} in the JSON body and ?community= in the query — the recorded cassette (test/vcr/test_rules.vcr:178,197) matches the RESOURCE_ID_KEYS = ["community"] rationale in specs/02-resources.md, so the deviation from the AGENTS.md default is both real and documented.
  • _raise_for_status populates request.errors before raising the generic 400, so the FAVORITE_LIMIT envelope asserted in ruleset_favorite_respx_test.py is reachable exactly as specs/05 claims.
  • Unset ruleset_list filters drop out in _params, so the no-filter request stays byte-identical to the old one (cassette line 66 confirms ?community=gamma only).
  • _e2e_helpers.poll_equals's "sleeps are free on VCR replay" claim is backed by the real autouse conftest._skip_poll_sleep_on_replay fixture.
  • Base is develop, no pyproject.toml bump, no ticket IDs in commit messages — gitflow clean.

Three things worth acting on, none blocking.

1. The tri-state None arm of source_rule_changed is asserted nowhere (test coverage)

resources.py:841-845 and specs/02-resources.md both make the point emphatically — None is "unknown", never "unchanged" — and a consumer that renders it as "unchanged" is the exact bug the tri-state exists to prevent. But every cassette carries source_rule_changed: false or true (test_rules.vcr:706,926), and hunt_tracking_builder_test.py pins the absent-field arm only for new_results_count. Same gap for rule_count / historical_hunt_count, whose "no answer is not 0" contract the spec also spells out; the live rulesets always report 1 / 0..1.

One pure-unit parse test alongside TestYaraRulesetStoredCounterParse closes it — build a HistoricalHunt from a payload with no rule_id (only the bracket-accessed keys id, created, status, progress, results_csv_uri) and assert rule_id is None, rule_modified is None, source_rule_changed is None.

2. historical_delete failing skips ruleset_delete (test cleanup)

client_scan_test.py:697-700 / async_client_test.py:740-743 run historical_delete(hunt.id) and then ruleset_delete(rule.id) in the same unguarded finally. Any failure in the first (transient 5xx, or the server refusing a hunt already in DELETING) skips the ruleset teardown and leaks a ruleset on the shared e2e stack. The in-body comment explicitly leans on that ruleset_delete — "slot hygiene does not depend on reaching it — the finally's ruleset_delete soft-deletes" — so the guarantee should not itself sit behind an unprotected call. Wrap the historical delete in its own try with the ruleset delete in the inner finally.

3. test_rules / test_async_rules are now long-pole tests but are not scheduled as such (minor)

They gained live_start/live_stop plus ~6 poll loops, but neither nodeid matches any entry in conftest._LONG_POLE_FRAGMENTS ("live" does not substring-match test_rules). On the live TESTS_VCR=off run they will now backfill the fast tail alongside the unit tests, which is what that hint exists to avoid. Adding "rules" to the tuple fixes it and does not collide with anything else in the suite.

@vhmartinezm
vhmartinezm force-pushed the DN-8480-hunting-schema-migration branch from e292cc1 to 8b57309 Compare August 25, 2026 22:48
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Review — hunt-page ruleset tracking

Checked against AGENTS.md and specs/01–specs/05. Gitflow is correct (base develop, no pyproject.toml version touched — right per specs/05 §Release flow). The RESOURCE_ID_KEYS = ["community"] split, the None-vs-0 field semantics, and the sync mirror all line up with the specs, and the cassettes confirm the wire shape the pure-unit builders assert (id + favorite in the JSON body, ?community=gamma on the query). Four things worth acting on.


1. max_results never puts limit on a real wire — and MAX_PAGE_SIZE is an unverified claim

src/polyswarm_api/core.py:671 — MAX_PAGE_SIZE = 1000, commented "Mirrors the server AI_MAX_QUERY_RESULTS; asking above it is a 400."

Every test of this path stubs _paginate: TestLiveFeedMaxResults replaces it with iter(range(n)), and TestLiveFeedLimitOnTheWire replaces it with a capture returning an empty iterator. So self._params(max_results=10_000)["limit"] == core.MAX_PAGE_SIZE proves the client builds limit=1000; nothing proves /hunt/live/list accepts it. The recorded cassettes only ever show the server echoing "limit":50. If AI_MAX_QUERY_RESULTS is not 1000 for this endpoint, live_feed(max_results=1500) 400s for consumers and no tier of this suite catches it.

AGENTS.md §Testing: "test against real artifact-index endpoints via the e2e stack; mock only as a last resort." The docstring justification ("producing more feed rows than a page holds on the shared e2e stack would mean generating real live-hunt volume") covers the truncation test, not the page-size one — bounding a read does not require a feed bigger than a page. Concrete missing case: test_live (sync + async) already drives live_feed(since=…) live against a hunt with results; add a live_feed(max_results=1) arm there, plus one live_feed(max_results=core.MAX_PAGE_SIZE) call so the clamp value itself is exercised against the server at least once.


2. The favorite read-after-write does not poll, unlike every other one in the test

test/client_scan_test.py:615 / test/async_client_test.py:647

fav = api.ruleset_favorite(rule.id, True)                 <- write
favorites = list(api.ruleset_list(favorites_only=True))   <- replica read, no poll, no guard
assert any(r.id == rule.id for r in favorites)

This is the sharpest read-after-write in the test — the star is written on the line above — yet it is the only one that neither polls nor guards NoResultsException, while the has_new_results and status="active" lists a few lines below do both. On a real-replica stack a lagging read gives either the pre-star list (assert fails) or a 204 → uncaught NoResultsException escaping as a test error. The PR description claims "Every read-after-write assertion polls"; this one and the by_name list above it do not. Wrap it the way _active_ids() is wrapped and poll it.


3. specs/05 versioning table now contradicts this PR

specs/05-downstream-contract.md:437 — | Signature change on a public method | major |

This PR adds optional kwargs to two public methods (live_feed(…, livescan_id=, max_results=), ruleset_list(name=, status=, favorites_only=, has_new_results=)). That is additive and plainly minor, but read literally the table calls it major, and invariant 1 ("…or alter the signature of those symbols are breaking") says the same. The spec is what moved out of date, not the code — add a row distinguishing new optional keyword parameter appended to a public method → minor from altering / removing / reordering existing parameters → major, since this PR is now the precedent. Everything else additive here (new resource class, new endpoint method, new fields) already has a row and lands as minor.


4. Docstrings pre-commit a release number

src/polyswarm_api/aio/api.py:510 and src/polyswarm_api/api.py:577 say since "said minutes before 4.4". pyproject.toml is at 4.3.0 and the bump is the maintainer call on the develop → master step (AGENTS.md §Gitflow). Phrase it version-neutrally ("this previously documented minutes and was wrong") so the docstring cannot be falsified by whatever number the release actually gets.

MAX_PAGE_SIZE mirrored the server's AI_MAX_QUERY_RESULTS code default of 1000,
but the chart sets 300 in every environment — so live_feed(max_results=500)
would have sent limit=500 and got a 400. The cap is an env var the deployment
chooses, so the SDK cannot know it: max_results now bounds only how many
results the generator yields, and the request is unchanged (which also keeps
the default call byte-compatible with every cassette).

Also polls the favorite read-after-write — the star is written on the line
above, and it was the one such assertion here that neither polled nor guarded
204. One read per attempt, so the recorded interactions are unchanged.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Review

Base is develop, no pyproject.toml touch, conventional commits, no ticket IDs or private repo names in the title/body — gitflow is clean. Code matches the specs it updates: the RESOURCE_ID_KEYS = ['community'] split does what specs/02 claims (core._params routes id/favorite to the body on PUT and community to the query), the cassettes are freshly recorded live (real favorites_limit, source_rule_changed flip, string rule_id), test/eicar.yara has no remaining references, and the new-surface coverage follows specs/04's split (pure-unit builders + a ClientTestCase respx body for the two arms the stack cannot produce + live VCR lifecycle).

Four things worth acting on, none of them correctness bugs:

1. Version-bump decision is implied but never stated, and specs/05 §Versioning has no row that covers it.
live_feed() and ruleset_list() both gain optional keyword args. The table's nearest row is | Signature change on a public method | major |, with no additive-optional-parameter carve-out — while aio/api.py:510 and api.py:296 assert the next release is 4.4 (a minor). One of the two should move: either add a row (New optional keyword argument with a default | minor — no existing call site changes) so the additive reading is documented, or state the bump decision explicitly in the PR. Right now a reader applying the table literally concludes this is a major.

2. Docstrings hardcode a version this PR does not set.
aio/api.py:510 / api.py:296: "this said 'minutes' before 4.4 and was wrong". Per AGENTS.md §Gitflow and specs/05 invariant 6, the version is chosen at the develop → master step — if that release cuts as anything other than 4.4, the shipped docstring is wrong and nobody will notice. "in earlier releases" carries the same meaning with no forward reference.

3. PR description contradicts the code and specs/05.
The body says max_results "bounds a read and sizes the page request". It does not — specs/05 lines 330-338 explain at length why sizing the page was rejected (AI_MAX_QUERY_RESULTS is a per-deployment env var, over-asking is a 400), and TestLiveFeedLimitOnTheWire pins that no limit is ever sent. The description is stale relative to the fix(live-feed): stop sizing the page from max_results commit; worth fixing before merge, since the body is what reviewers and the linked CLI PR read.

4. Duplicated comment paragraph in both live tests.
In test/client_scan_test.py and test/async_client_test.py, the two-line comment "The star was written on the line above — the sharpest read-after-write here, so it polls like the rest (specs/04)." appears twice in a row, in both transports. Copy-paste artifact.

Minor: core.py's module docstring helper list (lines 18-19) was not updated with as_result_bound even though specs/01:45 was.

I could not run scripts/regenerate_sync.py in this environment, so mirror freshness for api.py rests on the CI staleness check; the added bound/yielded loop and the ruleset_favorite placement read as a faithful mirror by eye.

sbneto added 4 commits August 28, 2026 01:55
The two are independent: the page stays the server's to choose (50 for web,
capped by AI_MAX_QUERY_RESULTS) and _next_page echoes it, so a bounded read
keeps paginating in those same small chunks and stops once it has enough.
Sending limit=max_results conflated them — the 400 above the deployment's cap
was a symptom of that, not the reason.
The two-line read-after-write note was pasted twice in a row, in both the
sync and async live tests.
The Versioning table had no row for adding an optional keyword to an
existing public method, so the nearest match was `Signature change on a
public method | major` — which scores this change major even though every
existing call site keeps working untouched.

The table already carves out the additive exception cases on exactly that
reasoning ("no consumer has to change"); this applies the same rule to
keyword arguments, and narrows the signature row to the changes a caller
must actually react to.
Three accuracy fixes from an audit of the revert commits:

- The `since` docstring said the parameter "said minutes before 4.4".
  The version is chosen at the `develop -> master` step, not here, so if
  that release cuts as anything else the shipped docstring is wrong and
  nothing would catch it. "in earlier releases" carries the same meaning
  with no forward reference.
- `specs/05` described the CLI's `1440` default in the present tense
  inside a paragraph that is otherwise entirely historical. That default
  was retired in this same change set.
- `core.py`'s module docstring lists the pure helpers; `as_result_bound`
  was added to `specs/01` but never to the list beside the code.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/01–05. Clean — no correctness, spec-drift, or gitflow issues found.

Checks that passed:

  • Correctness. YaraRulesetFavorite.RESOURCE_ID_KEYS = ['community'] routes exactly as documented through core._params (PUT → id/favorite to input_json, community to query), and the recorded cassette confirms the server accepts the stringified id + 1/0 bool. FAVORITE_LIMIT lands on the generic RequestException with .errors populated by _extract_json_body before _raise_for_status's else arm. as_result_bound truncation is on the yield side only — no limit reaches the wire, and 0/negative correctly mean "no bound" rather than a bound of one. All new parse fields are content.get(...), so an older server leaves them None; parse_isoformat(None) is None.
  • Spec drift. specs/02/03/04/05 updated in-PR for the new resource, the endpoint rows, the two new test modules, and the core helper. specs/99 §2 corrected rather than left stale. Retiring test/eicar.yara leaves no dangling references.
  • Test coverage. Both the None-vs-0 arms and the tri-state None arm are pinned in hunt_tracking_builder_test.py (the cassettes can't carry them); the canonical async max_results loop is tested separately from the unasync mirror, which is the right call since that loop is the part unasync rewrites; the favorite respx suite is on ClientTestCase per specs/04 invariant 5 and argues its exemption from invariant 1 in its docstring. Filter assertions are universal, not presence-only.
  • Gitflow. Base is develop, no pyproject.toml version bump, no ticket IDs or private repo names in the title/body/commits.

One item for the maintainer, not a defect:

  • This PR edits the bump-policy table in specs/05 (splitting the old blanket Signature change on a public method | major into a minor row for additive optional kwargs and a major row for changes a caller must react to). That is a change to the published bump policy itself, shipped alongside the feature that benefits from it. The reasoning is sound and consistent with the neighbouring exception rows, but it is worth an explicit ack — and the batch should be scored a minor at the develop → master step (new public symbols YaraRulesetFavorite, as_result_bound, ruleset_favorite, plus new optional kwargs on live_feed / ruleset_list), not a patch.

The CLI expresses its need for this change set's surfaces as a version
requirement — `polyswarm_api>=4.4.0` — rather than probing the installed SDK
at runtime. A pin is checkable by pip at install time, before any code runs,
and it cannot name a version this repo has not declared. So the bump lands
here, in the feature PR, instead of at the release step.

Minor, not major: every addition is additive — new methods, a new resource,
new optional keywords, new parsed fields — so no existing call site changes.

Bumped with bump-my-version and verified the emitted string is a clean
`4.4.0`: the serialize config can produce `4.4.0.devN+sha`, and PEP 440 orders
that BELOW 4.4.0, which would silently fail the CLI's floor and send its CI to
PyPI for a version that does not exist yet.

AGENTS.md and specs/05 record the exception and the ordering it forces: this
repo must release before the CLI can, since the CLI's floor is unsatisfiable
from PyPI until then. Consumer CI is unaffected — it installs this repo from
git by branch name.
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Review — clean on correctness; three process/doc items

Read AGENTS.md plus specs/01-05. The SDK code holds up against the documented conventions:

  • as_result_bound in core.py is the single definition, both transports import it, and the sync mirror at api.py:590 matches the canonical aio/api.py:523 (specs/01 Layer 1 — pure, not unasync-processed).
  • YaraRulesetFavorite with RESOURCE_ID_KEYS = [community] produces exactly the split specs/02 describes — verified against core._params (PUT: id/favorite to the body with id stringified and bools coerced to 1/0; community to the query) and against the recorded wire in test/vcr/test_rules.vcr:266 ({"id":"...","favorite":0}, ?community=gamma).
  • max_results genuinely does not touch the descriptor, so the default live_feed() request stays byte-compatible with the cassettes — confirmed: test_rules.vcr:580 records hunt/live/list?livescan_id=...&community=gamma with no limit.
  • since=0 reaches the wire (_params drops only None, _normalise_bool_params leaves ints alone), which is what the absent-or-0 contract in specs/03 requires.
  • No leftovers from the withdrawn revision: live_results_count / LiveHuntResultCounts / include_counts appear nowhere in src/, test/ or specs/.
  • FAVORITE_LIMIT reaches exc.request.errors via the existing core.py:387 envelope extraction, as specs/05 now documents.
  • Version strings are consistent across pyproject.toml (version + current_version) and __init__.py, all clean 4.4.0 — no .devN+sha. Cassette user-agents still say 4.3.0, harmless given the [method, scheme, host, port, path, query] matcher convention.

1. The bump is authorized by policy this PR writes (gitflow)

AGENTS.md Gitflow said "Do not bump the version inside a feature PR unless the maintainer specifically asks". This PR bumps to 4.4.0 and adds the standing exception that permits it and relaxes the specs/05 versioning table (Signature change on a public method | major narrowed, plus a new "optional keyword -> minor" row) that scores the change minor. The reasoning in each is sound in isolation — an added optional kwarg really is additive, and a downstream >= floor really cannot name an undeclared version — but the authorizing rule and the act it authorizes land together, so nothing external gates it. Please get the maintainer ack on record in the PR thread, and confirm the AGENTS.md bullet is intended as standing policy rather than a one-off for this change set.

Worth restating for the merge queue since it follows from the new text: develop carrying 4.4.0 publishes nothing (PyPI fires on a version change on master), so the CLI floor polyswarm_api>=4.4.0 in polyswarm/polyswarm-cli#266 stays unsatisfiable from PyPI until a develop -> master PR merges. specs/05 documents this correctly; the PR description ordering ("server first, then this PR to develop, then the CLI") omits that release step.

2. Head branch leaks an internal ticket ID

The branch is DN-8480-hunting-schema-migration. AGENTS.md Commit + PR hygiene: "Do not reference ticket IDs or internal project codes in commit messages, PR titles, or PR descriptions... This repo is public; published artefacts should not leak internal references." The title and body are clean, but a merge commit bakes Merge branch DN-8480-... into develop — and then master — history, which is the artefact the rule protects. Squash-merge with a clean subject, or rename the branch before merging.

3. TestLiveFeedLimitOnTheWire can no longer fail

TestLiveFeedLimitOnTheWire and TestAsyncLiveFeedMaxResults.test_no_limit_is_ever_sent in test/hunt_tracking_builder_test.py are now tautological: max_results never reaches LiveHuntResult.list, and the stubs replace _paginate wholesale, so _next_page — the only code that ever sets a limit param (aio/api.py:199-202) — never runs. The claim in the docstring that it "fails without it (verified)" was true of the revision that sent limit=page_size_for(...) and is stale now. Harmless as a guard, but either drop it or reword the docstring so it does not claim coverage it no longer provides.

Minor: AGENTS.md step 1 now has a counterexample

AGENTS.md:91 ("When adding a new resource"): "If the resource identifier is not id, set RESOURCE_ID_KEYS = [your_key]..." — YaraRulesetFavorite is the first resource where the key is not the identifier at all; community is routed to the query precisely so id can stay in the body. specs/02:234 already frames the attribute correctly ("keys that must go to the query string"), so it is just the orientation doc one-liner (and the specs/02:244 inline comment) that under-describes the mechanism. A clause would keep it honest.

Both AGENTS.md and the specs/02 inline comment framed the attribute as "set
this when the identifier isn't `id`". `YaraRulesetFavorite` is the first
resource where the key is not the identifier at all: it names `community` so
that `id` stays in the PUT body, because the default would move `id` to the
query string and the server 400s. specs/02's prose and its per-resource entry
already describe it correctly; the orientation doc and the code comment were
the two places still under-describing the mechanism.
@sbneto

sbneto commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

On the two process points from the last review, both answered rather than deferred:

Maintainer ack for the in-PR bump, on record. Requested and given explicitly by the maintainer, along with the decision this change set implements: cross-repo feature dependencies are expressed as a version contract — the consumer raises its floor and pip enforces it at install time — never as runtime probes or per-test skip guards. The bump to 4.4.0 is what that floor names, and the sibling CLI PR deletes the probing apparatus it replaces.

Yes, the AGENTS.md bullet is standing policy, not a one-off. The maintainer's words were that this is an org-wide pattern, and it is now written as one: specs/05-project-standards.md §16 in the workspace repo (polyswarm/devenv#44) states it project-agnostically, and this repo's AGENTS.md plus specs/05 carry the concrete instance. The circularity you flagged is real as a matter of sequence — the authorizing rule and the act land together — but the authority is external to the PR, not self-granted by it.

Merge ordering, corrected in the description. You were right that it omitted the release step: develop carrying 4.4.0 publishes nothing, so the CLI's >=4.4.0 floor stays unsatisfiable from PyPI until a develop → master PR merges here. The ## Requires section now says so.

On TestLiveFeedLimitOnTheWire — checked, and it still discriminates. I re-introduced the exact regression (limit=max_results on the initial descriptor in aio/api.py) and test_no_limit_is_ever_sent failed, alone. The stubs do bypass _next_page, but that path legitimately echoes the server's own page size and is not what these tests claim to cover. I also could not find the "fails without it (verified)" docstring the finding cites — it is not in test/ or src/. Left as-is.

RESOURCE_ID_KEYS: fixed in a6b6c39 — the orientation doc and the specs/02 code comment both described it as an identity declaration; it is a routing list, and YaraRulesetFavorite is the counterexample.

@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs 01–05, with the cassette interaction sequences walked against both test bodies. Clean on the documented conventions — no correctness findings in library code, and I could not find spec drift:

  • as_result_bound is pure and lives in core.py, listed in both the module docstring and specs/01 §Helpers; 0/negative/None collapse to one definition read by both halves of live_feed, and TestLiveFeedLimitOnTheWire pins that nothing reaches the wire as limit, so the default call stays byte-compatible with every cassette.
  • YaraRulesetFavorite narrowing RESOURCE_ID_KEYS to community matches core._params routing (community to the query, id/favorite to the body, bool coerced to int), and test/vcr/test_rules.vcr:178,197 records exactly PUT /hunt/rule/favorite?community=gamma with a body carrying only the stringified id and favorite: 1. AGENTS.md step 1 and specs/02 now describe the attribute as a routing list rather than an identity declaration, which is what this resource actually needs.
  • New endpoints landed in the canonical aio/api.py and the mirror is regenerated; test-unasync-mirror gates it. ruleset_favorite sits in specs/03 _single table, live_feed/ruleset_list in the _paginate one.
  • Test tiers follow specs/04: live-VCR lifecycle on both transports, pure-unit builder/parse pins for the arms the stack cannot produce (populated stored counter, all-None provenance), respx ClientTestCase only for FAVORITE_LIMIT and the query/body split — both genuinely unreachable on a shared single-community stack. Cassette request sequences match the test bodies exactly (25 sync / 24 async, including the extra historical_get the sync body has and the async one does not), so they were recorded from this code, not hand-edited. TestAsyncLiveFeedMaxResults covering the canonical async for loop rather than only the mirror is the right call — that loop is the part unasync rewrites rather than copies.
  • TestLiveFeedSinceOnTheWire correctly pins that since=0 reaches the server (_params drops None, not falsy), which is what makes the absent-or-0 contract in specs/03/specs/05 real rather than aspirational.
  • Base is develop. The 4.4.0 bump is the AGENTS.md §Gitflow standing exception, added in the same PR and cross-referenced from specs/05, with the PEP 440 dev-suffix trap called out; pyproject.toml (both version and the bumpversion current_version) and __init__.py agree and no stale 4.3.0 remains. The specs/05 Versioning table change — added optional keyword scored minor, signature row narrowed to changes a caller must react to — is consistent with the additive-exception rows already there.
  • Withdrawn surfaces are fully gone: no references remain to live_results_count, LiveHuntResultCounts, include_counts, or test/eicar.yara anywhere outside the specs/04 line recording the retirement.

Two minor items:

1. The PR description names a parameter that does not exist. The body says ruleset_favorite(id, favorite); the actual signature (and specs/03) is ruleset_favorite(ruleset_id, favorite=True). Harmless in prose, but the linked CLI PR is written against this description — if it calls api.ruleset_favorite(id=..., favorite=...) by keyword it will TypeError at runtime, and nothing in either repo CI catches that before the paired merge. Worth fixing the description and confirming the CLI passes it positionally or as ruleset_id=.

2. assert fav.favorites_limit == 5 hardcodes a server-owned number in a live test. Both test_rules and test_async_rules pin the cap exactly, commented as "the fixed product cap, no plan scaling". This is the same shape as the bug this PR already fixed at a3eb7cb: MAX_PAGE_SIZE mirrored the code default of AI_MAX_QUERY_RESULTS (1000) while the chart sets 300 in every environment. If the favorite budget is likewise settable per deployment, the pin fails against a live stack with TESTS_VCR=off — which the specs/04 invariant "tests must pass against the live e2e stack with VCR off" makes a real failure, not a config quirk. If it is a code constant with no env override, ignore this; otherwise assert favorites_limit >= 1 and keep only the used-vs-limit relation.

Relatedly, the star itself is unconditional: on a live run where the team five slots are already spent (these two tests each take one, and a hard-killed run leaks one past the finally that would otherwise soft-delete it free), ruleset_favorite(rule.id, True) raises FAVORITE_LIMIT and the test fails rather than skipping. Low probability, but it is the one assertion in this test with no guard.

`favorites_limit == 5` mirrors a code default the deployment can override —
`AI_FAVORITE_RULESETS_LIMIT` is read from the environment — which is the same
mistake as the MAX_PAGE_SIZE constant this branch already removed: the client
asserting a server-owned number it cannot see. Against a live stack with VCR
off and a different cap configured, the exact pin fails on a correct server.

Assert the relation instead; the used-vs-limit check beside it still carries
the meaning.
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/01–specs/05. Code side is clean: YaraRulesetFavorite's RESOURCE_ID_KEYS = ['community'] produces exactly the params={community} / json={id, favorite} split core._params implies for a PUT; as_result_bound + the yielded >= bound loop mirror correctly through unasync (verified api.py:590-607 against aio/api.py); the FAVORITE_LIMIT path works because _raise_for_status calls _extract_json_body before raising, so exc.request.errors really is populated on the 400; all new resource fields are .get()-based and the re-recorded cassettes match the assertions; and test_live / test_async_live still replay against their unmodified cassettes since poll_equals issues one read when the first value matches. Version string is a clean 4.4.0 in both pyproject.toml and src/polyswarm_api/__init__.py — no .devN+sha form — and the base is develop.

Three things worth action:

1. Spec drift — poll_equals / poll_equals_async are undocumented (specs/04-testing.md).
specs/04 §Test-file inventory gained bullets for the two new test modules and the eicar.yara retirement, but the new shared e2e helper is not mentioned anywhere, even though specs/04:268 documents its sibling convention (run_concurrently / run_concurrently_async in _e2e_helpers.py) explicitly. That matters more than usual here because the helper carries a load-bearing, non-obvious invariant — want=None is refused, because a 404 inside the lag window maps to value = None and would compare equal, turning a vanished resource into a passing assertion — and there are now six call sites across both suites relying on it. AGENTS.md: "Update the spec in the same PR as the code change." Add a bullet under the read-after-write / parallelism discussion covering: when to poll, the want-must-not-be-None rule, the "poll a boolean instead" workaround, and that the False poll cannot ride out a stale False.

2. Bump policy — the PR grants itself the exception it relies on; needs maintainer sign-off.
Pre-PR AGENTS.md read "Don't bump the version inside a feature PR unless the maintainer specifically asks — version bumps belong to the develop → master step." This PR adds the standing-exception paragraph, splits the specs/05 bump-table row (Signature change on a public method | major → new-optional-kwarg = minor), and performs the 4.4.0 bump in the same change. The reasoning is sound and the mechanics are right, but a policy amendment that authorizes its own commit is the one thing a reviewer shouldn't rubber-stamp — please get explicit maintainer confirmation on the AGENTS.md and specs/05 policy edits, separately from the SDK surface. (For what it's worth: >=4.4.0 genuinely cannot be satisfied by an undeclared version, and the PR body already states the forced release order correctly.)

3. Minor — the new create-response assertions widened the ruleset leak window.
client_scan_test.py:942-945 and async_client_test.py:695-698 add rule_count / favorite / favorited_at / historical_hunt_count assertions above the try: whose finally: does ruleset_delete. A failure on any of the four leaks a ruleset on the shared stack — which is exactly the "slot-hygiene guarantee" the surrounding comments lean on. The two pre-existing name / yara assertions had the same shape, so this is a widening rather than a new bug, but the fix is one line: open the try: immediately after ruleset_create and move all six assertions inside it.

Six create-response assertions sat above the `try:` whose `finally:` deletes
the ruleset, so any of them failing leaks one on the shared e2e stack. Two of
them predate this branch; the four tracking-field assertions widened it. The
cost is not abstract: a leaked STARRED ruleset holds one of the team's five
favorite slots, and the favorite tests need a free one. `try` now opens
immediately after `ruleset_create`, in both transports.

specs/04 gains the `poll_equals` / `poll_equals_async` entry it was missing
while documenting its sibling `run_concurrently`. It carries a non-obvious
invariant worth writing down: `want` must never be None, because not-found
during the lag window also reads as None and would turn a vanished resource
into a passing assertion — poll a boolean instead. Also records the
mirror-image limit, that a False poll cannot ride out a stale False.

AGENTS.md now names where the bump exception comes from: it is this repo's
instance of a workspace-level standard, not a rule the repo grants itself.
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/01–specs/05. This is clean on every dimension I check — one nit below.

What I verified

  • Architecture / layering. as_result_bound in core.py (pure, non-unasync'd, imported by both transports), resources stay descriptor-only, the two new endpoint methods land on aio/api.py and mirror correctly. Diffed the canonical live_feed/ruleset_list/ruleset_favorite against the generated api.py: the async for → for rewrite, the bound/yielded prologue, and the in-loop return are all identical in effect. No hand-edits visible in the mirror.
  • Request shaping. YaraRulesetFavorite.RESOURCE_ID_KEYS = ['community'] produces exactly params={'community': ...} / input_json={'id': '5', 'favorite': 1} through core._params (v is not None keeps favorite=False → 0; non-POST + not-in-param_keys → body). Matches the builder test, the respx pin, and the recorded cassette (PUT /hunt/rule/favorite?community=gamma, ×2 for star/unstar).
  • max_results semantics. Bound is per-generator, never reaches the wire as limit (pinned), and 0/negative/None all preserve the historical unbounded behaviour — so no existing caller's results move. The canonical async loop is tested directly rather than only through the mirror, which is the right call given that's the part unasync rewrites.
  • Cassettes. Re-recorded live, not copied: test_rules.vcr carries every new call (?name=, ?favorites_only=1, ?has_new_results=1, ?status=active, /hunt/live/list?livescan_id=, both favorite PUTs) plus real source_rule_changed false→true transitions and favorites_limit: 5. Poll counts are convergent, so replay consumes interactions in the recorded order.
  • poll_equals / poll_equals_async. The want=None refusal is the right guard (404→None would otherwise make a vanished resource pass), and None == False is falsy in Python so the want=False polls don't short-circuit on a 404. Sleeps are no-op'd on replay via the existing _skip_poll_sleep_on_replay fixture (module-level import time, so the monkeypatch reaches it).
  • _LONG_POLE_FRAGMENTS addition. "_rules" is correct, and the comment's reasoning holds — "rules" really is a prefix of "ruleset", and _long_pole_rank matches against the full nodeid, so the bare form would have mis-ranked the instant respx suite.
  • Downstream contract / gitflow. Base is develop. 4.4.0 is a clean PEP 440 release string in all three places ([project].version, [tool.bumpversion].current_version, __init__.__version__) — no .devN+sha leak, so the CLI's >=4.4.0 floor is satisfiable once develop → master lands. Every surface change is additive (new attributes default to None, new kwargs default to current behaviour), so minor is right, and specs/05 was updated to say so explicitly rather than leaning on the old blanket "signature change = major" row. specs/01–05 + 99 all updated in-PR; specs/04 correctly records the eicar.yara retirement (no other reference to it remains anywhere in the tree).

One thing to fix

  • test/client_scan_test.py:610 and test/async_client_test.py:633 — the trailing (F9) is an opaque internal review/finding code in a public repo. AGENTS.md keeps internal references out of published artefacts ("This repo is public; published artefacts shouldn't leak internal references"); the rule is written for commits/PR text, but a committed source comment is at least as durable. The sentence reads fine without it — drop the token (the neighbouring "the MAX_PAGE_SIZE mistake again" is at least self-explanatory from context).

One thing to confirm, not fix

  • The new AGENTS.md standing exception + specs/05 §"One case always asks" relax the "no version bumps in feature PRs" rule for all future PRs, and this PR is the first user of the exception it introduces. The reasoning is sound and the PR body frames it as an instance of a workspace-level standard rather than a local grant — just worth an explicit maintainer ack on the policy text, separate from the code review.

Two comments carried an "(F9)" review token. AGENTS.md keeps internal
references out of published artefacts; the rule names commits and PR text, but
a committed source comment outlives both. The sentences read the same without
it.
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Review — clean against the documented conventions

I read the source diff, all seven spec edits, both re-recorded cassettes, and the three test tiers against AGENTS.md + specs/01–05. I found no correctness, contract, or coverage issues that need action. Notes below are verification, plus one nit and one maintainer-decision flag.

Verified

  • Request shapes match the wire. YaraRulesetFavorite's RESOURCE_ID_KEYS = ['community'] produces exactly what the cassette records: PUT /hunt/rule/favorite?community=gamma with body {"id":"…","favorite":1} (test_rules.vcr:178,197), and the server answers 200. The core._params id→str and bool→int coercions are pinned deliberately in hunt_tracking_builder_test.py:47,53 and ruleset_favorite_respx_test.py:56, so a coercion change can't drift silently. The RESOURCE_ID_KEYS-as-routing-list reframing in AGENTS.md and specs/02:348 is the honest description of _params' actual behaviour.
  • max_results is a total, not a page size. TestLiveFeedLimitOnTheWire and the async twin assert 'limit' not in params for every bound form, which is the right pin given _next_page echoes request.limit — sending the bound as limit would have collided with AI_MAX_QUERY_RESULTS. as_result_bound handles None/0/negative uniformly and TestLiveFeedSinceOnTheWire correctly pins that _params drops None but not 0, which is what makes the documented "absent-or-0 = no filter" contract reachable.
  • The canonical async loop is covered, not just the mirror. TestAsyncLiveFeedMaxResults exists precisely because async for + yielded/return is what unasync rewrites rather than copies. Good call — a mirror-only test would have passed on a broken source.
  • Downstream contract + bump. specs/05 adds as_result_bound, YaraRulesetFavorite, the FAVORITE_LIMIT no-typed-exception decision (400 → RequestException, envelope at exc.request.errors, pinned dual-transport), and the new bump-table row for additive optional kwargs. live_feed/ruleset_list only gain trailing keyword args, so no existing call site reacts — minor is right. Both version strings are clean 4.4.0 (pyproject.toml:7,58, __init__.py:2), not the .devN+sha form the serialize config on pyproject.toml:64-67 can emit.
  • Cassette hygiene. Recorded against a stack carrying the paired server branch; the three identical hunt/rule/list?community=gamma interactions replay in recorded order, and every poll_equals site recorded exactly one interaction (first-try success), so replay is deterministic. poll_equals' want is None refusal is a real guard, not ceremony — the helper maps a lag-window 404 to None, so want=None would have turned a vanished ruleset into a pass, which is exactly what the old direct assert stopped.livescan_id is None would have caught and a naive poll would not.
  • Base is develop ✓. No ticket IDs in the PR title, body, or commit messages ✓. Sync mirror is consistent with aio/api.py (same method ordering, same imports).

Nit (scheduling only, no correctness impact)

test/conftest.py:124 — the "_rules" long-pole fragment still over-matches. The comment reasons carefully about why "rules" was rejected (it's a prefix of ruleset), but "_rules" is itself a substring of "_ruleset", so it matches this PR's own instant pure-unit test hunt_tracking_builder_test.py::TestProvenanceAndCounterAbsentArms::test_ruleset_counters_absent_is_none_never_zero and front-loads it. Harmless — the hint is explicitly documented as scheduling-only — but the comment's stated reasoning no longer holds. Listing the two fragments explicitly ("test_rules", "async_rules") would match exactly the intended pair.

Maintainer decision

The version bump is legitimate under the standing exception, but this PR adds that exception to AGENTS.md (line 43) and specs/05 in the same change that first relies on it — the pre-PR rule reads "Don't bump the version inside a feature PR unless the maintainer specifically asks." The rationale is sound and correctly stated (a >= floor can't name an undeclared version; the release ordering and the PEP 440 pre-release trap are both spelled out), so this just wants an explicit maintainer ack that the standard is being adopted here rather than a reviewer inferring it from the PR that introduces it.

The rules fragment has now been wrong three times — "rules" caught "ruleset",
"test_rules" missed "test_async_rules", and "_rules" is itself a prefix of
"_ruleset" so it front-loaded the instant unit tests. Every substring of those
two test names is a prefix of test_ruleset_*, so no fourth fragment fixes it.

Give the matcher a way to say "exactly this test" instead: a fragment starting
with "::" is matched as a nodeid SUFFIX. The two long poles now name themselves
exactly; every other fragment keeps substring matching, which is right for the
ones that legitimately span several tests.

Verified: test_rules and test_async_rules rank as long poles, the unit test
backfills to the tail.
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/01-05 + 99. Correctness and architecture look clean. Verified specifically:

  • YaraRulesetFavorite.RESOURCE_ID_KEYS = [community] does produce params={community: ...} / input_json={id: "5", favorite: 1} through core._params (on PUT, non-RESOURCE_ID_KEYS keys go to the body), and the recorded cassette (test/vcr/test_rules.vcr:178,197) confirms the server accepts that split.
  • New parse fields are all content.get(...), so an older server leaves them None as claimed; LiveYaraRuleset inherits them for free.
  • live_feed(max_results=) does not touch the request. TestLiveFeedLimitOnTheWire pins limit never reaching the wire, and TestAsyncLiveFeedMaxResults pins the canonical async loop rather than only the unasync mirror. Good instinct: the async for + yielded/return shape is exactly what unasync rewrites.
  • The poll_equals want-is-None refusal is real, and the time.sleep / asyncio.sleep monkeypatch in conftest._skip_poll_sleep_on_replay patches the module attribute, so the polls are free on replay.
  • The _long_pole_rank ::-suffix form correctly distinguishes ::test_rules from ...::test_async_rules; the uid fixture is not parametrised, so no [...] suffix breaks the endswith.
  • Spec updates land in the same PR for every area touched, and removal of test/eicar.yara has no remaining referents.

1. The version bump relaxes two documented policies in the PR that needs them, so it needs explicit maintainer sign-off.

pyproject.toml goes to 4.4.0, and the two rules that would have blocked it are edited in the same diff:

  • AGENTS.md:43 gains "The standing exception: a downstream floor that must point at this change."
  • specs/05-downstream-contract.md:441 splits the old "Signature change on a public method | major" row so that a new optional keyword scores minor.

The reasoning is sound on both counts: a >= floor cannot name an undeclared version, and added kwargs with behaviour-preserving defaults break no call site. But the pre-existing rule is "Do not bump the version inside a feature PR unless the maintainer specifically asks", and this PR grants itself the exception rather than citing a maintainer decision. Please get that confirmed on the PR before merge; it is the one item here a reviewer cannot settle from the diff. The release ordering the description spells out (SDK develop-to-master and the PyPI publish before the CLI can release) is correct and worth keeping.

2. as_result_bound is newly public for no consumer.

specs/05-downstream-contract.md:137 adds as_result_bound to the documented polyswarm_api.core surface, which puts a four-line internal helper under the "rename / removal of a public symbol = major" rule. Every sibling helper in core.py is underscore-private (_normalise_bool_params, _raise_for_status), nothing outside api.py / aio/api.py calls it, and the linked CLI PR does not either. Suggest _as_result_bound plus dropping the specs/05 entry (keep the specs/01 Helpers mention) so a later refactor is not a contract event. Minor placement note: it sits between BaseJsonResource and the Hash-helpers divider rather than in the Helpers block at the top, where specs/01 says it lives.

3. The public spec now carries production telemetry and deployment config values.

specs/05 Documentation-corrections publishes "~197k requests carrying since per 30 days from 10 distinct API keys and 12 user agents", and the new test comments name AI_MAX_QUERY_RESULTS (with "300 in the chart") and AI_FAVORITE_RULESETS_LIMIT. This is a public repo, and AGENTS.md Commit-and-PR-hygiene draws the line at published artefacts not leaking internal references. The decision is worth recording; the traffic figures and chart values are not needed to record it. Trimming to the conclusion would do it: re-basing the wire to minutes would silently widen the window of every existing caller 60x with no error, so the wire stays seconds.

4. Spec and test prose narrates the review history.

specs/05 argues with its own prior revisions ("An earlier revision of this document claimed...", "An earlier revision sent limit=max_results..."), and several new test comments do the same ("the MAX_PAGE_SIZE mistake again"). Per AGENTS.md a spec is the statement of intent, and "if a PR drifts from the spec, the spec is wrong until proven otherwise" -- superseded revisions belong in git history, not in the independently-readable document. The since-is-seconds and max_results-is-a-total notes should stay; the "an earlier revision said otherwise" framing can go. Same for 99-open-questions.md:265, where the correction reads as a changelog entry rather than the current state. Not blocking, but the file is long enough that a future reader has to work out which claim is live.

5. The branch name puts a ticket ID into public history.

Title, body and all 31 commit subjects are clean of ticket IDs, which is good. The branch is DN-8480-hunting-schema-migration, so a merge commit carries it onto develop in a public repo, which is what AGENTS.md Commit-and-PR-hygiene is protecting against. Squash-merge (or rename the branch) and it is a non-issue. Relatedly, "the internal artifact API change on the same branch name" in the description effectively publishes the same pairing.

Test coverage: no missing case worth naming. The two arms the e2e stack genuinely cannot produce (a populated new_results_count, and a full favorite budget) are covered by parse pins and the dual-transport respx suite respectively, and both test bodies say plainly what the zero-result livescan_id feed check does and does not prove. The universal all(...) form on the name-filter arm is the right call over presence-only.

…nternals

`as_result_bound` was listed on the documented public surface, which put a
four-line internal helper under "rename or removal of a public symbol = major".
Nothing outside this package calls it — not the paired CLI — and every sibling
helper in core.py is already underscore-private. Renamed to `_as_result_bound`
and dropped from the specs/05 surface listing; specs/01 still records it among
the helpers, where it belongs.

This repo is public, so two other things should not have been in it:

- Production telemetry. specs/05 published request volumes, distinct API-key
  and user-agent counts, and the observed value range for a parameter. The
  decision it supports — the wire stays seconds, because re-reading live
  traffic as minutes widens every window 60x silently — needs none of it.
- A deployment's configured page cap. The mechanism (a per-deployment env var,
  set well below a large bound) is what explains the 400; the number is not.

Also stops the spec arguing with its own drafts. "An earlier revision of this
document claimed…" and "the MAX_PAGE_SIZE mistake again" describe review
history, which git already holds; a spec should read as the current state so a
reader does not have to work out which claim is live.
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/01–05. No correctness, spec-drift, or coverage findings. What I checked:

  • Transport boundary / request shapes. YaraRulesetFavorite.RESOURCE_ID_KEYS = [community] produces PUT /hunt/rule/favorite?community=gamma with the id and the toggle in the JSON body — confirmed against both cassettes. RESOURCE_ID_KEYS is consumed only by core._params, so the deviation from the default has no other reach, and the specs/02 + AGENTS.md step-1 rewording ("a routing list, not an identity declaration") correctly generalises the rule rather than carving out an exception.
  • _params edge cases. favorite=False serialises to 0 rather than being dropped (only None is), since=0 reaches the wire (so the server truthiness test still means "no filter"), and every unset ruleset_list filter is omitted — the no-filter request stays byte-compatible with the old contract. All three are pinned in hunt_tracking_builder_test.py, including the explicit-False arm.
  • Error path. 400 → _raise_for_status → generic RequestException with .request.errors populated by _extract_json_body; the respx suite and the specs/05 FAVORITE_LIMIT paragraph agree, and "no typed exception" is the right call here — nothing pre-existing needs re-routing, unlike KnownGoodWithheldException.
  • Cassette/body agreement. Traced both live bodies request-by-request against the recorded interactions: 25/25 sync, 24/24 async, in order — including the two same-query PUT /hunt/rule/favorite calls, which replay positionally under the [method, scheme, host, port, path, query] matcher. The cassettes were re-recorded after the final body edits, so replay will not drift.
  • max_results. Bound applied client-side only; TestLiveFeedLimitOnTheWire pins that no limit reaches the request, and TestAsyncLiveFeedMaxResults covers the canonical async for + return shape that unasync rewrites rather than copies — the right place for it. _paginate fully buffers via session.execute, so abandoning it early leaks nothing.
  • test/eicar.yara removal. No remaining references in source, tests, CI, or docs.
  • Gitflow. Base is develop; no ticket IDs or private repo names in commit messages, PR title, or description.

Two notes, neither blocking:

  1. The version bump adds the rule it relies on. AGENTS.md previously read "Do not bump the version inside a feature PR unless the maintainer specifically asks," and this PR both bumps to 4.4.0 and adds the standing exception that permits it. The reasoning is sound — a polyswarm_api>=4.4.0 floor downstream cannot name an undeclared version, and a pin is checkable at install time in a way a runtime probe is not — and specs/05 records the forced release ordering. But since the PR is its own authority here, this wants an explicit maintainer ack rather than passing silently. The PEP 440 check the new text demands does hold: pyproject.toml version, bumpversion current_version, and __init__.__version__ all read a clean 4.4.0, no .devN+sha.
  2. Cosmetic. specs/05-downstream-contract.md picked up a stray blank line just inside the closing fence of the core.py helpers code block (after def parse_isoformat(date_string)).

I could not execute scripts/regenerate_sync.py in this environment. The sync mirror signatures, method ordering, and _as_result_bound import all match the canonical async source on inspection, but the CI staleness check remains the authority on that.

…om source

The bullet read as if any sibling needing a new surface justifies bumping the
version in a feature PR. It does not. The exception exists because a floor
cannot name a version this repo has not declared — which is only a problem for
a consumer whose CI installs this repo from source by branch and can therefore
adopt before publication.

A consumer that installs published artifacts has no such need: it adopts after
the release, and this repo bumps at its own release step as usual. Stating the
condition keeps the exception from being applied where it does not hold.
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/01–05 (architecture, resources, endpoints, testing, downstream contract). Correctness, transport shape, spec sync and test coverage all check out — details below. One actionable finding.

Actionable: the newly-documented bump procedure does not bump the version that ships

AGENTS.md now says: "Bump with bump-my-version and check the emitted string is a clean X.Y.0".

But pyproject.toml has exactly one file target — a [[tool.bumpversion.files]] block with filename = "src/polyswarm_api/__init__.py".

bump-my-version rewrites [tool.bumpversion] current_version (its own config key) plus the files it is told about. Nothing points it at [project] version on line 7 — which is the string setuptools packages, and the one AGENTS.md itself names as the release trigger ("PyPI release happens automatically when pyproject.tomls version changes on master").

So bump-my-version bump minor on this tree yields current_version = "4.5.0" and __version__ at 4.5.0, and leaves [project] version = "4.4.0". The develop → master release then builds a 4.4.0 artifact — a re-upload of an existing version, i.e. a failed or no-op release, with __version__ disagreeing with the installed distribution.

This PRs own bump touched all three (line 7, current_version, __init__.py), so the tree is correct today; it is the instruction that is incomplete, and the instruction is new in this PR. Fix: add a second [[tool.bumpversion.files]] block for filename = "pyproject.toml" whose search/replace templates the version = "..." line (anchored tightly enough not to also match current_version). Worth doing here, since the standing exception now routes real bumps through a feature branch where the person running the command is not the develop → master maintainer.

Note, not a blocker: the exception is added and exercised in the same PR

The base AGENTS.md reads "Dont bump the version inside a feature PR unless the maintainer specifically asks." This PR adds the downstream-floor exception and bumps to 4.4.0 under it. The stated precondition holds as described (polyswarm-cli#266 resolves this repo from source by branch name, so its polyswarm_api>=4.4.0 floor cannot name an undeclared version), the emitted string is a clean 4.4.0 with no dev suffix, and specs/05s bump table now scores an added optional keyword as minor — which is what every change here is. Still, a rule-change plus its first use in one PR wants an explicit maintainer ack rather than a reviewers.

What I checked and found clean

  • Transport shape. YaraRulesetFavorite.RESOURCE_ID_KEYS = [community] routes correctly through core._params on PUT: community to the query, id/favorite to the body with the bool-to-int coercion. Matches the recorded cassette exactly (body carries id as a digit string and favorite as 1; uri is .../hunt/rule/favorite?community=gamma). RESOURCE_ID_KEYS is read nowhere except _params, so naming a non-identifier key has no side effects — and AGENTS.md step 1 plus specs/02 were both corrected to describe it as a routing list rather than an identity declaration.
  • _as_result_bound. Correctly placed in core.py (Layer 1, pure, no I/O), private, documented in the module docstring and specs/01. None/0/negative to "no bound" preserves the historical behaviour, and bound is not None and yielded >= bound gets max_results=0 right (the is not None variant would yield exactly one — pinned by a test).
  • Codegen. live_feed, ruleset_list and ruleset_favorite in api.py are faithful unasync+ruff mirrors of the canonical aio/api.py; the _as_result_bound import mirrors too. No per-symbol carve-out needed, per specs/01.
  • Additivity. Both new keywords are appended to live_feeds signature with behaviour-preserving defaults, so no positional caller moves; every new resource field is a .get() that yields None on an older server. YaraRulesetFavorite is in specs/05s resource list; the FAVORITE_LIMIT no-typed-exception decision is recorded there with the exc.request.errors path, which _raise_for_status does populate on a 400 via _extract_json_body.
  • Test coverage. The bound loop is pinned on the canonical async generator, not just the mirror (TestAsyncLiveFeedMaxResults) — the right call, since async for plus return is exactly what unasync rewrites. limit is asserted absent from the wire across all bound forms. The None/absent arms the cassettes cannot carry (tri-state source_rule_changed, never-refreshed counters) are pinned on the parse. Explicit-False filters serialise to 0. since=0 is pinned as sent, which is what makes the truthiness contract observable. Both ruleset_list filter arms are universal, not presence-only.
  • VCR. The sync vcr_.VCR(...) relies on vcrpys default match_on, which is the [method, scheme, host, port, path, query] set the convention asks for; async sets it explicitly. Cassettes carry real e2e responses, poll sleeps are no-opd on replay, and no record_mode=none. The ::test_rules / ::test_async_rules long-pole fragments are matched as nodeid suffixes so they cannot catch the instant unit tests.
  • Hygiene. Base is develop. No ticket IDs or internal project codes in the title, body or any commit message; no private repo named. No AI-attribution trailers. test/eicar.yaras removal leaves no dangling references, and specs/04 records the retirement. The withdrawn per-request count surfaces (LiveHuntResultCounts, live_results_count, include_counts) are fully absent from code, specs, tests and cassettes.
  • Specs. Every touched area is updated in-PR: 01 (helper), 02 (resource plus the RESOURCE_ID_KEYS deviation), 03 (both endpoint rows, with the seconds and absent-or-0 contracts), 04 (both new test files, ClientTestCase users), 05 (exports, FAVORITE_LIMIT, the documentation-corrections section, the bump table, the floor exception), 99 (the corrected livescan_id claim). Recording in specs/99 that the old "livescan_id is never assigned" text "may never have been" true, rather than silently overwriting it, is a good catch.

Minor, ignore at will: specs/05 gains a stray blank line inside the Layer-1 helpers code fence (~line 137), and the max_results paragraph has a doubled em-dash clause ("since the cap is an env var each deployment sets — and set well below a large bound —").

@sbneto
sbneto merged commit dba4573 into develop Aug 31, 2026
2 checks passed
@sbneto
sbneto deleted the DN-8480-hunting-schema-migration branch August 31, 2026 20:55
@vhmartinezm vhmartinezm mentioned this pull request Sep 8, 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