Skip to content

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

Open
vhmartinezm wants to merge 25 commits into
developfrom
DN-8480-hunting-schema-migration
Open

Hunt-page ruleset tracking: favorites, rule counts, hunt provenance#321
vhmartinezm wants to merge 25 commits into
developfrom
DN-8480-hunting-schema-migration

Conversation

@vhmartinezm

@vhmartinezm vhmartinezm commented Aug 21, 2026

Copy link
Copy Markdown

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

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(id, favorite) — 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.
  • 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 :192ruleset_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/falsecore.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
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/0105. 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
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/01specs/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/02specs/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.pyYaraRulesetFavorite.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:618ruleset_list(name=uid) immediately after ruleset_create
  • client_scan_test.py:631ruleset_list(favorites_only=True) immediately after the star
  • client_scan_test.py:648ruleset_list(status='active') immediately after live_start
  • client_scan_test.py:672ruleset_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
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_LIMITrequest.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:523assert 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
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/0105. 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
@vhmartinezm
vhmartinezm force-pushed the DN-8480-hunting-schema-migration branch from 8b57309 to 68ec25c Compare August 25, 2026 22:49
@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

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

Overall: clean. Builder shapes check out against core._params (PUT + RESOURCE_ID_KEYS = [community]community to query, id/favorite to body; the recorded cassettes agree), the field parses are all .get()-additive, both transports mirror each other, all four touched specs were updated in-PR, and the base is develop with no version bump — correct per AGENTS.md ("version bumps belong to the develop → master step"). The new surface (ruleset_favorite, YaraRulesetFavorite, the new kwargs and resource attributes) is purely additive, so it is a minor bump for whoever opens the release PR.

Two things worth acting on:


1. name= and favorites_only= are pinned by presence-only assertions — an ignored param still passes (test/client_scan_test.py:597,610; test/async_client_test.py:630,644)

The PR calls this hazard out explicitly for live_feed(livescan_id=) ("it cannot tell a working filter from an ignored param") and dodges it for status=/has_new_results= by asserting the negative arm after the hunt stops. But the two new list filters only get:

by_name = [r.id for r in api.ruleset_list(name=uid)]
assert rule.id in by_name
...
favorites = list(api.ruleset_list(favorites_only=True))
assert any(r.id == rule.id and r.favorite for r in favorites)

Both hold identically if the server drops the query param and returns the unfiltered list — which is exactly the regression an SDK-side filter test should catch, and the one most likely to happen when the paired server change lands. Tighten to the universal arm:

by_name = list(api.ruleset_list(name=uid))
assert rule.id in {r.id for r in by_name}
assert all(uid.lower() in (r.name or "").lower() for r in by_name)
...
favorites = list(api.ruleset_list(favorites_only=True))
assert any(r.id == rule.id for r in favorites)
assert all(r.favorite for r in favorites)

No re-record needed — the requests are unchanged and both recorded responses carry a single row that already satisfies the stronger form (test/vcr/test_rules.vcr:111, :242; async twin at :111, :242).


2. The branch name carries the internal ticket ID into public history

DN-8480-hunting-schema-migration. AGENTS.md: "Don't reference ticket IDs or internal project codes in commit messages, PR titles, or PR descriptions. This repo is public." The title and body are clean, but a merge commit bakes the branch name into develop's history, and the body's "the internal artifact API change on the same branch name" points a reader straight at it. Squash-merge with a clean subject (or rename the branch) before it lands.


Smaller notes, no action needed:

  • assert fav.favorites_limit == 5 hard-pins a server-side product constant from the SDK suite — deliberate per the comment, just note it is a cross-repo tripwire that breaks here if the cap ever moves.
  • test_rules no longer exercises ruleset_update(rules=None) (it now always sends a body edit), so the name/description-only update path lost its live coverage. Generic _params None-drop behaviour, so low value.
  • Sync/async asymmetry: the sync body has an extra hunt_read = api.historical_get(hunt.id); assert hunt_read.rule_id == rule.id that the async twin lacks. Harmless, cassettes match.
  • I could not execute scripts/regenerate_sync.py or pytest in this environment, so the sync mirror was verified by reading rather than by regeneration — CI's stale-mirror check is the real gate. aio/api.py and api.py agree method-for-method on both changed endpoints.

@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

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

What I checked:

  • Request shapes. YaraRulesetFavorite.RESOURCE_ID_KEYS = [%s'community'%s] does exactly what the comment claims against core._params (PUT routes only param_keys to the query; id/favorite fall to the body, favorite via the isinstance(v, bool) → int(v) branch). ruleset_list filters and live_feed(livescan_id=) are GET, so everything lands in the query with None omitted — the no-filter request really is byte-compatible. req.input_json is the right attribute (PolyswarmRequest.__post_init__ relocates jsoninput_json).
  • Parse side. Every new field is .get(); parse_isoformat(None)None. The canned payloads in hunt_tracking_builder_test.py satisfy HistoricalHunt.__init__'s required keys (id/created/status/progress/results_csv_uri), so the None-arm tests won't KeyError.
  • Cassettes. Walked both test_rules.vcr (25 interactions) and test_async_rules.vcr (24) request-by-request against the test bodies — exact one-to-one match in order, including the poll loops, the 204 arms for has_new_results=1 and the scoped live/list, and the sync-only extra historical_get. No hand-editing smell; recorded values are internally consistent (same ruleset id throughout, favorites_used 1→0 across the toggle).
  • Withdrawn surfaces. live_results_count / LiveHuntResultCounts / include_counts / ruleset_list(since=) are gone from code, specs and tests — no leftovers. test/eicar.yara's only surviving reference is the specs/04 line documenting its retirement.
  • Spec parity. ruleset_favorite is in the _single table, ruleset_list/live_feed in the paginated one (specs/03's organizing invariant holds); specs/02 documents the RESOURCE_ID_KEYS deviation; specs/04's fixture inventory and harness list are updated; specs/05 carries YaraRulesetFavorite and the FAVORITE_LIMIT-has-no-typed-exception decision. New helpers live in _e2e_helpers.py, so the "no time.sleep() in test bodies" rule is respected.
  • Gitflow. Base is develop; pyproject.toml stays at 4.3.0. Correct — the surface is purely additive, so the bump is a develop → master decision.

One low-priority nit, take it or leave it:

  • live_feed's docstring (both transports) embeds an in-flight server change — "The server is also tightening the parameter from a truthiness test to is not None, so since=0 means an empty window, not 'all time'." That ships to PyPI and is wrong on one side of the server release or the other, with no way for a reader to tell which. The specs/05 "Documentation corrections" entry is the right home for the migration note; the docstring would age better stating only the stable fact (since is in seconds). Not blocking.

@vhmartinezm

Copy link
Copy Markdown
Author

All applied at head:

  1. Added TestProvenanceAndCounterAbsentArms alongside TestYaraRulesetStoredCounterParse — a HistoricalHunt parsed with no rule_id asserts all three provenance keys None, and a YaraRuleset with no counters asserts rule_count/historical_hunt_count None (never 0).
  2. Both teardown finally blocks now nest: the ruleset delete runs in an inner finally so it's reachable even if the historical delete raises — sync and async.
  3. Added "rules" to _LONG_POLE_FRAGMENTS.

Thanks for catching the teardown one — that was a real leak risk on the shared stack.

@sbneto

sbneto commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the hunt page's backend: a team-shared favorite star with a 5-slot budget, ruleset-list filters, a per-hunt "new results" badge kept in a stored column by a scheduled job, a per-hunt feed scope, and provenance linking each historical hunt back to the ruleset it froze — plus this SDK and the CLI that read all of it, and the deploy entries that run the jobs. 4 PRs on the shared branch name; 93 files, +7320/−1277 across the set. This member is 16 files, +2435/−366.

Severity: 0 HIGH · 4 MODERATE · 6 LOW. Prior feedback: 54 checked · 4 open.
Objective: met, with gaps — the hunt page's tracking columns, their write/read surface, and the SDK/CLI legs.

Fixes are proposed, not applied; nothing was run; findings verified by single-pass reading against the current heads.

Cross-repo coordination

Merge order: internal API → this PR → polyswarm/polyswarm-cli#266, with the chart deployed alongside the server image. §14: the API is the contract.

Surface Producer Consumer
live_feed(since=) unit + semantics internal API polyswarm-api → F3, F5

Coherence: F3's wording depends on F5's unit decision — F5 lands first and F3 states whatever unit it lands. Do not apply F3 in isolation, or the docstring will be corrected to a unit that is about to change again.

Findings (round 2)

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

[MODERATE] F3. The docstring documents the inverse of the server's since contract

What happens: A caller who passes since=0 to live_feed expecting an empty result gets the entire unfiltered feed. The published docstring, its sync mirror, and the downstream-contract spec all state the opposite, and the spec records it as a shipped server-side behaviour change for the release-bump decision.

When: Any live_feed(since=0) call, on this branch and after it.

Why:

  • aio/api.py:513 says the server is "tightening the parameter from a truthiness test to is not None, so since=0 means an empty window"; the sync mirror repeats it at api.py:579.
  • The paired server does no such thing — its live-feed view still applies the filter only when since is truthy, byte-identical to its default branch. Nothing in this change set alters it.
  • That truthiness test is the decided contract, not an oversight: since absent or 0 means "paginate over everything with no initial time filter". So the docstring does not merely describe an unimplemented change; it describes the inverse of the intended semantics.
  • specs/05-downstream-contract.md:312 states it as fact — "that one IS a server-side behaviour change".
  • The producer is right here and the consumer misreads it, so this repo moves (§14).

Lands in: polyswarm-api
Proposed fix (untested): delete the two sentences beginning "The server is also tightening" from live_feed's docstring in aio/api.py, regenerate the sync mirror with scripts/regenerate_sync.py, and drop the "Relatedly, the server is tightening…" clause from specs/05-downstream-contract.md. Replace the unit statement with whatever F5 lands rather than the current "SECONDS" text — see below.

[MODERATE] F5. The feed's time unit is wrong on the wire, and this repo documents the wrong half of it

What happens: A CLI user reads New live results (last 24h): 12 on a ruleset, runs the feed command, and sees far fewer than 12 with no error. The server reads since in seconds while every surface that surrounds it was written for minutes.

When: Every feed call that relies on the default window.

Why:

  • The server converts with timedelta(seconds=since); the CLI's default is 1440 — 24 × 60, a day encoded in minutes, written in 2022 against this SDK's docstring, which said "minutes" until this PR corrected it to "SECONDS".
  • The value was always right and the unit under it was wrong, so the agreed resolution is to make minutes the unit on the wire — a bug fix, not a preference.
  • That makes this PR's current correction the wrong half: specs/05-downstream-contract.md:306 now tells the story "live_feed(since=) was always SECONDS", which stops being true the moment the server changes.

Pre-existing, exposed here
Lands in: polyswarm-cli · Also touches: src/polyswarm_api/aio/api.py:510, src/polyswarm_api/api.py:576, specs/05-downstream-contract.md:306
Proposed fix (untested), the part that lands here:

  1. live_feed(since=…) docstring to minutes on both transports, then scripts/regenerate_sync.py for the mirror.
  2. Rewrite the specs/05 "Documentation corrections" entry — it currently argues the opposite. The honest framing is a wire fix: the parameter was always intended as minutes, the server read seconds, and the server is being corrected.
  3. Add max_results=None to live_feed on both transports. Purely additive — no default changes, so no existing caller's completeness moves. It caps the number of items yielded and sizes the page request to min(max_results, 1000) so a small ask fetches a small page instead of a full 50-row one. Scoped to live_feed: that is the surface this set widens, and putting it on every paginated method is a larger contract change this capability does not need.

Why max_results is worth having even though nothing is unbounded by default: the server has never run an unbounded query — its paginator caps every request at 50 rows, max 1000. What is unbounded is this library: _paginate follows cursors up to _MAX_PAGES = 10_000. That was never reachable while the feed defaulted to a 24-minute window; it becomes reachable now that the same default means 24 hours, and outright if someone passes since=0 for the everything-feed. max_results is the control for that, not a safety requirement.

Release note, not a version bump. AGENTS.md is explicit that version bumps belong to the develop → master step, and this PR correctly carries none. The unit change is a break — a caller passing an explicit since in seconds now gets a 60× wider window with no error — so it belongs in specs/05-downstream-contract.md as a behaviour change that the develop → master bump decision can see. No caller loses data; they get more.

One cassette to watch: client_scan_test.py:484 calls live_feed(since=600), which widens from 10 minutes to 10 hours. It filters to its own livescan_id and polls, so it should hold, and a freshly-booted stack has under 10 minutes of history either way — re-record it only if the recording stack has been up longer.

  • [LOW] F8. specs/99-open-questions.md:153 still says live_start leaves livescan_id unassigned on the local e2e stack. This PR's tests hard-assert the opposite (poll_equals(lambda: api.ruleset_get(rule.id).livescan_id is not None, True)) and test/vcr/test_rules.vcr records a real id. Raised in the previous round and not applied; AGENTS.md says the spec is wrong until proven otherwise and moves in the same PR. Fix (untested): narrow §2 to what is still true — no microengines, so the feed stays empty, which is what the acknowledged zero-result livescan_id feed check rests on — and drop the livescan_id clause.

  • [LOW] F9. The two new list filters are pinned by presence-only assertions that hold identically if the server drops the query param and returns the unfiltered list: client_scan_test.py:597 asserts rule.id in by_name, and :610 asserts any(r.id == rule.id and r.favorite …). The same async twins. This is the regression an SDK-side filter test exists to catch, and the one most likely when the paired server change lands. Raised in the previous round and not applied. Fix (untested): add the universal arm — assert all(uid.lower() in (r.name or "").lower() for r in by_name) and assert all(r.favorite for r in favorites). No re-record needed; both recorded responses already satisfy the stronger form.

elsewhere: F4 → polyswarm/polyswarm-cli#266 · F1, F2, F7, F10 → the deployment chart PR · F6 → the internal API PR

Outstanding review feedback

Status Raised The ask Disposition
not addressed round 1 move the in-flight since=0 note out of the shipped docstring → F3
not addressed round 1 narrow specs/99 §2, contradicted by the new tests → F8
not addressed round 1 tighten the presence-only filter assertions to the universal arm → F9
open — not a defect both public repos The branch name carries an internal ticket id into public history. The cross-repo CI seam matches on branch name, so it cannot be renamed now; a squash merge with a clean subject satisfies both and needs no rebase. Nothing in the diff can settle it — it is a merge-time action, and it is the only thing between this repo and the no-internal-refs rule.

Everything else raised here is addressed, including the three items applied at the current head (the absent-arms parse tests, the nested teardown finally, and the long-pole fragment).

Standards conformity

The project-level audit stands from round 1. This round introduces no new change-level violation here.

Set-level rows, which belong to every member:

  • §14 delivery order — the set is one capability (API + SDK + CLI + deploy) with the UI legitimately following. Coverage is incomplete: the feed scope and the list filters have no CLI leg. → F4
  • Rule 6 name identity — clean. All four branches are byte-identical, so the harness resolved every sibling at the matching image tag and CI exercised the change together.
  • ## Requires linkage — present on all four, but two entries misdescribe the deploy member. → F2

sbneto added 5 commits August 27, 2026 21:30
The docstring said SECONDS and claimed the server was tightening the parameter
to `is not None` so that since=0 would mean an empty window. No such change
was ever made, and the opposite is the contract: the server applies the filter
on a truthiness test, so absent-or-0 means no time filter at all and the feed
pages over everything. The unit is now minutes on the wire, which is a break
worth naming — a caller passing an explicit since in seconds gets a 60x wider
window, silently — so specs/05 records it as a behaviour change rather than a
documentation correction, for the develop -> master bump decision.

max_results is additive and defaults to None, the historical behaviour: every
page, up to the client's page cap. It bounds the yielded count and sizes the
page request via core.page_size_for, so a small ask does not fetch a full
default page. The bound is client-side by nature — the server has never served
an unbounded query, it caps every page; this client is what follows cursors
until has_more clears.

page_size_for lives in core.py: pure, shared by both transports, and not
unasync-processed, so there is exactly one copy. Sync mirror regenerated.
It said live_start never gets a livescan_id on the local e2e stack. The rules
tests now assert the opposite as a hard contract and test/vcr/test_rules.vcr
records a real one. What is still true is that no microengines process
submissions, so the feed stays empty — which is what the zero-result feed check
in those tests rests on.
`rule.id in by_name` and `any(... and r.favorite ...)` both hold if the
server ignored the query param and returned the unfiltered list — which is the
regression an SDK-side filter test exists to catch, and the one most likely
when the paired server change lands. Both arms now assert over every row. No
re-record: the recorded responses already satisfy the stronger form.
Truncation and page-size selection are both decisions the code now makes and
neither had a test. Pinned in the pure-unit tier: producing more feed rows than
a page holds on the shared e2e stack would mean generating real live-hunt
volume, and _paginate is the seam that would otherwise keep following cursors.
The truncation tested `is not None` while page_size_for treats 0 as no bound,
so max_results=0 asked for a server-default page and then stopped after the
first result. 0 now means no bound on both halves, which is also what `since`
means by 0 on the same call.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md + specs/0105. The resource/builder work is solid — YaraRulesetFavorite's RESOURCE_ID_KEYS = ['community'] split is correct against core._params (PUT + non-param_keys → body), the tri-state/None-vs-0 parse arms are pinned in the pure-unit tier, the sync mirror matches aio/api.py, no dangling LiveHuntResultCounts / live_results_count / include_counts / eicar.yara references survive the withdrawal, and gitflow is clean (base develop, no pyproject.toml version bump — correctly deferred to develop → master, with the since break recorded in specs/05 for that decision).

Three things need action.

1. Spec drift — specs/03-endpoints.md:190 still documents since as SECONDS.

| `live_feed(since=None, …, livescan_id=None)` | … `since` is in SECONDS (the server converts with `timedelta(seconds=since)`) |

That is the pre-2cc5e80 claim and it now directly contradicts aio/api.py:510 (Window in MINUTES. Absent or 0 means NO time filter at all) and the new specs/05 "Behaviour changes" section. The same row is also missing max_results=, which is a new public parameter. AGENTS.md: "Update the spec in the same PR as the code change." Both the unit and the new parameter belong in that row.

2. Correctness — live_feed(max_results=0) yields one result, and fetches a full default page while doing it.

The feature reads 0 two contradictory ways. core.page_size_for (core.py:684) treats falsy as unbounded, pinned deliberately at hunt_tracking_builder_test.py:143 (core.page_size_for(0) is None — "0 is not a bound either"). The generator loop treats it as a bound:

# aio/api.py:533 (and api.py:608)
yield item
yielded += 1
if max_results is not None and yielded >= max_results:
    return

With max_results=0: page_size_for(0) → None so the request asks for a full server-default page, then the loop yields item #1 before the 1 >= 0 check fires. So a caller passing a computed remaining budget of 0 gets one row instead of none, having paid for a full page. Negative values behave the same. Pick one reading — either make the loop bail on falsy too (if max_results and yielded >= max_results, plus an early return for max_results is not None and max_results <= 0), or make page_size_for treat 0 as a real bound. Whichever way, TestLiveFeedMaxResults covers 3, 9 and the default but has no 0 case; that's the missing test.

3. Minor — specs/01-architecture.md:45 enumerates core.py's pure helpers by name (Hashable/Hash, parse_isoformat, _normalise_bool_params, RequestParamsEncoder, _raise_for_status). page_size_for / MAX_PAGE_SIZE are new shared pure helpers in that file and should join the list.

Non-blocking: the PR description still says "since documented in seconds — the unit the server has always read" and doesn't mention max_results. specs/05 now says the opposite (minutes, an explicit break). Since the description is what the develop → master bump decision reads alongside the spec, worth syncing it.

@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md, specs/02, specs/03, specs/04, specs/05. Resource/builder/codegen shape is correct (pure descriptors, canonical async + regenerated mirror, RESOURCE_ID_KEYS deviation justified and pinned), cassettes are live-recorded and exercise every new filter, gitflow base is develop and no version bump — all good. Four things need action, the first two before merge.


1. live_feed(since=) units contradict themselves inside this PR — SECONDS vs MINUTES.

  • specs/03-endpoints.md (this PR): "since is in SECONDS (the server converts with timedelta(seconds=since))"
  • specs/05-downstream-contract.md (this PR, new §Behaviour changes): "live_feed(since=) is now MINUTES, and it is a wire fix, not a doc fix … the server was corrected to read minutes"
  • aio/api.py / api.py docstrings (this PR): "Window in MINUTES."
  • PR description: "live_feed(since=) (since documented in seconds — the unit the server has always read)."

Three surfaces say minutes, one spec plus the PR body say seconds. This is a 60x window either way for anyone who reads the wrong one, and the SDK ships no conversion — it forwards since verbatim, so the docs are the entire contract. Pick the unit the paired server branch actually implements and fix the other two.

2. The bump decision reads the PR body, and the PR body contradicts specs/05.

specs/05 §Versioning: "Behaviour change on a documented contract | major". The new §Behaviour changes text correctly says the since change "is a behaviour change the develop → master bump decision should see, not a documentation correction" — but the TL;DR tells the maintainer the opposite ("the unit the server has always read"), i.e. that nothing changed. Whoever opens the develop → master PR reads the description, not specs/05. Reconcile it, and state the intended bump.

3. max_results is an undeclared public-surface addition.

live_feed(max_results=) is new on both clients and documented in specs/05, but:

  • the specs/03-endpoints.md row was updated for livescan_id only — | live_feed(since=None, …, livescan_id=None) | — while AGENTS.md §"When adding a new resource" step 5 requires specs/03 updated in the same PR;
  • core.page_size_for / core.MAX_PAGE_SIZE are new polyswarm_api.core symbols cited by specs/05 §Behaviour changes (core.page_size_for) but absent from that same file's ### polyswarm_api.core export listing;
  • the PR description never mentions max_results at all, so it is invisible to the review that the §Versioning table is scored against.

4. Test gap: nothing pins that max_results actually sizes the request.

specs/05 promises "it also sizes the page request (core.page_size_for)", and live_feed implements it as limit=page_size_for(max_results). But:

  • TestLiveFeedMaxResults replaces _paginate wholesale (api._paginate = lambda *a, **kw: iter(range(count))), so the descriptor is never built — it covers only the yielded >= max_results truncation;
  • TestLiveFeedScopeBuilder builds a descriptor but passes no limit, so params has no limit key to assert on;
  • test_page_size_for tests the helper standalone.

So limit=page_size_for(max_results) — the wiring between the two — is asserted nowhere; dropping that argument keeps every test green. Add the missing case: assert the descriptor live_feed(max_results=5) produces carries params['limit'] == 5 (and that the default omits limit entirely, which is what keeps the no-bound request byte-compatible). Cheapest home is TestLiveFeedScopeBuilder, alongside the livescan_id assertion.

Same test also only covers the sync client; the async live_feed truncation is unasync-mirrored so that half is fine to leave.

max_results read 0 two contradictory ways: page_size_for treated falsy as
unbounded while the generator tested `is not None`, so max_results=0 asked for
a full server-default page and then stopped after one row. Negatives were worse
— page_size_for returned -1, which would have put limit=-1 on the wire and the
server answers that with nothing.

as_result_bound is now the single definition (None/0/negative -> no bound) and
both halves read it, so they cannot disagree again. The bound and the page it
implies stay separate numbers on purpose: a caller asking for 5000 gets 1000-row
pages and still stops at 5000.

Also pins the wiring itself. The truncation tests replace _paginate wholesale,
so the descriptor was never built and dropping `limit=page_size_for(...)`
entirely kept every test green; TestLiveFeedLimitOnTheWire fails without it
(verified), and asserts the unbounded case sends no limit at all, which is what
keeps the default request byte-compatible with the recorded cassettes.

specs/01, specs/03 and specs/05 pick up the new core symbols, the MINUTES unit
and the absent-or-0 contract — specs/03 still documented SECONDS, contradicting
the docstrings and specs/05 inside the same change.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Review

Architecture, gitflow, and spec discipline are clean: canonical async edited first with the sync mirror regenerated, resources stay pure, both live-e2e VCR bodies + pure-unit builder tests + a dual-transport ClientTestCase respx suite, all six specs updated in-PR, no pyproject.toml version bump (correct — the since behaviour change is recorded in specs/05 §Behaviour changes for the develop → master call), base is develop.

Four things worth acting on, none blocking:

1. test/conftest.py:124 — the "rules" long-pole fragment also matches ruleset_favorite_respx_test.py.
_long_pole_rank does frag in item.nodeid, and "rules" is a prefix of "ruleset", so the RulesetFavoriteTestCaseSync / RulesetFavoriteTestCaseAsync nodeids rank 7 and get scheduled ahead of hash_search / existence_probe / sandboxtask. Those are instant respx tests — exactly the "~100 unit/respx tests finish near-instantly … backfill the tail" case the comment above the tuple describes. Correctness is unaffected (the comment says so), but it inverts the hint on the live run. "test_rules" or "_rules" matches only the two intended nodeids.

2. src/polyswarm_api/core.py:409MAX_PAGE_SIZE = 1000 is asserted only against itself.
The limit wire path never reaches a server anywhere in the suite: the only e2e live_feed calls are live_feed(since=600) and live_feed(livescan_id=…), both without max_results, so no cassette records a request carrying limit, and TestLiveFeedLimitOnTheWire compares page_size_for(10_000) to core.MAX_PAGE_SIZE — i.e. the clamp is pinned but the clamp value is not. The recorded responses show the server default page is 50; if its AI_MAX_QUERY_RESULTS is below 1000 the client asks for a page it cannot get. Cheap fix: have the test_rules feed read pass a small max_results so at least one cassette carries limit on the wire against the real server. Separately, this constant is an "API maximum" living in core.py while MAX_HUNT_RESULTS / MAX_ARTIFACT_BATCH_SIZE / RESULT_CHUNK_SIZE all live in settings.py — worth a line in specs/01 explaining the split, since specs/05 now exports it publicly.

3. ruleset_list(favorites_only=False) / has_new_results=False serialise to query 0, and nothing pins that arm.
core._params coerces bools to ints before body/query routing, so these land as the literal string 0 in the query. specs/02 justifies the 1/0 coercion for the favorite toggle — but that argument is about the JSON body, where 0 is a real int. The other query-string bool in this repo (IOC.iocs_by_hash hide_known_good) bypasses _params and serialises via _normalise_bool_params to 'False', so the two filter families disagree on the wire. hunt_tracking_builder_test.py covers only True and None. If the server query bool parser does not treat "0" as false, favorites_only=False silently means favorites_only=True — the inverse of the ask. Either add the explicit-False builder case, or state in the docstring / specs/03 that the filters are omit-to-disable and False is not a supported value.

4. Branch name DN-8480-hunting-schema-migration carries a ticket ID.
Commit subjects, PR title and body are all clean, so the rule was followed where it is written — but the AGENTS.md reason ("this repo is public; published artefacts shouldn't leak internal references") applies to the merge commit, which will bake the branch name into develop history. Squash-merge with a hand-written subject, or note the branch-name case in AGENTS.md so the next PR does not hit it.

…r arm

'rules' is a prefix of 'ruleset', so the long-pole fragment also matched the
instant ruleset_favorite respx suite and scheduled it ahead of real long poles.
'test_rules' matches only the two intended nodeids.

Nothing covered an explicit favorites_only=False / has_new_results=False: they
serialise to query 0 (core._params coerces bools before routing), and an
inverted filter is the one failure mode that silently returns the wrong rows.

Also records where MAX_PAGE_SIZE comes from — the server's AI_MAX_QUERY_RESULTS
— and why it sits in core.py rather than settings.py.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Review

Clean against AGENTS.md and specs/ on the load-bearing points: canonical async edited + sync mirror regenerated, ruleset_favorite built as a YaraRulesetFavorite.update descriptor (RESOURCE_ID_KEYS = ['community'] correctly routes id/favorite to the PUT body via core._params), all new resource fields additive .get() parses, e2e-first tests with a justified respx fallback, specs 01-05 + 99 updated in the same PR, and no pyproject.toml version bump (correct — the since-minutes behaviour change is recorded in specs/05 §Behaviour changes for the develop → master bump decision, per §Release flow). Base is develop; no ticket IDs in commit messages.

Three things worth fixing, all minor.

1. test/conftest.py:124 — the "test_rules" fragment does not match test_async_rules.

_long_pole_rank does a plain substring test on item.nodeid, and test/async_client_test.py::TestAsyncScanCase::test_async_rules contains no "test_rules" (it is test_ + async_rules). The inline comment claims the fragment covers both, but the async rules test — which this PR grows to ~8 poll loops, the heaviest new test in the suite — falls to the len(_LONG_POLE_FRAGMENTS) backfill tail and starts last. That is exactly what the hint exists to prevent on a live -n 8 run.

"_rules" matches both nodeids and still avoids the false positive the adjacent comment warns about: in test/ruleset_favorite_respx_test.py the character before rules is /, not _.

2. test/conftest.py:126 — garbled duplicated comment. The line ends ... respx suite) # test_rules / test_async_rules — live enable/stop + ~6 poll loops: an earlier version of the trailing comment survived the edit. Drop everything after respx suite).

3. Async live_feed bound / page-sizing is untested. TestLiveFeedMaxResults and TestLiveFeedLimitOnTheWire (test/hunt_tracking_builder_test.py:184,219) both build PolyswarmAPI.__new__(PolyswarmAPI) and stub _paginate. That exercises the generated mirror only; the canonical PolySwarmAsyncAPI.live_feed bound loop and its limit=page_size_for(bound) wiring have no test at all. The async for + yielded/return shape is the one part unasync rewrites rather than copies, so it is the part worth pinning. Small lift: the same stub driven through an _AsyncToSync-wrapped client, or an _api_yielding twin returning an async iterator.

The long-pole fragment went from 'rules' to 'test_rules' to fix a false positive
and introduced a false negative: 'test_async_rules' is test_ + async_rules, so
the heaviest new test in the suite fell to the backfill tail. '_rules' matches
both nodeids and still misses the respx suite, where the character before
'rules' is a slash. The replaced line also left its old trailing comment behind.

Every max_results test drove the GENERATED sync mirror. The async for +
yielded/return shape is the part unasync rewrites rather than copies, so the
canonical loop had no coverage at all — verified the new tests fail when the
canonical bound check alone is broken.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/0105. No correctness, spec-drift, contract, or gitflow issues found. Detail on what I checked, and the one nit:

Correctness — clean.

  • YaraRulesetFavorite.RESOURCE_ID_KEYS = [<200b>'community'] produces the right split through core._params (PUT, community in param_keys → query; id/favorite fall through to json_params, with idstr(int(v)) and the bool → 1/0). Matches the respx and pure-unit pins.
  • as_result_bound / page_size_for: the not max_results or max_results < 0 short-circuit is safe for None, and both halves of the bound now come from one normalisation, so the max_results=0 disagreement the comment describes is genuinely closed. limit is not an *_id key, so it rides the query as an int and _next_page still re-sends the server-echoed request.limit — no interaction.
  • Every new field is content.get(...) and parse_isoformat(None) → None, so the additive/None-not-0 claim holds against an older server.
  • limit and all new filters are omitted when unset, so the default live_feed() / ruleset_list() requests stay byte-identical — which is what keeps the 40-odd untouched cassettes valid.
  • poll_equals's want is None refusal is the right guard given it maps 404 → None; the time.sleep/asyncio.sleep calls go through the module attributes the autouse _skip_poll_sleep_on_replay fixture patches, so replay stays free.

Spec drift — none; specs 01/02/03/04/05/99 were all updated in the same PR as required, including the retired test/eicar.yara (no remaining references outside specs/04). RESOURCE_ID_KEYS is being used here to route a non-identifier (community) rather than an alternate id, which is a deviation from the AGENTS.md §"When adding a new resource" wording — but it's explicitly documented as such in specs/02, so it's a deliberate, recorded exception rather than drift.

Downstream contractlive_feed/ruleset_list additions are keyword-appended, so no positional caller breaks. The since-in-minutes behaviour change is recorded under specs/05 §Behaviour changes and correctly scored major against the §Versioning table; deferring the actual bump to the develop → master PR is exactly what AGENTS.md §Gitflow and specs/05 §Release flow require (pyproject.toml untouched at 4.3.0 ✓).

Test coverage — the None arms the cassettes can't carry, the explicit-False filter arm, the since=0-reaches-the-wire arm, the limit-on-the-wire arm, and the canonical-async bound loop (not just the unasync mirror) are all pinned. Cassettes carry real rule_count/favorites_limit/source_rule_changed values, so they were recorded live as claimed. The two self-declared gaps (livescan_id scoping can't be distinguished from an ignored param on a zero-result hunt; the stored counter is never non-null on the e2e stack) are noted in-test and deferred to the server suite — reasonable.

Gitflow — base is develop ✓, no version bump ✓, conventional-commit prefixes ✓, no AI-attribution trailers ✓, no ticket IDs or private repo names in the commit messages, PR title, or body ✓.

Nit (non-blocking, no code change): the branch name DN-8480-hunting-schema-migration carries the internal ticket code, and the ## Requires line points at "the same branch name" — so the code is effectively published on a public repo. The letter of AGENTS.md covers commit messages / PR titles / descriptions only, but the stated reason ("this repo is public; published artefacts shouldn't leak internal references") applies to branch names too. Worth avoiding on the next one.

The wire is not moving to minutes (prod traffic makes it a silent 60x widening
for clients we don't control), so this is a documentation correction again
rather than a behaviour change — which also removes it from the develop -> master
bump decision. max_results and the absent-or-0 contract are unaffected.

Also shortens the core helper comments: the argument belongs in specs/05, not
beside every line that touches it.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Review — hunt-page ruleset tracking

Checked against AGENTS.md and specs/01specs/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:671MAX_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/0105. 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.

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