Skip to content

Render hunt-page ruleset tracking and hunt provenance fields - #266

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

Render hunt-page ruleset tracking and hunt provenance fields#266
vhmartinezm wants to merge 27 commits into
developfrom
DN-8480-hunting-schema-migration

Conversation

@vhmartinezm

@vhmartinezm vhmartinezm commented Aug 21, 2026

Copy link
Copy Markdown

TL;DR

The CLI leg of the hunt-page capability: render the ruleset tracking and hunt provenance fields the SDK now parses, and add rules favorite <id> [--unfavorite] — the star toggle the platform delivery-order standard requires this change set to carry. Formatter legs are getattr-guarded so the CLI keeps rendering results parsed by an SDK release that predates the fields.

Requires

What's new

  • rules favorite <id> [--unfavorite]: renders the toggle state, Favorited at, and the server-owned budget ("Favorites used: N of M" — the client never counts). A full-budget refusal (the server's machine-readable FAVORITE_LIMIT) becomes a clean actionable message at exit 2 — the central mapping's server-refusal code; exit 1 stays reserved for no-results/not-found.
  • Floor guard: ruleset_favorite ships in the paired SDK change and does not exist on the published floor (4.3.0). The command guards with getattr and fails with a clean upgrade message on a floor install; every pre-existing command works unchanged there, so no pin bump (specs/05 documents the exception and the follow-up bump once the SDK releases).
  • ruleset: Favorite: yes (+ Favorited at), Rules in ruleset (omitted when the server had no answer — never shown as 0), Historical hunts triggered, and New live results (last 24h) — the server's stored badge, labelled with its fixed product window since a caller can no longer choose one — with the new_results_counted_at staleness marker beside it. (The earlier --include-counts flag is withdrawn with the per-request server aggregate it wrapped, per the platform query-design standard.)
  • rules list gains the four server-side filters --name / --status active / --favorites-only / --has-new-results. The list is keyset-paginated, so filtering locally would mean walking every page. An unfiltered rules list is still a zero-argument ruleset_list() call, which is what keeps the common invocation working on the pin's floor.
  • live feed gains --livescan-id — the drill-down for the per-ruleset new-results badge, which had no way to list the results it counts — and --max-results (unset or 0 means no bound, as before). --since keeps its seconds unit and moves its default from 1440 to 86400 — the 24h it was always written for (the old value was 24 * 60, against an SDK docstring that wrongly said minutes). The wire is untouched: this endpoint takes ~197k requests per 30 days carrying since from clients outside our control, so re-basing it to minutes would widen every one 60x silently. The CLI's own default window does widen 60x (24 min -> 24 h) as a result — worth a release note on the develop -> master PR, since a plain live feed now returns a day of results; 0 still means no time filter at all.
  • Three surfaces exceed the declared SDK floor, each guarded so a floor install gets a clean upgrade message at exit 2 rather than a traceback: rules favorite, the rules list filters, and the two new live feed options. Every pre-existing invocation still reaches the floor's own signatures untouched — a new option may require the newer SDK, an existing invocation may not. specs/05-sdk-contract.md §Current floor lists all three and why the floor itself does not move.
  • hunt: Source Ruleset Id, the source's last-modified at freeze time, and Source ruleset changed since this hunt froze it: yes/no — the label names the reference point deliberately; unknown prints nothing.

Tests

tests/formatter_hunt_fields_test.py pins the guards (old-SDK results render, new lines omitted), zero-distinct-from-absent for the counters, the truthy-only favorite leg, the staleness-marker render, the reference point in the changed-since-freeze label, the zero-argument ruleset_list() call (signature-checked via autospec), the FAVORITE_LIMIT exit-2 message, and the floor degradation. The ruleset-shaped VCR cassettes are re-recorded against a stack running the paired server branch (hunt provenance stays null in every recording, so those render legs are unit-pinned rather than cassette-pinned) (every ruleset body carries the tracking keys; list bodies carry the stored counter pair; livescan_id is a digit string), with new recordings for the favorite/unfavorite round-trip.

Two formatter legs, both getattr-guarded so the CLI still renders results
parsed by an SDK release that predates the fields:

- ruleset: Favorite / Favorited at, Rules in ruleset (absent when the
  server had no answer — never shown as 0), Historical hunts triggered,
  and New live results in window (only when the caller asked the list to
  include counts).
- hunt: Source Ruleset Id, the source's last-modified at freeze time, and
  'Source ruleset changed since this hunt froze it: yes/no' — the label
  names the reference point deliberately; unknown prints nothing.
The getattr guards (an old-SDK result without the attributes renders,
new lines omitted), zero-distinct-from-absent for the counters, the
truthy-only favorite leg, and the reference point in the
changed-since-freeze label.
Maps to the server's include_counts so the 'New live results in window'
formatter leg is reachable from the CLI (only live-hunting rulesets
carry a count; the param is omitted unless asked).
The flag must reach ruleset_list(include_counts=True) and the unflagged
run must omit the param entirely — the SDK drops None, and the exact
wire value is load-bearing (the server only accepts '0'/'1'/'false'/
'true').
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Review. Base is develop OK, commit messages carry no ticket IDs OK. Four things need action, one of them blocking.

1. Blocking — --include-counts is a hard SDK dependency the pin cannot express (src/polyswarm/client/rules.py:41)

The formatter legs are getattr-guarded, but this call site is not:

for ruleset in api.ruleset_list(include_counts=include_counts or None):

ruleset_list() takes no arguments before polyswarm-api PR 321, and that PR does not bump the SDK version — the SDK pyproject still declares 4.3.0. This repo floors at polyswarm_api>=4.3.0,<5.0.0, which the published 4.3.0 satisfies while lacking the kwarg. So once this merges, pip install polyswarm-cli against SDK 4.3.0 turns a plain polyswarm rules list — no flag — into TypeError: ruleset_list() got an unexpected keyword argument, which ExceptionHandlingGroup renders as a traceback plus "Unhandled exception happened. Please contact support." and exit 2. Arguments bind at call time even for a generator function, so nothing defers it to iteration.

CI will be green (the SDK branch name matches this branch, so the archive install picks up 321), which is why this needs catching in review rather than from a red pipeline.

specs/05-sdk-contract.md: "The SDK version pin in pyproject.toml is the compatibility contract. Floor it at the lowest SDK version that exposes every method/behaviour the CLI relies on" — and a floor bump has two preconditions there: the version is on PyPI, and the SDK develop declares at least that version. Neither holds today. The PR body reasoning "No version-pin bump: the getattr guards are exactly the degradation path" is true of the formatter fields and not of this line.

Two ways out:

  • have PR 321 bump the SDK to 4.4.0, release it, then floor this PR at >=4.4.0; or
  • keep the default path degradable, matching the claim the PR body already makes — build the kwargs conditionally (dict(include_counts=True) when flagged, empty otherwise) and splat them, so rules list keeps working on 4.3.0 and only the new flag needs the new SDK.

(The or None itself is fine — the existing test_ruleset_list_json cassette pins that the SDK drops None params, since the default query matcher would reject an added include_counts key.)

2. The new mock hides exactly that failure (tests/formatter_hunt_fields_test.py:96)

mock.patch(...PolyswarmAPI.ruleset_list) without autospec=True replaces the method with a signature-free MagicMock, so test_flag_sends_include_counts_true and test_no_flag_omits_the_param both pass green against an SDK whose ruleset_list() accepts no arguments. Add autospec=True and the test becomes a real signature check against the installed SDK — the one thing that would have surfaced (1) locally.

3. Formatter tests use SimpleNamespace, so field renames fail silently (tests/formatter_hunt_fields_test.py:29-40)

Every new line is getattr-guarded, which converts an attribute-name mismatch into silent omission rather than an error. Fed hand-built namespaces, these tests stay green if the SDK renames historical_hunt_count or source_rule_changed and the CLI quietly stops rendering it. specs/04-testing.md Style 3 asks for "an SDK resource built from a literal dict" — known_good_field_test.py does that with ArtifactInstance. Constructing real resources.YaraRuleset / resources.HistoricalHunt objects from literal dicts here couples the guards to the real attribute names, and additionally pins that favorited_at / rule_modified arrive as parse_isoformat datetimes rather than the raw strings the tests currently feed.

4. Spec drift — no spec touched

AGENTS.md, step 6: "Update the specs for the area you touched (at least 02-commands.md) in the same PR." rules list grew a user-facing option and the rules row in specs/02-commands.md does not mention it. specs/03-formatters.md says rendering rules get documented "when they become non-obvious or contested" — the semantics this PR encodes in code comments are exactly that: rule_count=None is not 0, favorite is truthy-only (so "not favorited" and "old SDK" render identically), source_rule_changed is tri-state with None meaning unknown rather than unchanged, and the label deliberately names the freeze as its reference point.

5. Minor (src/polyswarm/formatters/text.py:301)

if getattr(result, 'favorite', None) is not None and result.favorite: — the is not None half is dead, since None is falsy. if getattr(result, 'favorite', None): says the same thing.

…sources

Review findings:

- Blocking: the unconditional include_counts= kwarg made plain
  'rules list' a hard dependency on an SDK newer than the pin's floor —
  4.3.0's ruleset_list takes no arguments, so every unflagged run would
  TypeError against the published SDK (CI could not see it: the branch
  archive install picks up the new SDK). The kwargs are now built
  conditionally; only --include-counts requires the new SDK, matching
  the degradation claim the PR body makes.
- The flag tests now autospec the mock, turning both assertions into
  signature checks against the installed SDK — the check that would
  have caught the above locally.
- The rendering tests build REAL SDK resources from literal dicts, so
  an SDK attribute rename fails the test instead of silently dropping a
  line (the getattr guards convert mismatches into omission); they also
  pin that favorited_at/rule_modified arrive as parsed datetimes.
  SimpleNamespace remains only for the old-SDK degradation cases, where
  absent attributes are the point.
- specs/02-commands.md documents the new flag and the floor-SDK
  constraint; specs/03-formatters.md records the non-obvious rendering
  semantics (0-vs-None, truthy-only favorite, the tri-state and its
  reference point). Dead 'is not None' half of the favorite guard
  dropped.
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Review — hunt-page tracking fields

Two things need action; the formatter legs themselves look right.

1. --include-counts needs a pin bump, and getattr guards don't cover it (correctness / spec drift)

specs/05-sdk-contract.md §Invariants: "The SDK version pin in pyproject.toml is the compatibility contract. Floor it at the lowest SDK version that exposes every method/behaviour the CLI relies on."

The PR body says "No version-pin bump: the getattr guards are exactly the degradation path for the current polyswarm_api>=4.3.0,<5.0.0 range." That reasoning holds for the two formatter legs — a missing attribute silently omits a line. It does not hold for src/polyswarm/client/rules.py:45. The comment right above it states the problem itself:

an installed SDK at the pin's floor (4.3.0) has a zero-argument ruleset_list

So on a conforming install (polyswarm_api==4.3.0, which the pin explicitly permits), polyswarm rules list --include-counts hits TypeError: ruleset_list() got an unexpected keyword argument 'include_counts'. ExceptionHandlingGroup has no branch for that — it falls through to the terminal except Exception (client/polyswarm.py:164) and the user gets a full logger.exception traceback plus "Unhandled exception happened. Please contact support." at exit 2. The conditional-kwarg trick protects plain rules list, not the flag the PR is adding; a flag that is guaranteed to crash on the declared floor is a flag the pin doesn't cover.

Per spec 05 the floor has two preconditions before it can move (the version is on PyPI, and the SDK's develop declares at least that version, no .devN suffix). If polyswarm-api#321's release satisfies both, bump the floor here. If it doesn't yet, the flag can't ship in this PR — either way the PR needs an explicit bump decision rather than "no bump", and the "keeps working on the pin's floor SDK" claim now in specs/02-commands.md:33 needs to say the same thing.

2. specs/05-sdk-contract.md §"Current floor" is stale (spec drift)

The section header and body still read polyswarm_api>=4.2.0, but pyproject.toml:25 has said >=4.3.0 since #264. Pre-existing, but this PR's entire no-bump argument (and the new comment in rules.py) is reasoning off "the pin's floor", and the PR already edits two specs — fix it here rather than leave the authoritative doc contradicting the file it documents. Whatever lands for #1 goes in the same section.

Minor

  • specs/04-testing.md §Style 3 asks for TextOutput(color=False) called with write=False, asserting on the returned lines — no stream. tests/formatter_hunt_fields_test.py renders through an io.StringIO instead. Equivalent in effect, but it diverges from the convention known_good_field_test.py sets; worth matching for consistency.

Clean

  • Base is develop, no CLI version bump, no ticket IDs in commit messages or PR text — gitflow and hygiene rules all satisfied.
  • JSONOutput correctly needs no change (both hunt and ruleset dump result.json).
  • Zero-vs-absent handling, the truthy-only favorite leg, and the tri-state source_rule_changed guard all match the invariants the PR adds to specs/03-formatters.md, and each is pinned by a test.
  • Building the render fixtures from real resources.YaraRuleset / resources.HistoricalHunt instances is the right call — with getattr guards, a hand-built namespace would turn an attribute-name mismatch into a silently passing test.

@vhmartinezm

Copy link
Copy Markdown
Author

All five addressed in ff1dc77: the blocking one is fixed as suggested — the kwargs are built conditionally, so plain rules list keeps working on the pin's floor SDK and only --include-counts needs the new one (specs/02 documents that constraint); the flag tests are autospec'd (the signature check that would have caught it locally); the rendering tests now build real resources.YaraRuleset/HistoricalHunt from literal dicts — SimpleNamespace remains only for the old-SDK degradation cases, where absent attributes are the point — and pin the parse_isoformat datetimes; specs/03-formatters records the 0-vs-None, truthy-only-favorite and tri-state semantics; the dead is not None half is gone.

@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 rendering, 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; API, SDK and CLI support land in one change set) — plus the design decisions settled on the server side of this change.

What's clean. The rendering legs are careful and the semantics are documented where they are non-obvious rather than left in code comments: 0 distinct from None on both counts, truthy-only favorite, and the tri-state source_rule_changed whose label names its reference point — "changed since this hunt froze it" — so it cannot be misread as "edited recently". Building the render fixtures from real SDK resources rather than namespaces is the right call, since the getattr guards would otherwise turn an attribute rename into a silently passing test, and keeping SimpleNamespace only for the old-SDK degradation cases is the correct exception. JSONOutput needing no change is right. Base develop, no version bump, no internal ticket id in the title, body or commits. Nothing here aggregates client-side.


Findings

[MODERATE] F1. --include-counts is built on a server aggregate that is being withdrawn — and crashes on the declared floor SDK today

What happens: Two problems in one line. The flag calls ruleset_list(include_counts=True), which is a TypeError on the published SDK 4.3.0 that this repo's pin explicitly permits — ExceptionHandlingGroup renders that as a traceback plus "Please contact support" at exit 2. And the parameter it passes wraps a per-request COUNT … GROUP BY on the server, which is a design flaw under §13 and is being replaced by a stored counter column refreshed by a scheduled job. So the flag is both unsafe on a conforming install and pointed at a parameter that is going away.

When: The crash fires on any rules list --include-counts against an install at the pin's floor — which CI cannot surface, because the branch-name SDK archive install always picks up the matching branch.

Why:

  • The conditional-kwargs fix protects plain rules list but not the flag itself; a flag guaranteed to fail on the declared floor is a flag the pin does not cover.
  • specs/05-sdk-contract.md makes the pin the compatibility contract, and moving the floor has two preconditions — the version on PyPI, and the SDK's develop declaring at least that version — neither of which holds.
  • §13's first rule puts client-visible counts in stored columns refreshed asynchronously; the window becomes a property of the refresh job rather than of the request.

Proposed fix (untested): remove --include-counts, the conditional-kwargs machinery (list_rules returns to api.ruleset_list()), both tests in RulesListIncludeCountsFlagTest, and the flag's description from the rules row in specs/02-commands.md. That resolves the floor problem outright rather than deferring it: with ruleset_list() back to zero-arg, this PR needs no new SDK behaviour at all — every field it renders is getattr-guarded — so no pin bump is required and the whole floor question goes away.

new_results_count itself stays and keeps rendering; it becomes a stored column that is always present, None meaning "not yet refreshed".

[MODERATE] F2. The favorite capability ships with no CLI leg

What happens: A user can favorite a ruleset through the API and through the SDK, but not through the CLI. The companion SDK PR adds ruleset_favorite(); this PR touches only list_rules, and the rules row in specs/02-commands.md still lists ruleset_{create,delete,update,get,list}.

When: On merge — the capability is simply absent from this interface.

Why:

  • §14 requires API, SDK and CLI support for a capability to land in one change set. This one has two of three legs.
  • The endpoint returns favorites_used / favorites_limit specifically so a client can render "N of M used" without counting — a contract with no consumer here.
  • The refusal path is machine-readable through exc.request.errors['code'] for the same reason, and nothing in this repo reads it.

Proposed fix (untested): add rules favorite <id> with an --unfavorite flag, rendering the returned star state plus the two counters, and handling the FAVORITE_LIMIT refusal as a clean message rather than a traceback. Add its row to specs/02-commands.md and its rendering rules to specs/03-formatters.md beside the ones this PR already documents.

  • [LOW] F3. "New live results in window" names a window the caller can no longer choose or see, once the window belongs to the refresh job. Fix (untested): name the fixed product window in the label, and render the new_results_counted_at staleness marker the server will expose beside it — §13 requires that marker to be observable, which means the interface has to show it.
  • [LOW] F4. specs/05-sdk-contract.md:77 still headers polyswarm_api>=4.2.0 while pyproject.toml:25 has said >=4.3.0 since release: bump version to 4.3.0, floor the SDK at 4.3.0 #264. Pre-existing, raised in the 21 Aug review and not addressed — and it matters here specifically, because this PR's whole no-bump argument reasons off "the pin's floor" while the authoritative doc names a different one. Fix (untested): correct the header and body in this PR, since it already edits two specs.
  • [LOW] F5. The ruleset cassettes still mirror the contract this change alters: three carry livescan_id as a bare int (test_live_hunt_start_json.click, test_live_hunt_start_json.vcr, test_live_hunt_start_text.vcr), and all 16 ruleset-shaped recordings predate the four tracking keys, while the JSON formatter dumps the body verbatim. The companion SDK PR re-recorded its two cassettes; this one touches none, so replay stays green while a live run diverges. Fix (untested): re-record on this branch. Needs confirmation: that requires a live stack running the server branch's image, which was not stood up here.

Standards conformity

§14 — conformant on ordering, incomplete on coverage. This is correctly the third leg, on the identical branch name, based on develop, merging after the SDK. F2 is the gap: one of the two capabilities in this change set has no CLI support.

§13 — F1 is the gap, inherited from the server design this PR wrapped rather than introduced here. Removing the flag brings this repo into line and removes the floor hazard at the same time.

Fixes are proposed, not applied; nothing was run.

@vhmartinezm

Copy link
Copy Markdown
Author

Review applied — head e391f46

  • F1: --include-counts removed outright — flag, conditional-kwargs machinery, both tests, spec row. list_rules is zero-argument again (signature-checked via autospec) and the badge renders from the stored counter fields.
  • F2: rules favorite <id> [--unfavorite] added: renders state + the server-owned "Favorites used: N of M", and the machine-readable FAVORITE_LIMIT refusal becomes a clean actionable message at exit 2 (the central mapping's server-refusal code — a ClickException's default 1 would collide with no-results). One consequence of adding the leg in this change set: ruleset_favorite doesn't exist on published 4.3.0, so the command getattr-guards and degrades to a clean upgrade message on a floor install — every pre-existing command still works on the floor, no pin bump (specs/05 documents the exception and the follow-up bump once the SDK releases). VCR recordings cover the favorite/unfavorite round-trip.
  • F3: the label names the fixed window ("New live results (last 24h)") and the new_results_counted_at staleness marker renders beside it.
  • F4: the specs/05 floor header now follows the pin (>=4.3.0), with a note on why the floor lives in one authoritative place.
  • F5: all hunt-shaped cassettes (ruleset CRUD, live start/stop, historical create/delete/list) re-recorded against a stack running the server branch's image — every ruleset body carries the tracking keys, list bodies carry the stored counter pair, livescan_id is a digit string throughout.

@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/0105. The change matches the documented conventions: formatter method added to base.py/text.py/json.py (§03 invariant), getattr-guarded legs, rules list back to a zero-argument ruleset_list(), server-refusal → exit 2 per the §01 mapping, ## Requires link present, base is develop, no version bump. Three items:

1. Merge gate (already known, restating because it is load-bearing). polyswarm/polyswarm-api#321 is still open. Per specs/05-sdk-contract.md §Coordinated changes this must not merge until ruleset_favorite / YaraRulesetFavorite are on the SDK's develop. The PR build is fine — the SDK branch name matches, so $CI_COMMIT_BRANCH.zip resolves — but a merge to develop falls back to the SDK's develop.zip, and every favorite test errors at patch time.

2. tests/formatter_hunt_fields_test.py cannot run on the floor SDK it certifies. mock.patch("polyswarm_api.api.PolyswarmAPI.ruleset_favorite", …) (4 tests) and resources.YaraRulesetFavorite(...) (3 tests) both raise AttributeError against published polyswarm_api 4.3.0 — the exact install the rules favorite guard exists for, and one the pin permits. test_favorite_on_the_floor_sdk_degrades_cleanly is the sharpest case: it claims to simulate the floor but can only run where the attribute already exists. create=True on that patch, plus skipUnless(hasattr(PolyswarmAPI, "ruleset_favorite"), …) on the other four, makes the suite honest on both installs.

3. Ticket id in the branch name (DN-8480-…). CLAUDE.md bans ticket ids from commit messages, PR titles and descriptions on this public repo; a merge commit embeds the branch name in history — squash-merge with a clean subject, or rename the branch. Same spirit: commit dab6c5e cites internal standard section numbers ("org delivery-order standard §14", "query-design standard §13") in public history.

Nothing else. The cassettes are genuine re-recordings (the 3.0.0-era 308-redirect interactions are gone, UA is 4.3.0, ids are this run's resources), the .click fixtures line up with the recorded bodies — new_results_count is null in the list bodies and absent from the detail serializer, so the badge and provenance legs render only in the Style-3 unit tests, which is the sanctioned split — and rule_id/source_rule_changed are null throughout because historical start <file> is a raw-yara hunt. The FAVORITE_LIMIT handling matches the SDK's documented envelope (exc.request.errors), including the deliberate absence of a typed exception.

@vhmartinezm
vhmartinezm force-pushed the DN-8480-hunting-schema-migration branch from e391f46 to 04dd0ac Compare August 25, 2026 22:37
@vhmartinezm

Copy link
Copy Markdown
Author

Applied at head:

  1. Merge gate acknowledged — the SDK PR merges first, per its ## Requires and the coordinated-changes rule; nothing merges until ruleset_favorite is on the SDK's develop.
  2. The suite is now honest on both installs: every test touching the new surface (ruleset_favorite patches, YaraRulesetFavorite fixtures) carries skipUnless(hasattr(...)), and the floor-degradation test patches with create=True so it runs — and means something — on published 4.3.0 too.
  3. Commit messages reworded without the internal section numbers; squash-merge with a clean subject on the day covers the branch name.

@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown

Review

Gitflow is clean (base develop, no pyproject.toml version bump, ## Requires links the SDK PR), the formatter methods land on BaseOutput/TextOutput/JSONOutput as specs/03 requires, and the floor exception is documented in specs/05. Findings below, most severe first.

1. tests/cli_test.py favorite cassettes break the "honest on both installs" claim.
test_ruleset_favorite_text / test_ruleset_unfavorite_text have no _needs_favorite_sdk guard. On an install that satisfies the declared pin (published polyswarm_api==4.3.0, which specs/05 now explicitly documents as supported), rules favorite takes the toggle is None branch and exits 2 with the upgrade message — both cassette tests then fail against their .click. formatter_hunt_fields_test.py goes out of its way to skip there; these two do not. Either apply the same skipUnless or drop the claim.

Related: _SDK_HAS_FAVORITE also requires hasattr(resources, "YaraRulesetFavorite"). If the paired SDK names the resource anything else, all five favorite tests silently skip and CI stays green with the new command effectively untested (only the floor-degradation test still runs). The command tests only need ruleset_favorite to exist — keying the skip on the method alone shrinks that blind spot.

2. The FAVORITE_LIMIT wire shape is load-bearing and nothing pins it. src/polyswarm/client/rules.py:79-81 reads exc.request.errors["code"]. Two problems:

  • specs/05 §"What the CLI imports from the SDK" enumerates the SDK exceptions the CLI consumes (NoResultsException, NotFoundException, FailedInstanceException, PolyswarmException). RequestException, and the structured .request.errors payload behind it, are a new coupling to the SDK error surface that this PR spec edits do not record. Add it to that table (and to §Current floor if it needs the paired SDK).
  • test_favorite_limit_refusal_is_a_clean_message_at_exit_2 builds request as a bare mock.Mock() and assigns .errors itself, so the test passes regardless of where the SDK actually puts the server error body or what the code string is. A rename on either side silently disables the friendly message (the bare raise falls through to a raw SDK message at exit 2) with no test failure. specs/03 §Known-good sets the precedent for exactly this situation — state where the key was read off the server serializer, and where it is pinned. A recorded 4xx-refusal cassette would pin it properly.

3. No coverage for rules favorite --output-format json. specs/04 §"What to test for a new command" item 2 asks for both formats where both matter. JSONOutput.ruleset_favorite assumes the new resource exposes .json; nothing in the suite exercises that path (both new cassettes and all unit tests are text-only).

4. Question on the re-recorded rules view body. tests/vcr/test_ruleset_view_json.click loses "community": "_public" — the detail response no longer carries it. That is a user-visible change to rules view --output-format json output and it is not mentioned in the PR body. Confirm it is an intentional server-side serializer change on the paired branch and not an artefact of the recording stack.

5. Minor, gitflow hygiene. AGENTS.md bans internal ticket IDs in commit messages / PR titles / descriptions; the branch name would land its ticket code in public history via a default merge-commit subject. Squash-merge with a clean subject.

…ntract

- rules favorite <id> [--unfavorite]: the CLI leg of the favorite
  capability (API, SDK and CLI land together as one change set).
  Renders the toggle state plus the server-owned 'Favorites used: N of
  M' budget, and converts the machine-readable FAVORITE_LIMIT refusal
  into a clean actionable message at exit 2 — the central mapping's
  server-refusal code (a ClickException would exit 1, the code reserved
  for no-results/not-found). Pinned end-to-end against a real recorded
  400 (tests/cli_test.py::test_ruleset_favorite_limit_text), not just a
  hand-built mock, so a rename of the error shape on either side fails
  a test.
- The command guards the SDK surface: ruleset_favorite ships in the
  paired SDK change and does not exist on the declared floor
  (published 4.3.0), so on a floor install the command fails with a
  clean upgrade message instead of an AttributeError traceback — the
  same only-the-new-surface-may-require-the-new-SDK principle as the
  withdrawn flag below. Every pre-existing command works unchanged on
  the floor; the floor itself cannot move until the SDK releases
  (specs/05 documents the exception and the follow-up bump). Every
  test touching the new surface is guarded on the narrowest dependency
  it actually needs (the method for command tests, the resource class
  for formatter fixture tests) so a rename on either side skips only
  the tests that need it, not the whole suite silently.
- drop --include-counts: it wrapped a per-request server aggregate that
  is withdrawn (no count is computed on a request path; the badge is a
  stored, server-refreshed counter) — and it crashed on the declared
  floor SDK, which CI's branch-name SDK install could never surface.
  With it gone, list_rules is zero-argument again.
- the new-results badge renders as 'New live results (last 24h)' — the
  fixed product window, since a caller can no longer choose one — with
  the new_results_counted_at staleness marker beside it.
- specs: the sdk-contract floor header follows the pin (>=4.3.0, moved
  by #264; the header had lagged at 4.2.0), the imports table records
  RequestException/.request.errors as a real SDK dependency, and the
  command/formatter tables cover the favorite leg, its floor
  degradation, and the stored-counter render.
All ruleset/live/historical cassettes (and their click snapshots)
re-recorded against a stack running the paired server branch: every
ruleset body carries the four tracking keys, the LIST bodies
additionally carry the stored new_results_count / new_results_counted_at
pair (the detail serializer deliberately does not render the badge),
livescan_id is a digit string on every surface, and the fixture ids are
this recording run's own resources. New recordings cover the favorite
toggle's happy path in both output formats, unfavorite, and a real
FAVORITE_LIMIT refusal recorded against a genuinely full team budget.
@vhmartinezm
vhmartinezm force-pushed the DN-8480-hunting-schema-migration branch from 04dd0ac to b430a14 Compare August 26, 2026 15:03
@vhmartinezm

Copy link
Copy Markdown
Author

Applied at head:

  1. Both existing favorite cassette tests now carry _needs_favorite_method, and two new recordings were added: test_ruleset_favorite_json (the JSON leg you flagged missing) and test_ruleset_favorite_limit_text — a real FAVORITE_LIMIT 400 recorded against a genuinely full team budget (5 rulesets starred, then the 6th refused), all four guarded the same way. Also split the guard into _needs_favorite_method / _needs_favorite_resource per your note — command tests key on the method alone so a resource rename can't silently skip the whole command suite.
  2. The FAVORITE_LIMIT wire shape is now pinned by that real cassette rather than only the hand-built mock (the mock tests stay, for the exit-code/message-formatting unit coverage) — a rename on either side fails test_ruleset_favorite_limit_text. Also added the RequestException/.request.errors line to specs/05's imports table.
  3. Covered above (recording a few fixes, since parameter #1).
  4. Checked — not a recording artifact. community was never emitted by the server's YaraRuleSerializer (the ruleset detail view), on this branch or on master; rules view's JSON output doesn't carry it under either. Confirmed by diffing the serializer against origin/master (zero community references in either version of that class) — nothing to fix here.
  5. Agreed, squash-merge with a clean subject on the day.

@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 the SDK and this 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 47 files, +1381/−873.

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 → polyswarm/polyswarm-api#321 → this PR, with the chart deployed alongside the server image. §14: the API is the contract.

Surface Producer Consumer
?livescan_id= feed scope internal API polyswarm-cli → F4
live_feed(since=) unit internal API polyswarm-cli → F5

Coherence: F4 and F5 both add options to live feed and share one paired-SDK guard — apply them together, not in sequence. F5's server half must land first, or the CLI will express minutes while the wire still reads seconds.

This PR carries the two most important findings in the set.

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] F4. The CLI renders the hunt badge but cannot list the results it counts

What happens: polyswarm rules view now prints New live results (last 24h): N for a ruleset, and there is no way to see those N results from the CLI. live feed has no option to scope to one hunt, so the badge's own drill-down is unreachable. The four new ruleset-list filters have no CLI surface either.

When: On merge — the capability is simply absent from this interface.

Why:

  • The server added a livescan_id filter to the live-results list, and the SDK exposes it as live_feed(livescan_id=) (src/polyswarm_api/api.py:597 in the paired PR).
  • The CLI's feed command forwards five kwargs and not that one (src/polyswarm/client/live.py:45).
  • rules list calls a zero-argument ruleset_list() (src/polyswarm/client/rules.py:45) while the SDK gained name, status, favorites_only and has_new_results.
  • §14 requires API, SDK and CLI support for a capability to land in one change set — the same rule that put rules favorite in this PR.

Lands in: polyswarm-cli
Proposed fix (untested): add -i/--livescan-id to live feed, passed only when set. Guard it the way rules favorite already does — a clean upgrade message when the installed SDK's live_feed has no such parameter, never a TypeError traceback. That is deliberately the rules favorite precedent and not the --include-counts machinery the previous round removed: the objection there was that the flag crashed on the declared floor, not that a new surface may require the newer SDK.

Give the four rules list filters the same treatment. Two guard sites justify a small helper in client/utils.py rather than repeating the check.

Update the live and rules rows in specs/02-commands.md, and the floor-exception paragraph in specs/05-sdk-contract.md — it currently names rules favorite as the one command exceeding the published floor, and after this set that is three surfaces on two commands.

[MODERATE] F5. live feed asks for 24 minutes beside a badge that counts 24 hours

What happens: A user reads New live results (last 24h): 12 on a ruleset, runs polyswarm live feed, and sees far fewer than 12 with no error. The default asks the server for the last 1440 seconds — 24 minutes — while the badge beside it counts 24 hours.

When: Every live feed run that does not pass --since.

Why:

  • src/polyswarm/client/live.py:34 sets default=1440 with help text "How far back in seconds"; the server converts with timedelta(seconds=since).
  • 1440 is 24 × 60 — a day encoded in minutes, written in 2022 against an SDK whose live_feed docstring said "minutes" until the paired PR corrected it.
  • The value was always right and the unit under it was wrong. The window was intended to be 24 h from the start, so the agreed resolution is a wire fix — minutes becomes the unit everywhere — rather than a relabel here.
  • The new line that makes the two surfaces disagree is src/polyswarm/formatters/text.py:313.

Pre-existing, exposed here
Lands in: polyswarm-cli · Also touches: the internal API's live-feed view, and live_feed's docstring in polyswarm/polyswarm-api#321
Proposed fix (untested), the part that lands here:

  1. --since keeps default=1440 — under minutes that is 24 h, which is the window it was always meant to be. Correct the help text to name minutes.
  2. Add --max-results with no default, so nothing any current invocation returns changes. Implement it as a truncation of the SDK generator (for i, result in enumerate(api.live_feed(...)): if max_results and i >= max_results: break). It rides F4's paired-SDK guard on the same command, so it needs no machinery of its own; if you also pass it through to the SDK's new max_results kwarg to size the page request, that is what the guard is already there for.
  3. Update the live row in specs/02-commands.md.

Naming: --max-results rather than --limit, both to avoid colliding with the server's limit (which means page size, not a result cap) and because it says what it does.

Cassettes need no re-recording. The three live feed recordings pin ?since=9999999, which is "everything" under either unit, so the request is byte-identical and the replayed response unchanged.

Why --max-results is worth adding even though nothing here is unbounded by default: the server has never run an unbounded query — its paginator caps every request at 50 rows, max 1000. The SDK's _paginate, however, follows cursors up to _MAX_PAGES = 10_000. That was unreachable while the default meant 24 minutes; it becomes reachable now the same default means 24 hours, and outright for anyone passing --since 0 for the everything-feed. --max-results is the control for that, not a safety requirement.

  • [LOW] F10. The badge's 24 h product window is stated in three repos with no server-side declaration — the refresh job's window flag, the chart's args, and this repo's hardcoded New live results (last 24h) label (src/polyswarm/formatters/text.py:313). The payload carries new_results_counted_at but no window field, so the client cannot render it from data; tuning the job silently makes this label lie. Fix (untested): lands in the deployment chart as a comment naming the client labels that duplicate the window, so a change there is known to require a paired change here. Nothing to do in this repo unless the window actually moves.

elsewhere: F3, F8, F9 → polyswarm/polyswarm-api#321 · F1, F2, F7 → the deployment chart PR · F6 → the internal API PR

Outstanding review feedback

Status Raised The ask Disposition
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.

Everything else raised here is addressed. Verified rather than taken on report: the floor guard split into _needs_favorite_method / _needs_favorite_resource, the JSON leg and a real FAVORITE_LIMIT 400 both recorded, specs/05's imports table carrying RequestException/.request.errors, and the pin header corrected from >=4.2.0 to >=4.3.0. The re-recorded ruleset cassettes are genuine — real timestamps, the tracking keys present, livescan_id a digit string, and the favorite counters internally consistent across the toggle.

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, and this repo is where the gap sits: 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 3 commits August 27, 2026 21:30
A new OPTION may require the newer SDK; an existing INVOCATION may not. Passing
an unknown keyword to an older SDK raises a bare TypeError that
ExceptionHandlingGroup renders as a traceback plus 'Please contact support', so
the guard fires only when the caller actually uses the new option and produces
a clean upgrade message instead.

Inspects the installed signature rather than catching TypeError, so a genuine
argument error inside the SDK is never mistaken for a version mismatch. Two
commands need it, which is what earns a helper over an inline check.
rules view renders a per-ruleset new-results badge and there was no way to list
the results it counts: live feed gains --livescan-id, the badge's drill-down.
It also gains --max-results, and rules list gains the four server-side filters
the SDK exposes (--name, --status, --favorites-only, --has-new-results). The
list is keyset-paginated, so filtering locally would mean walking every page.

--since is documented in MINUTES to match the corrected wire unit; its 1440
default is unchanged and now means the 24h it was always meant to mean. 0 means
no time filter at all.

Every new option is forwarded only when passed, so an unfiltered rules list and
a plain live feed still reach the floor SDK's own signatures untouched. specs/05
records all three floor-exceeding surfaces and why the floor itself does not
move.
Covers the three decisions the plumbing now makes: an unfiltered list still
calls a zero-argument ruleset_list(), a filtered one forwards exactly the
filters given (a False flag must not become favorites_only=False, a filter the
caller never asked for), and live feed forwards the two new kwargs only when
passed. Each new surface is also pinned to degrade with a clean message at exit
2 against a stand-in carrying the floor signature, never a traceback.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md + specs/02, specs/03, specs/04, specs/05. Gitflow is fine (base develop, no pyproject.toml version bump, SDK PR linked under ## Requires). Five things worth acting on, ordered by severity.


1. formatter_hunt_fields_test.py — the floor guards are applied to the wrong tests, so the suite is not "honest on both installs"

The file docstring says "These tests must stay honest on BOTH installs", and _needs_favorite_method / _needs_favorite_resource are described as "deliberately as NARROW as each dependency". But the narrowness reasoning stops at the class level and misses the attribute-level dependency: _ruleset() / _hunt() instantiate resources.YaraRuleset / resources.HistoricalHunt, which do exist on the pin's floor (4.3.0) — they just do not parse favorite, rule_count, historical_hunt_count, new_results_count, rule_id, rule_modified, source_rule_changed. Resources declare attributes explicitly (that is the premise of every getattr guard in this PR, and of specs/03 §Known-good), so on a floor install the guards silently return None and these tests fail rather than skip:

  • test_ruleset_tracking_fields_render_with_zero_distinct_from_absent
  • test_ruleset_staleness_marker_renders_beside_the_count
  • test_hunt_provenance_fields_render_with_the_reference_point

test_ruleset_none_and_false_fields_are_omitted is worse than a failure — on the floor it passes vacuously, asserting only absence, so it looks like coverage while pinning nothing. Guard these the same way, on the attribute rather than the class (e.g. skipUnless(hasattr(_ruleset(favorite=True), 'favorite'))), or state in the docstring that the formatter fixtures require the paired SDK unconditionally.

2. No end-to-end coverage for the hunt-provenance lines or the new-results badge, despite the PR body claiming the cassettes carry them

The body says "All hunt-shaped VCR cassettes are re-recorded … every ruleset body carries the tracking keys; list bodies carry the stored counter pair". What the recordings actually contain:

  • test_historical_hunt_list_text.vcr: rule_id: null, rule_modified: null, source_rule_changed: null
  • test_ruleset_list_json.vcr: new_results_count: null, new_results_counted_at: null
  • test_ruleset_view_json.vcr: no new_results_count / new_results_counted_at key at all

So four of the new text legs — Source Ruleset Id, Source ruleset last modified at freeze, Source ruleset changed since this hunt froze it, New live results (last 24h) + New-results count refreshed at — never render in any .click expectation. Only the Style-3 unit tests touch them, which specs/04 allows, but the description should not claim cassette coverage that is not there. Concretely missing: a cassette over a ruleset whose stored badge is non-null, and one over a historical hunt frozen from a ruleset modified since (source_rule_changed: true) — the tri-state true branch and the staleness marker are exactly the legs a hand-written fixture cannot pin against a server rename.

3. live feed --since silently changes documented units, and now contradicts its sibling command

client/live.py:36-38 re-documents --since as MINUTES (was "seconds"), with no code change and no test. client/historical.py:66 still reads 'How far back in seconds to request results.' for the same SDK since parameter. polyswarm.py:218 (download_stream) documents minutes. One of live/historical is wrong — fix both in this PR, or say which the SDK actually means. The new 'Pass 0 for no time filter at all' claim also has no test and no cassette; test_plain_feed_forwards_neither_new_kwarg only pins the 1440 default.

This is a user-facing semantics claim now baked into specs/02-commands.md, so it should not be an unverified drive-by.

4. Spec drift — specs/05-sdk-contract.md import table

The new row reads:

from polyswarm_api import exceptions (bare, not aliased) | RequestException — caught by rules favorite

client/rules.py:3 actually does from polyswarm_api import exceptions as api_exceptions — the aliased form the row directly above already covers. Either fold RequestException into the existing api_exceptions row or drop the "(bare, not aliased)" claim; as written the spec describes an import that does not exist in the code, which is the drift the spec convention exists to prevent.

5. PR description does not describe the diff

The body's "What's new" says "rules list is zero-argument again" while the diff adds four filters to it (--name, --status, --favorites-only, --has-new-results), and never mentions live feed --livescan-id / --max-results or the --since unit change at all — those surface only in the specs/ diff. Given the paired-SDK story turns on exactly which surfaces exceed the floor, the description should list all three guarded surfaces the way specs/05 does.


Minor: the newer than 4.3.0 floor string is hardcoded in three places (client/utils.py:47, client/rules.py upgrade message, and the guard's own text). When the follow-up floor bump lands (specs/05 §Current floor), one is easy to miss — worth a single module constant.

The guard design itself (require_sdk_kwargs inspecting the installed signature rather than catching TypeError, getattr on the method for favorite, forwarding new kwargs only when passed) is right and matches specs/05, and the FAVORITE_LIMIT → CLI PolyswarmException → exit 2 path checks out against ExceptionHandlingGroup in client/polyswarm.py:134-160.

sbneto added 2 commits August 27, 2026 21:48
The floor version was hardcoded in three places, so the follow-up bump that
drops the guards had three chances to miss one. SDK_FLOOR states it once.

--max-results takes IntRange(min=0): 0 is meaningful (no bound, matching the
SDK) but a negative is a typo, and refusing it at the interface beats silently
treating it as unbounded.
…lass

The floor guards were keyed on the resource CLASS, but YaraRuleset and
HistoricalHunt exist on the published floor — they simply do not parse the
tracking and provenance keys. Resources declare attributes explicitly, so on a
floor install the formatter's getattr guards return None and these tests FAIL
rather than skip. test_ruleset_none_and_false_fields_are_omitted was worse: it
asserts absence, so it passed vacuously there, looking like coverage while
pinning nothing. Keyed on the attribute the fixture actually needs.

specs/05's import row also claimed a bare `from polyswarm_api import exceptions`
while rules.py aliases it — the drift the spec convention exists to prevent.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/01, 02, 03, 04, 05. Gitflow is clean: base is develop, no CLI version bump, ## Requires links the SDK PR, and specs/02 + specs/03 + specs/05 were updated in the same PR. Five things worth acting on.

1. The floor is triplicated, contradicting the invariant this PR just wrote (src/polyswarm/client/utils.py:19)

specs/05-sdk-contract.md:80 (added here) says the drift "is why the floor lives in ONE authoritative place, the pin, and this doc must follow it". But the floor now exists in three places that nothing ties together:

  • pyproject.toml:25polyswarm_api>=4.3.0
  • src/polyswarm/client/utils.py:19SDK_FLOOR = '4.3.0'
  • four hardcoded literals in tests (formatter_hunt_fields_test.py:241,289,367, cli_test.py:25)

The comment on SDK_FLOOR says it is "named once so the follow-up bump… has a single place to look" — but the follow-up bump touches pyproject.toml, and nothing fails if SDK_FLOOR stays at 4.3.0. Every user-facing guard message would then name the wrong version, and the tests (which assert 'newer than 4.3.0') would still pass. Easy to miss because src/polyswarm/__init__.py:1 is also 4.3.0 — the CLI's own version and the SDK floor coincide today.

Either derive it (parse the specifier off the installed distribution metadata) or add a test asserting SDK_FLOOR equals the pin's lower bound, and have the tests assert against utils.SDK_FLOOR rather than the literal.

2. specs/03-formatters.md:144-146 still declares the floor as 4.2.0

"Both attributes ship in SDK 4.1.0, but the dependency floor is polyswarm_api>=4.2.0"

Stale against pyproject.toml (>=4.3.0) and against specs/05 §Current floor. This PR edits this exact file (+21 lines), and specs/05 §Current floor calls out this precise drift as the reason the pin is authoritative — fix it here.

3. --max-results 0 fires the version guard for behaviour documented as unchanged (src/polyswarm/client/live.py:42-69)

Help text: "Unset or 0 means no bound — every page, as before." Code:

if max_results is not None:
    kwargs['max_results'] = max_results

So live feed --max-results 0 puts max_results=0 in kwargs and trips require_sdk_kwargs. On the pin's floor, a caller asking for exactly the pre-existing unbounded behaviour gets "live feed --max-results requires a polyswarm-api release newer than 4.3.0". That contradicts the rule specs/05 §Current floor states as the reason the floor does not move — "a new option may require the newer SDK, but an existing invocation may not". if max_results: makes the code match its own help text and keeps 0 off the wire entirely.

Related missing case: no test covers --max-results 0. LiveFeedOptionsTest covers unset and 5 only, so nothing pins that 0 means unbounded on either side of the boundary.

4. FAVORITE_LIMIT message can render (None of None used) (src/polyswarm/client/rules.py:102-106)

errors.get('favorites_used') / errors.get('favorites_limit') are interpolated unguarded, while the server's own human-readable result string ("Favorite limit reached (5 of 5 used).", present in tests/vcr/test_ruleset_favorite_limit_text.vcr:25) is discarded. An envelope carrying code but not the counters yields "Favorite limit reached (None of None used)". Guard the counters, or fall back to the server's result string.

5. Ticket ID in the branch name will land in the merge commit

AGENTS.md §Commit + PR hygiene: "Don't reference ticket IDs or internal project codes in commit messages, PR titles, or PR descriptions." The commits and PR title/body are clean, but the branch is DN-8480-hunting-schema-migration, which a merge commit puts into develop's history as "Merge pull request #266 from polyswarm/DN-8480-…". Squash-merge with a clean subject.


Minor, no action needed: the re-recorded hunt cassettes all carry rule_id / rule_modified / source_rule_changed as null, so the populated provenance render has unit coverage (test_hunt_provenance_fields_render_with_the_reference_point) but no e2e coverage — fine per specs/04 Style 3. Just noting that the PR body's "re-recorded against a stack running the paired server branch" is only load-bearing for the ruleset bodies.

--max-results 0 is documented as the pre-existing unbounded behaviour, but it
was forwarded to the SDK and therefore tripped the floor guard: a caller asking
for exactly what the floor already does got 'requires a polyswarm-api release
newer than 4.3.0'. That contradicts the rule specs/05 states as the reason the
floor does not move — a new OPTION may require the newer SDK, an existing
INVOCATION may not. 0 now stays out of kwargs and off the wire entirely.

The FAVORITE_LIMIT counters are advisory and an envelope can carry the code
without them; interpolating them unguarded rendered 'Favorite limit reached
(None of None used).' at the user. The server's own message is the fallback.

SDK_FLOOR is now tied to the pin by a test — it existed only so the guard
messages could name the floor, and nothing failed if it drifted from
pyproject.toml while every message named the wrong version. The guard tests
assert against the constant rather than a literal. specs/03-formatters.md still
declared the 4.2.0 floor, which is the drift specs/05 names the pin to prevent.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/02, 03, 05. Gitflow is right (base develop, no pyproject.toml version bump), the floor-guard design matches what specs/05 §Current floor now documents, and the formatter legs follow the getattr convention in specs/03. Six things worth acting on.

1. --livescan-id help points at a command that never renders the badge. src/polyswarm/client/live.py:56-58 (and the same sentence in specs/02-commands.md) says the badge is "the per-ruleset new-results badge that rules view renders". rules view calls ruleset_get, and the re-recorded detail cassette has no such key — grep -c new_results_count tests/vcr/test_ruleset_view_json.vcr is 0, tests/vcr/test_ruleset_list_json.vcr is 1. Commit b430a14 says this is deliberate ("the detail serializer deliberately does not render the badge"). So the docstring a user reads in live feed --help sends them to the one ruleset command that never shows the number. Should name rules list, in both the docstring and specs/02.

2. exc.request.result is accessed unguarded inside the handler that exists to avoid tracebacks. src/polyswarm/client/rules.py:108. .errors two lines above is read through getattr(..., None), but .result is not. If the SDK request object does not carry .result (or the envelope omitted it), this raises AttributeError inside the except block and the user gets exactly the traceback + "Please contact support" the branch is written to prevent. The only test covering this path (tests/formatter_hunt_fields_test.py:387) builds request as a bare mock.Mock() and assigns request.result itself, so a Mock has the attribute no matter what the real class does — the test cannot fail on this. Use getattr(exc.request, "result", None) or "Favorite limit reached." (or fall back to str(exc)). Note the counters-present path is cassette-pinned, so only the fallback is unpinned — which is why it needs the guard.

3. test_favorite_limit_without_counters_uses_the_server_message is missing @_needs_favorite_method. tests/formatter_hunt_fields_test.py:387 patches polyswarm_api.api.PolyswarmAPI.ruleset_favorite with autospec=True and no create=True. On a floor install (published 4.3.0, no such method) mock.patch raises AttributeError and the test errors instead of skipping — every sibling in that class is either guarded or uses create=True. The file's own header says "Every test touching the new surface is guarded on the narrowest dependency it actually needs"; this one slipped.

4. Two of the four new rules list filters are never signature-checked. test_filters_are_forwarded_only_when_given (tests/formatter_hunt_fields_test.py:210) exercises only --name and --favorites-only. The autospec'd mock is what turns these assertions into a signature check against the installed SDK, so status and has_new_results currently have no check that the CLI's kwarg names match the SDK's. A rename on either side would not fail CI — it would ship as require_sdk_kwargs refusing with "requires a polyswarm-api release newer than 4.3.0" on an SDK that actually has the surface, which is the most confusing possible failure. Adding --status active --has-new-results to that same invocation covers it.

5. specs/05-sdk-contract.md imports table now has two rows with an identical left column (from polyswarm_api import exceptions as api_exceptions, lines 21-22). Fold the RequestException / .request.errors detail into the existing row — as written the table reads as two different imports.

6. The branch name carries an internal ticket ID. AGENTS.md: "Don't reference ticket IDs or internal project codes in commit messages, PR titles, or PR descriptions." Title and body are clean, but a merge commit generated from this branch embeds the ID in the public history. Squash-merge with an explicit title, or rename the branch.

The help text sent users to 'rules view', which is the one ruleset command that
deliberately does not carry new_results_count — the badge is a list-serializer
field. It names 'rules list' now, in the docstring and specs/02.

exc.request.result was read unguarded inside the handler whose whole job is to
avoid a traceback; the only test built a Mock, which has every attribute, so it
could never fail on this. getattr now, pinned by a request object that really
lacks it.

Also guards the counters-fallback test on the floor (it patched ruleset_favorite
with autospec and no create=True, so it errored rather than skipped there), and
signature-checks all four rules-list filters instead of two — autospec is what
makes those assertions a check against the installed SDK, and a kwarg rename
would otherwise ship as the floor guard refusing on an SDK that has the surface.

The imports table had two rows with an identical left column after the earlier
alias correction; folded into one.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Review — #266

Base is develop, no pyproject.toml version bump, ## Requires links the SDK PR, commits carry no ticket IDs. Gitflow is clean. Specs 02/03/05 were updated alongside the code. The guard-vs-floor design (a new option may need the newer SDK, an existing invocation may not) is coherent and is genuinely pinned by formatter_hunt_fields_test.py.

A handful of things worth fixing before merge.

1. require_sdk_kwargs false-refuses an SDK whose method takes **kwargs

src/polyswarm/client/utils.py:52

parameters = inspect.signature(method).parameters
missing = [n for n in names if n not in parameters]

inspect.signature reports **kwargs as a single VAR_KEYWORD parameter named kwargs, not as the individual names it accepts. If ruleset_list / live_feed are (or become) declared as def ruleset_list(self, **kwargs) — a common shape for list endpoints that forward query params — every name lands in missing and the guard tells the user to upgrade an SDK that already supports the option. That is the exact failure mode the "inspects the installed signature rather than catching TypeError" rationale in specs/05-sdk-contract.md §Current floor claims to avoid, and it fails closed on a working install.

The autospec'd tests only cover the SDK currently installed in CI, so this does not fail today, but it is a one-liner to make robust:

parameters = inspect.signature(method).parameters
if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values()):
    return

2. exc.request is the one unguarded attribute in the block that exists to avoid tracebacks

src/polyswarm/client/rules.py:95

errors = getattr(exc.request, 'errors', None) or {}

.errors and .result are both getattr-guarded — with a comment explaining that "this block exists to avoid a traceback, so it must not raise one reaching for a fallback" — but exc.request itself is dereferenced bare. A RequestException raised without a request (or with it set to None) raises AttributeError inside the handler, producing precisely the traceback plus "Please contact support" the handler was written to prevent. Reach for it with getattr(exc, 'request', None) first. Every test constructs the exception with a request object, so nothing covers this path.

3. historical list --since is now the only place in the CLI still documenting seconds

src/polyswarm/client/historical.py:66

@click.option('-s', '--since', type=click.INT, help='How far back in seconds to request results.')

This PR corrects live feed --since to MINUTES; download stream --since already said Request archives X minutes into the past. Default: 1440. historical list is the same wire unit with the same wrong help string, and the PR walked past it. Fix it in the same change — leaving one of three identical options saying "seconds" is worse than the uniformly-wrong state it replaced.

4. specs/04-testing.md needed updating too

The PR introduces a new, load-bearing test convention across two files — unittest.skipUnless(hasattr(PolyswarmAPI, 'ruleset_favorite')), plus the narrower attribute-keyed guards (_needs_tracking_fields, _needs_provenance_fields) and the reasoning about why keying on the resource would let the whole command suite skip silently while CI stays green. That convention is what keeps the suite honest on both installs the pin permits, and it is in no spec. specs/04-testing.md owns test conventions (Styles 1-3, the cassette workflow) and is marked "Incremental — to be expanded"; AGENTS.md says "Update the specs for the area you touched in the same PR." 02/03/05 were updated; 04 is the one that actually describes what changed here.

5. --livescan-id is untyped where every other id in the CLI is click.INT

src/polyswarm/client/live.py:39. live start / live stop take @click.argument('ruleset-id', type=click.INT); rules view/update/delete and the new rules favorite all use click.INT. --livescan-id takes a bare string, so a typo'd non-numeric id goes to the server unvalidated. Either match the convention or note in specs/02 why this one differs.

Minor

  • specs/02-commands.md and several code comments describe exit 2 as "the server-refusal code". ExceptionHandlingGroup maps 2 to a broad bucket — InternalFailureException, both PolyswarmException hierarchies, JSONDecodeError, UnicodeDecodeError, and any unhandled exception. "Exit 2, not 1" is the accurate claim; calling it the server-refusal code in an authoritative spec is a statement the mapping does not support.
  • rules list's short_help is still List all rulesets. now that it filters.
  • The PR description cites "the delivery-order standard (§14)" and "the query-design standard §13" — documents that are not in specs/. On a public repo those read as dangling internal references; AGENTS.md asks for internal references to stay in the internal tracker.
  • No cassette exercises non-null hunt provenance (rule_id / rule_modified / source_rule_changed are null in every re-recorded hunt body), so the new hunt legs are Style-3-only. That satisfies specs/04 §Style 3 as written, but the re-recording claim in the PR description oversells what the cassettes now pin.

require_sdk_kwargs refused any method declared **kwargs: signature() reports one
VAR_KEYWORD parameter rather than the names it accepts, so the guard would have
told a user to upgrade an SDK that already supports the option. It fails open
there now — the reason for inspecting the signature at all is to avoid a
confusing upgrade message on a working install.

--livescan-id takes click.INT like every other id option; Python ints are
arbitrary precision, so a 17-digit id survives exactly (the server renders it as
a string for JS consumers, not for us) and a typo is refused before it reaches
the server.

specs/04 gains the floor-guard convention this change introduced. It is
load-bearing and lived in no spec: guard on the narrowest dependency, because a
class-level guard does NOT skip a test whose resource exists but does not parse
the attribute — the render tests fail and an absence-asserting test passes
vacuously.

Also drops the 'exit 2 is the server-refusal code' claim from a comment and from
specs/02: ExceptionHandlingGroup maps 2 to a broad bucket, so the supportable
contract is '2, not 1'.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Review

Gitflow and contract hygiene are clean: base is develop, no pyproject.toml version bump, ## Requires links the SDK PR, and specs/02/03/04/05 were all updated in-PR. Four things need action.


1. Two re-recorded text cassettes now depend on the paired SDK, with no guard — contradicting the spec this PR adds

tests/vcr/test_live_hunt_start_text.click:15,17 and tests/vcr/test_live_hunt_stop_text.click:11,13 now expect

Rules in ruleset: 2
Historical hunts triggered: 0

Those lines only render when the SDK parses rule_count / historical_hunt_count. But LiveHuntTest.test_live_hunt_start_text (tests/cli_test.py:192) and test_live_hunt_stop_text (:203) carry no skip guard, unlike the favorite tests which got _needs_favorite_method. On a floor (polyswarm_api==4.3.0) install the formatter's getattr guard returns None, the two lines vanish, and both tests fail.

That is precisely the case the section this PR adds to specs/04-testing.md legislates against:

A test that needs a surface the floor does not have must skip there — not fail […] | a parsed attribute on a resource (rule_count, source_rule_changed) | hasattr(<a built resource>, '<attr>') |

The new unit-test file already builds exactly that guard (_needs_tracking_fields, tests/formatter_hunt_fields_test.py:61). Either reuse it for these two cassette tests, or re-record them off a ruleset whose counters the server omits.

The JSON ruleset cassettes are genuinely floor-safe (JSONOutput dumps the raw .json), so it is only the two text ones.

2. Nothing pins the new query parameters at the wire

rules list --name/--status/--favorites-only/--has-new-results and live feed --livescan-id/--max-results are pinned only by autospec'd mocks (tests/formatter_hunt_fields_test.py:210,276). Autospec checks the Python kwarg names against the installed SDK — it says nothing about what reaches the server. If the server renames has_new_results (or the SDK forwards it under a different query key), every one of these tests stays green.

The FAVORITE_LIMIT path got a real recorded 400 for exactly this reason, and specs/05-sdk-contract.md now says so explicitly ("pinned end-to-end […] against a real recorded 400 (not a hand-built mock), so a rename on either side fails that cassette"). The filters — a larger new wire surface — got no recording at all. A rules list --favorites-only and a live feed --livescan-id <id> cassette would pin the query string; you are already re-recording against the paired stack.

3. --since unit correction is applied to one of two hunt --since options

live feed --since is relabelled MINUTES (src/polyswarm/client/live.py:36-39, and specs/02-commands.md), described in the PR body as matching "the corrected wire unit". But historical list --since (src/polyswarm/client/historical.py:66) still reads How far back in seconds to request results., and no test or cassette in this repo pins either unit.

If the correction is endpoint-wide, that help string is now wrong. If it is live-feed-only, specs/02-commands.md should say the two --since options genuinely differ in unit, because the next reader will assume it is a miss. (download stream --since is already documented in minutes, which makes "seconds" on historical list look like the outlier.)

4. Minor — internal ticket ID in the branch name

AGENTS.md bans ticket IDs in commit messages, PR titles and PR descriptions on this public repo; all three are clean here. The branch name DN-8480-hunting-schema-migration is the one place it still shows, and it is rendered on the public PR page. Not blocking, but worth noting for the convention's intent ("published artefacts shouldn't leak internal references").


Everything else checks out against the docs: the guards do fire only on the new option rather than the existing invocation (--max-results 0 correctly short-circuits before both the SDK call and the guard, tests/formatter_hunt_fields_test.py:287,296); require_sdk_kwargs failing open on a **kwargs signature is the right call and is documented; the FAVORITE_LIMIT handler reaches for .result through getattr so the no-traceback handler cannot itself raise; SDK_FLOOR is tied to the pin by SdkFloorConstantTest; and exit 2 for the refusal is consistent with ExceptionHandlingGroup (src/polyswarm/client/polyswarm.py:153-161).

test_live_hunt_start_text / test_live_hunt_stop_text expect 'Rules in ruleset'
and 'Historical hunts triggered' since the cassettes were re-recorded, but those
lines only render when the SDK PARSES the attributes. On a floor install the
formatter's getattr guard omits them and both tests FAIL — the exact case the
specs/04 section this change adds legislates against.

The guards are now needed in two modules, which is what earns them a shared home
rather than a second copy of the resource-building boilerplate. Verified both
directions: the tests run against the paired SDK and skip when the attribute is
absent.

specs/02 also records that the two hunt --since options differ in unit on
purpose — live feed is minutes, historical list is seconds, because they are
different endpoints and the server reads each accordingly. Without saying so the
remaining 'seconds' reads as a missed rename.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Mostly clean against AGENTS.md and specs/: base is develop, no version bump, SDK_FLOOR is tied to the pin by a test, the new formatter method lands on BaseOutput + TextOutput + JSONOutput per specs/03 §Adding a renderable resource (hash formatters correctly skipped — it carries no hashes), SDK generators are iterated, and every spec the change touches is updated in the same PR. Five things worth acting on.

1. specs/02-commands.md:29 — the blockquote breaks the catalogue table

The > **The two hunt --since options differ in unit...** note is inserted between the live row and the historical row. GFM terminates a table at the first block-level structure, so the table ends at the live row and the nine rows after it (historical, tag, link, family, rules, metadata, activity, account, notification) render as a literal paragraph of pipe-delimited text — including the long rules row this PR wrote. Move the note above the table or below the last row.

2. src/polyswarm/client/live.py:36-39--since 0 is documented as "no time filter" but is forwarded verbatim

since goes to api.live_feed(since, ...) positionally on every path. If the server computes the window as now - timedelta(minutes=since), then --since 0 is "results since now" — i.e. nothing — not "no time filter at all". Note the asymmetry with the option right below it: --max-results 0 is deliberately dropped from the kwargs so it cannot reach the SDK, but --since 0 is passed through. Nothing in this PR pins the claim (no unit test, no cassette; test_plain_feed_forwards_neither_new_kwarg only pins the 1440 default), and the same sentence is now asserted as fact in specs/02. Either confirm the SDK/server actually treats 0/None as unbounded and pin it, or drop since from the call when it is 0 the way max_results already is.

Same option: the seconds→minutes re-documentation is a user-visible semantic change with zero code change — anyone who was passing --since 3600 on the old help text is now told they asked for 60 hours. Worth a release note when this reaches master, not just a specs/02 entry.

3. src/polyswarm/client/rules.py:96exc.request is the one un-guarded access in the no-traceback handler

errors = getattr(exc.request, "errors", None) or {}

.errors and .result are both getattr-guarded — with a comment at line 110 saying "this block exists to avoid a traceback, so it must not raise one reaching for a fallback" — but exc.request itself is a bare attribute access. A RequestException constructed without a request (or with it unset) turns the handler into the AttributeError traceback it exists to prevent. getattr(exc, "request", None) completes the pattern. test_favorite_limit_on_a_request_without_result_still_has_no_traceback covers the missing-.result case but not this one.

4. tests/formatter_hunt_fields_test.py:434 — hardcoded floor, contradicting the convention this PR just wrote

assert "requires a polyswarm-api release newer than 4.3.0" in result.output

specs/04-testing.md — added in this PR — says: "Whatever names the floor, name it onceutils.SDK_FLOOR … nothing fails if a hardcoded literal in a guard message goes stale." The two sibling floor-degradation tests (RulesListZeroArgTest.test_filtering_on_a_floor_sdk_is_a_clean_message_not_a_traceback, LiveFeedOptionsTest.test_new_options_on_a_floor_sdk_are_a_clean_message) both use f"newer than {utils.SDK_FLOOR}". This one should too — it is the exact drift the new spec section calls out.

5. Gitflow — the branch name will carry the ticket ID into public history

Base is develop and the version is untouched, both correct per AGENTS.md. But the head branch is DN-8480-hunting-schema-migration, and GitHub’s default merge commit subject is "Merge pull request 266 from polyswarm/DN-8480-hunting-schema-migration" — which lands the internal ticket ID in develop’s history on a public repo. That is what "Don’t reference ticket IDs or internal project codes in commit messages" exists to prevent, even though the rule enumerates messages/titles/descriptions rather than branch names. Squash-merge with an edited subject, or rename the branch before merging.


Non-blocking, checked and fine: require_sdk_kwargs’s VAR_KEYWORD fail-open is the right call and is documented in both the docstring and specs/05; the guard placement (method-level for ruleset_favorite, runtime kwarg-level for the filters, attribute-level for the parsed fields) matches the specs/04 table exactly; _needs_tracking_fields is applied to precisely the two re-recorded .click files that contain the new lines (test_live_hunt_start_text, test_live_hunt_stop_text) and no others, which is correct since the JSON cassettes dump result.json and render regardless of SDK parsing; the FAVORITE_LIMIT cassette is a real recorded 400 rather than a hand-built mock, so a rename on either side fails it.

A blockquote between rows terminates a GFM table, so the note split the
catalogue: everything from 'historical' down rendered as a paragraph of
pipe-delimited text, including the rules row this change rewrote. Moved below
the last row.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md + specs/0105. Gitflow is correct (base develop, no version bump — the floor stays >=4.3.0 and ## Requires links the SDK PR). Architecture, formatter, and testing conventions are followed: ruleset_favorite lands on BaseOutput/TextOutput/JSONOutput per specs/03 §Invariants, generators are iterated, SDK-boundary mocks are autospec'd, and the new specs/04 §"Staying honest on both installs the pin permits" is a genuinely load-bearing addition.

A handful of small things worth fixing:

1. exc.request is the one unguarded read in the handler — src/polyswarm/client/rules.py:96

errors = getattr(exc.request, 'errors', None) or {}

.errors and (since 33680ea) .result are both getattr-guarded on exactly the reasoning in the comment at :110 — "this block exists to avoid a traceback, so it must not raise one reaching for a fallback." .request itself isn't. A RequestException raised without a populated .request turns the FAVORITE_LIMIT handler into AttributeError → "Unhandled exception … Please contact support", which is the outcome the whole block exists to prevent. getattr(exc, 'request', None) once, then guard off that.

2. tests/cli_test.py:30 re-declares a guard the PR just centralised

tests/_sdk_guards.py exports needs_favorite_method and its docstring says "Shared because two modules need the same guards" — but cli_test.py imports only needs_tracking_fields and hand-rolls a second copy of _needs_favorite_method against the same hasattr(PolyswarmAPI, 'ruleset_favorite'). Import it; two copies is exactly the drift the shared module was added to stop.

3. SDK_FLOOR literal leaks back in — tests/formatter_hunt_fields_test.py:434

assert 'requires a polyswarm-api release newer than 4.3.0' in result.output

The other two floor-guard tests (:227, :315) assert f'newer than {utils.SDK_FLOOR}', and SdkFloorConstantTest exists precisely because "a hardcoded literal in a guard message goes stale" (specs/04). This one is the hardcoded literal.

4. Retracted exit-code claim survives in the user-visible help text — rules.py:75-76

11c59c8 dropped "exit 2 is the server-refusal code" from the inline comment and from specs/02 in favour of "the supportable contract is '2, not 1'", and the comment at :101-103 now says so. The command docstring — which is what polyswarm rules favorite --help prints — still reads "still exit 2 — the central mapping's server-refusal code". Same stale phrasing in tests/formatter_hunt_fields_test.py:363 and tests/cli_test.py:294.

5. rules.py:87 references a flag no reader can find

"Same principle as the withdrawn --include-counts flag" — --include-counts was withdrawn within this PR and appears nowhere else in the tree. State the principle without the dangling reference.

6. specs/03-formatters.md:38 now misattributes the floor

The edit changed the version but not the sentence: "polyswarm_api>=4.3.0 — set by two other behaviours … both of which fail silently on 4.1.0". Those two behaviours set 4.2.0; 4.3.0 was set by the #264 release bump. specs/05 gets this right ("The 4.2.0 rationale below still holds transitively"); specs/03 should point at 05 rather than restate it.

7. specs/04/specs/05 say utils.require_sdk_kwargs / utils.SDK_FLOOR unqualified

There are two utils modules, and specs/01 §"Support — utils.py, exceptions.py" documents src/polyswarm/utils.py. The new helper is in src/polyswarm/client/utils.py. Qualify it in both specs so the next reader doesn't look in the wrong file.

Nothing here blocks the SDK pairing; 1 is the only one with a runtime failure mode.

The 'exit 2 is the server-refusal code' claim was dropped from a comment and
specs/02 last commit but survived in the command DOCSTRING — which is what
'rules favorite --help' prints — and in two test docstrings. ExceptionHandlingGroup
maps 2 to a broad bucket, so the supportable contract is '2, not 1'.

cli_test.py hand-rolled a second copy of _needs_favorite_method against the same
hasattr, which is the drift tests/_sdk_guards.py was added to stop; the last
hardcoded '4.3.0' in a guard assertion now reads SDK_FLOOR, the constant
SdkFloorConstantTest ties to the pin.

Two dangling references: a comment cited '--include-counts', withdrawn inside
this PR and present nowhere in the tree, and another still said list is
zero-argument after this change gave it filters.

specs/03 attributed the floor to the behaviours that set 4.2.0 — 4.3.0 came from
the #264 bump — and now points at specs/05 rather than restating it. specs/04
and specs/05 said 'utils.' for helpers that live in client/utils.py, not the
top-level utils.py specs/01 documents.

Also records why exc.request is read directly: RequestException.__init__ assigns
it unconditionally, so a guard there would be dead code. Raised twice in review;
written down so it stays settled.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/02, 03, 04, 05. Gitflow is correct (base develop, no pyproject.toml version bump, SDK PR linked under a Requires section), the formatter/guard split matches specs/03 and specs/05 §Current floor, and the cassette re-records are internally consistent (rule_id is null in every hunt recording, so the unguarded historical *_text .click files correctly carry no Source … lines; only the two live hunt *_text cassettes gained tracking lines and both got @_needs_tracking_fields). Findings below.

1. --since 0 = "no time filter" is asserted in help + spec but implemented nowhere (correctness / coverage)

src/polyswarm/client/live.py:36-39 promises "Pass 0 for no time filter at all", and specs/02-commands.md repeats it. But since is forwarded unconditionally and positionally (live.py:81-84), and the existing cassettes show it lands on the wire verbatim (tests/vcr/test_live_feed_json.vcr:16 -> ?since=9999999). So --since 0 sends since=0, and whether that means "unbounded" or timedelta(minutes=0) is a pure server decision this repo neither implements nor pins.

That is asymmetric with the two comparable cases in the same change set:

  • --max-results 0 is handled CLI-side — live.py:76 drops the kwarg precisely so "0 means the pre-existing unbounded behaviour", and test_zero_max_results_is_unbounded_and_never_reaches_the_sdk pins it.
  • historical list --since (client/historical.py:72-73) expresses "no filter" by omitting the kwarg, never by sending 0.

Failure scenario: server reads since as timedelta(minutes=since); polyswarm live feed --since 0 returns zero results and exits 1 (NoResultsException) while the help text says it should return everything. Either drop since from the call when it is 0 (mirroring max_results), or add a test/cassette pinning since=0 -> unbounded.

2. The seconds->minutes correction is doc-only here and depends on an unlinked server change

No CLI code changed for --since; only the help string and specs/02. The new spec note asserts "the server reads each accordingly (timedelta(minutes=...) for the live feed)", but nothing in this repo pins that — the live-feed cassettes are untouched and none of the new tests assert the unit. The Requires section links only polyswarm-api#321; per AGENTS.md §Companion repos the unit lives in the server-side API repo, and that change is neither linked nor gated by the SDK pin. If it is not deployed everywhere by the time this reaches master, the published help text is wrong for prod users by a factor of 60. Worth linking the server change (by category, per this repo's public-repo rule) and stating explicitly that it is already deployed.

3. The skip guards make CI green even if the paired SDK is not on the SDK's develop (testing / merge ordering)

specs/05 §Coordinated changes: "The CLI PR must not merge until the SDK surface it depends on is on the SDK's develop" — CI's pip install $POLYSWARM_API_ARCHIVE/$CI_COMMIT_BRANCH.zip || … develop.zip is what enforced that, because a missing surface used to fail. With tests/_sdk_guards.py in place, the fallback to develop.zip on a floor SDK now skips every rules favorite test, both test_ruleset_favorite_* cassettes, the limit cassette, and — newly — test_live_hunt_start_text / test_live_hunt_stop_text, which were previously unguarded end-to-end coverage. Green CI no longer distinguishes "SDK #321 is merged" from "the whole feature is untested". The guards are the right call per the new specs/04 section, but the merge-order precondition now needs a manual check before merging; consider recording that in specs/05 §Coordinated changes so the lost signal is documented rather than assumed.

4. specs/03-formatters.md — the floor edit left a dangling fragment that now says the wrong thing

Lines 144-149 read:

polyswarm_api>=4.3.0 — the pin's current value (moved there by the #264 release bump). Its rationale is two behaviours that landed in 4.2.0 and still hold transitively; [05-sdk-contract.md] §Current floor is authoritative. Both of which fail silently on 4.1.0 (see …) — so every supported install has them.

"Both of which" lost its antecedent when the sentence was split. As it stands the fragment attaches to the known-good attributes, which the same paragraph has just said ship in 4.1.0 and are explicitly not what sets the floor — i.e. it now asserts the opposite of the surrounding text. Re-join it into the preceding sentence.

5. Minor

  • tests/cli_test.py:11 and :19unittest and PolyswarmAPI are imported but unused now that the guards live in tests/_sdk_guards.py. Nothing in .gitlab-ci.yml lints, so this will not be caught.
  • Not a rule violation as written, but AGENTS.md bans internal ticket IDs in commit messages / PR titles / descriptions because this repo is public — the branch name DN-8480-hunting-schema-migration is published on the PR page all the same. Commits and the PR body are clean; worth avoiding in the branch name next time.

The 1440 default was written as 24*60 against a docstring that said minutes;
the server reads seconds, so the real window was 24 minutes while the badge
beside it counts 24 hours. Fixing the caller rather than the wire gets the same
24h with no break for existing integrations. historical list --since is seconds
too, so the two now agree.

Also trims the FAVORITE_LIMIT handler and the SDK guard comments.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/02, 03, 04, 05. Gitflow is clean (base develop, no CLI version bump, no ticket IDs in commits/title/body), the ## Requires link is present, formatters were added on BaseOutput/TextOutput/JSONOutput per specs/03 §"Adding a renderable resource", and the withdrawn --include-counts left no residue. Findings below, most severe first.


1. The skip guards may skip permanently — the probe dicts omit the very keys they probe. tests/_sdk_guards.py:20-24,38-42

needs_tracking_fields builds resources.YaraRuleset(_RULESET, ...) and asks hasattr(..., "rule_count"), but _RULESET has no rule_count key. Same for needs_provenance_fields / source_rule_changed against _HUNT. That only works if the SDK resource assigns every attribute unconditionally (self.rule_count = content.get(...)). If it assigns only for keys present in the payload, the guard reports "not installed" even on the paired SDK, and every test it decorates — including cli_test.py::test_live_hunt_start_text and test_live_hunt_stop_text, whose re-recorded .click files are the only cassette pin on the Rules in ruleset / Historical hunts triggered legs — skips forever while CI stays green. That is the exact failure mode specs/04 §"Staying honest on both installs" was added to prevent, inverted from vacuous-pass into silent-skip. Put the tracking/provenance keys into _RULESET / _HUNT so the probe is unambiguous.

2. Nothing fails loudly if the paired SDK is absent — the feature's whole coverage can vanish silently. tests/_sdk_guards.py, .gitlab-ci.yml:28

CI does pip install $ARCHIVE/$CI_COMMIT_BRANCH.zip || pip install $ARCHIVE/develop.zip. If polyswarm/polyswarm-api has no branch named exactly DN-8480-hunting-schema-migration, CI falls back to the SDK's develop; if the paired SDK PR is not merged there yet, every new test skips (_needs_favorite_method, _needs_favorite_resource, _needs_tracking_fields, _needs_provenance_fields) and the PR merges green with zero exercise of rules favorite, the tracking-field render legs, or the re-recorded text cassettes. Please confirm the SDK branch name matches, or add a marker that fails when none of the guards are active, so "green" cannot mean "skipped everything".

3. live feed --since 1440 → 86400 is a 60x widening of the default window, pinned only against itself. src/polyswarm/client/live.py:36, tests/formatter_hunt_fields_test.py:246

The unit assertion is live_feed.call_args[0][1] == 86400 — it compares the CLI's constant to the CLI's constant and would pass for any value. No cassette records a live feed with --since omitted (all three existing live-feed cassettes pass --since 9999999 explicitly), so nothing in the repo pins the default against the wire. The prior help text already said seconds, so the claim is at least self-consistent — but every user's plain live feed now returns 24h instead of 24min of results, unbounded by default (--max-results unset = every page). Record a default-invocation cassette, and call the behaviour change out in the release notes on the develop → master PR.

4. The FAVORITE_LIMIT fallback can render a non-string repr at the user. src/polyswarm/client/rules.py

str(getattr(exc.request, "result", None) or "Favorite limit reached.") — the counters branch is cassette-pinned (test_ruleset_favorite_limit_text); this fallback is only covered by hand-built mocks that set .result to a string. On a real envelope PolyswarmRequest.result is the parsed result, so if the server ships the code without the counters and result is a dict/list the user sees its repr interpolated into the message. Check isinstance(..., str) before using it.

5. Spec drift the PR half-fixed.

  • specs/05-sdk-contract.md §Version pin still reads "For the current floor both were read from origin/develop: version = "4.2.0" and __version__ = "4.2.0"" — stale now that the heading below correctly says 4.3.0. The PR fixed the heading and left the paragraph above it naming the old floor.
  • specs/03-formatters.md: the inserted paragraph leaves a dangling fragment — "…§Current floor is authoritative. Both of which fail silently on 4.1.0 (see …) — so every supported install has them."

6. tests/cli_test.py:11,19import unittest and from polyswarm_api.api import PolyswarmAPI are added but unused.

The guard probes asked hasattr() for keys the probe payloads omitted. That works
only because the SDK assigns every attribute unconditionally — verified, and the
guarded tests do run — but it made the guards depend on that; the keys are in the
payloads now, so a silent skip-everything cannot arise from an SDK style change.

The FAVORITE_LIMIT fallback interpolated exc.request.result unchecked; result is
the parsed body, so a dict would have reached the user as a repr.

Two edits I left half-done: specs/03's floor paragraph lost its sentence, and
specs/05 still named 4.2.0 above the heading that says 4.3.0. Drops the imports
the shared-guard move orphaned.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md + specs/02/03/04/05, and cross-checked the surfaces against the paired SDK PR (polyswarm/polyswarm-api#321 head): ruleset_favorite(ruleset_id, favorite=True), ruleset_list(name, status, favorites_only, has_new_results), live_feed(..., livescan_id, max_results) with since seconds and absent-or-0 = no filter, and FAVORITE_LIMIT surfacing as an untyped RequestException with exc.request.errors — all match what this PR calls. Gitflow is right (base develop, no version bump, ## Requires present). Three things:

1. Two tests fail rather than skip on a floor SDK — the exact failure mode specs/04 §"Staying honest on both installs the pin permits" was added to prevent.

tests/formatter_hunt_fields_test.py:188 (test_filters_are_forwarded_only_when_given) and :255 (test_livescan_id_and_max_results_are_forwarded) assert exit_code == 0. With autospec=True the mock carries the installed signature — which is the point of the test — so on published 4.3.0 (ruleset_list(self), live_feed without livescan_id/max_results) require_sdk_kwargs refuses, the command exits 2, and both assertions fail. Everything else that needs the paired SDK skips correctly (_needs_favorite_method, _needs_tracking_fields), so this is the one gap. Guard them on the parameter, e.g. unittest.skipUnless('name' in inspect.signature(PolyswarmAPI.ruleset_list).parameters, ...).

Related spec drift: specs/04's new table says a test needing "a keyword on an existing method" should guard on "client/utils.py's require_sdk_kwargs at runtime". That's the guard for the product code — it never skips a test, so the row as written prescribes exactly the two failures above. The row should say: guard the option-passing test on the parameter's presence in the installed signature, and keep the unfiltered/plain-invocation test unguarded (that part of the reasoning is right).

2. PR description contradicts the code on the live feed --since default.

The bullet says the default moves 1440 → 86400, then closes with "its 1440 default is unchanged and now means the 24h it was always meant to mean". client/live.py:36 is default=86400. That trailing clause reads like a leftover from the revision where the SDK was going to re-base the wire to minutes (SDK patch 12, reverted by patch 20). Since this bullet is what the develop → master release note will be cut from, and the actual user-visible change is a 60x wider default window, it's worth fixing the text.

3. Nit: tests/formatter_hunt_fields_test.py imports unittest (:29) and PolyswarmAPI (:38) but uses neither — leftovers from moving the guards into tests/_sdk_guards.py.

Formatter guards, zero-vs-absent handling, the tri-state label, require_sdk_kwargs inspecting the signature instead of catching TypeError, the exit-2 refusal path, and the re-recorded cassettes all look right to me.

sbneto added 2 commits August 28, 2026 12:08
`test_filters_are_forwarded_only_when_given` and
`test_livescan_id_and_max_results_are_forwarded` pass a keyword the floor
SDK does not accept, so `require_sdk_kwargs` refused and the command exited
2 — both asserted `exit_code == 0` and FAILED there rather than skipping.
CI never caught it: the branch-name match installs the paired SDK, so the
floor install the pin permits is the one nobody exercises.

Guard them on the parameter's presence in the installed signature, the
narrowest dependency a keyword-passing test has. The plain-invocation tests
stay unguarded — the floor supports those, and skipping them would drop the
coverage that matters most.

specs/04's table prescribed `require_sdk_kwargs` for this row. That is
product code and never skips a test, so the row described exactly the bug
above; its reasoning about keeping the unfiltered call covered was right and
is kept.

Also drops two imports left over from moving the guards into
`tests/_sdk_guards.py`.
`live feed --livescan-id` was documented as the drill-down for the ruleset
new-results badge — "this is how you list them". It lists a subset: the
badge counts the hunt across every community it runs in, public and private
together, while the feed shows one at a time and this command always sends
one. A user drilling down on a multi-community hunt sees fewer rows than the
badge reported, or none at all.

Documents the asymmetry instead of implying an equivalence that does not
hold. No behaviour change.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Review

Measured against AGENTS.md and specs/02, 03, 04, 05. Gitflow is correct (base develop, no pyproject.toml version bump), the specs were updated in the same PR rather than after, the floor guards match specs/04 §"Staying honest on both installs" row-for-row (method → hasattr, keyword → inspect.signature, parsed attribute → built resource), and the re-recorded cassettes are internally consistent — hunt provenance is null in every recording, and the only .click files carrying the new lines are the ones guarded by _needs_tracking_fields / _needs_favorite_method. Two things worth acting on.

1. live feed --since: the 60x default widening rests on an unpinned claim

src/polyswarm/client/live.py:36 moves the default from 1440 to 86400, and specs/02-commands.md:41-48 asserts "the server has always read seconds". Nothing in this repo pins that:

  • test_plain_feed_forwards_neither_new_kwarg (tests/formatter_hunt_fields_test.py:254) asserts live_feed.call_args[0][1] == 86400 — that pins the CLI's own constant against itself, not the wire unit.
  • The three test_live_feed_* cassettes all pass --since 9999999 explicitly, so they never exercise the default, and a recorded query string cannot distinguish minutes from seconds anyway.

By the PR's own description the paired SDK's live_feed docstring still says minutes. That leaves the two repos contradicting each other on a parameter that specs/05 §"The SDK owns the wire" makes the SDK's to define. If the unit is actually minutes, every default live feed silently becomes a 60-day query with no error — the exact silent-widening failure the spec note cites as the reason not to change the server.

Concretely: fix the docstring in the paired SDK PR so the claim is stated once, on the side that owns it, and confirm the server-side unit before merge. This is also the only user-visible behaviour regression in an otherwise additive PR, and it is independent of the hunt-page capability — worth landing separately if that confirmation is not quick. The release note on the develop -> master PR is required either way.

2. needs_live_feed_options is keyed on one of the two parameters it gates

tests/_sdk_guards.py:63 keys the guard on max_results only, but it gates test_livescan_id_and_max_results_are_forwarded, which passes --livescan-id too. specs/04-testing.md:77 says to guard on "the parameter's presence in the installed signature", and the guard's own message names both. If the SDK lands max_results without livescan_id, require_sdk_kwargs refuses at exit 2 and the test fails instead of skipping — the failure mode that row exists to prevent. Check both parameters, or split into two guards.

Not blocking

  • --since 0 ("no time filter at all") is newly documented in the help text and specs/02, but api.live_feed(0, ...) just sends since=0 and there is no test for it. Fine to leave if that server behaviour is already established.

`needs_live_feed_options` checked only `max_results` while gating a test that
passes `--livescan-id` too, and `needs_ruleset_list_filters` checked only
`name` while gating a test that passes all four filters. An SDK carrying the
subset would satisfy the guard, then `require_sdk_kwargs` would refuse the
invocation at exit 2 and the test would FAIL instead of skipping — the exact
failure mode these guards were added to prevent, reintroduced by keying them
too narrowly.

`_accepts` now takes several names and requires all of them. Verified it
discriminates: True against the paired signatures, False against a partial
SDK carrying only `max_results`.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/0105.

Gitflow is clean: base is develop, no pyproject.toml version bump, ## Requires links the SDK PR, conventional commit prefixes, no ticket IDs or private repo names in the title/body/messages. The paired-SDK head branch name matches this one, so .gitlab-ci.yml's $CI_COMMIT_BRANCH.zip fallback will actually install the paired SDK PR and the new skip guards will not vacuously skip. Formatter methods land on BaseOutput/TextOutput/JSONOutput per specs/03 §Adding a renderable resource; generators are iterated per specs/05. Three things below.


1. api_exceptions.RequestException collides by name with the transport branch of the central exit-code mapping

client/rules.py:112 re-raises every non-FAVORITE_LIMIT RequestException, and tests/formatter_hunt_fields_test.py::test_other_refusals_still_raise asserts that exits 2. But specs/01-architecture.md §exit-code mapping documents:

| Transport errors matched by ancestry class name (httpx's HTTPError root, legacy requests' RequestException, builtin ConnectionError/SSLError) | 1 |

and client/polyswarm.py:170 literally intersects {c.__name__ for c in type(e).__mro__} with a set containing 'RequestException'. Exit 2 therefore holds only because the SDK's RequestException subclasses api_exceptions.PolyswarmException and is caught by the earlier clause. If it does not — or if the SDK ever reparents it — every non-limit server refusal from rules favorite exits 1 and prints "Unhandled exception happened. Please contact support if the error persists.", which is exactly the wrong advice for a 4xx the user can fix.

This PR is the first place the CLI catches an SDK class whose bare name is in that set, so the overlap now needs to be stated rather than inferred. specs/05's new row documents where the error body lives but not the hierarchy the exit code depends on. Please add the parent class to that row (or narrow the transport branch so it cannot shadow an SDK exception), and consider covering it directly — none of the new tests would catch a reparent, since test_other_refusals_still_raise asserts 2 without asserting which branch produced it.

2. live feed --since 0 promises server behaviour nothing in this repo pins

The help text (client/live.py:38) and specs/02-commands.md both promise "Pass 0 for no time filter at all", repeated three times in the PR body. But since is forwarded positionally and unconditionally (client/live.py:85) — the CLI does nothing to make 0 mean "unfiltered"; that is entirely an SDK/server behaviour. Nothing pins it: LiveFeedOptionsTest never passes --since 0, and every live feed cassette passes --since 9999999. If the endpoint reads 0 literally, polyswarm live feed --since 0 silently returns nothing while the help says it returns everything.

specs/05-sdk-contract.md exists for exactly this ("which parts of the SDK's public surface it relies on"), and it does not list since=0 alongside the other relied-on behaviours in §Current floor. Either add it there as an explicit dependency, or drop the claim from the help and the spec.

(The 60x default widening, 1440 -> 86400, is a deliberate call and the PR body already flags it for a release note on the develop -> master PR — no action, but note --max-results has no default, so a plain live feed is now a day of results, unbounded.)

3. Two command tests stack the resource guard the new convention says not to stack

tests/_sdk_guards.py states the rule, and specs/04-testing.md's new table repeats it:

| an API method (rules favorite -> ruleset_favorite) | hasattr(PolyswarmAPI, '<method>') | keying it on the resource class too would let a resource rename silently skip the whole command suite while CI stays green |

But RulesFavoriteCommandTest.test_favorite_calls_the_sdk_and_renders_the_budget and test_unfavorite_flag_flips_the_boolean carry both @_needs_favorite_method and @_needs_favorite_resource, because _response() builds a real resources.YaraRulesetFavorite. A YaraRulesetFavorite rename in the SDK therefore silently skips the only two tests that assert rules favorite calls the SDK and renders its output — the precise failure mode the guard module was written to prevent. The module docstring in formatter_hunt_fields_test.py even asserts the opposite ("the command tests need only the METHOD ... the formatter fixture tests need only the RESOURCE class they instantiate"), which is not what the code does.

TextOutput.ruleset_favorite only touches result.id plus getattrs, so _response() can return a SimpleNamespace and drop @_needs_favorite_resource from these two — keeping the resource guard on the two fixture tests that genuinely instantiate it. Otherwise, amend the specs/04 row and the two comments to describe the actual rule.

`test_favorite_calls_the_sdk_and_renders_the_budget` and
`test_unfavorite_flag_flips_the_boolean` carried @_needs_favorite_resource
only because the shared `_response()` built a real YaraRulesetFavorite. A
rename of that class would therefore have skipped the only two tests that
assert `rules favorite` calls the SDK at all — the failure the guard module's
own docstring says it exists to prevent. TextOutput.ruleset_favorite reads
`.id` plus getattrs, so a SimpleNamespace serves and the command tests now
depend on the METHOD alone. The two fixture tests that genuinely instantiate
the resource keep the guard.

Also pins the exception hierarchy the exit-code mapping silently rests on:
non-limit refusals exit 2 only because the SDK's RequestException subclasses
PolyswarmException and the handler catches that base before the transport
branch, which matches the bare name 'RequestException' against the MRO and
exits 1 with "contact support". A reparent in the SDK would turn every
fixable 4xx into that advice; the new test fails loudly instead.

specs/05 records the two server-owned behaviours the CLI relies on and cannot
enforce — `--since` being seconds, and `--since 0` meaning no filter — naming
the server-side tests that pin each.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/01-05.

Gitflow OK: base is develop, no version bump, the Requires section links the SDK PR. Specs 02/03/04/05 are all updated in-PR as AGENTS.md requires; the formatter method landed on both JSONOutput and TextOutput, generators are iterated, mocks sit at the SDK boundary. Four things worth action.

1. require_sdk_kwargs fails open on **kwargs, and nothing pins the real floor signature.
client/utils.py:41-43 returns early when the installed method declares VAR_KEYWORD. specs/05 §Current floor promises a floor install gets "a clean upgrade message at exit 2". If published polyswarm_api 4.3.0 declares ruleset_list(self, **kwargs) or live_feed(self, since, **kwargs), the guard silently no-ops and --favorites-only / --has-new-results / --livescan-id are forwarded to an SDK that drops them: the user gets an unfiltered list at exit 0 with no warning. That is exactly the silent-failure class specs/05 says the floor mechanism exists to prevent.
Neither floor test pins the real signature — test_filtering_on_a_floor_sdk_is_a_clean_message_not_a_traceback and test_new_options_on_a_floor_sdk_are_a_clean_message both substitute a hand-written stand-in with the signature the author believes the floor has. Please confirm the actual 4.3.0 ruleset_list / live_feed signatures; if either takes **kwargs, the guard needs a different key (SDK __version__, or probing for the resource attribute the new kwarg produces).

2. Cassette guards are broader than specs/04 requires.
tests/cli_test.py:281 and :288 — test_ruleset_favorite_text / test_ruleset_unfavorite_text assert .click output containing Favorites used: N of M and Favorited at:, which render only when the SDK parses those keys off the response resource. specs/04 §"Staying honest on both installs" row 3 is explicit that a render assertion must be guarded on the built resource attribute, not on the method. _sdk_guards.needs_favorite_resource exists for exactly this and is unused in cli_test.py. Not a live failure today (method and resource ship together), but it is the drift that spec section was written to stop.

3. specs/02 --since blockquote is incomplete on the units it exists to disambiguate.
The new note enumerates live feed --since (seconds) and historical list --since (seconds) and concludes "the two agree" — but download stream --since (client/download.py:62) is a third --since, in MINUTES, IntRange(1, 2880), default=1440. That literal 1440 is the same value the live feed default is being corrected away from, and is the likeliest origin of the original mistake. Name it in the blockquote so the next reader does not copy it again.

4. Review artifact in shipped source.
src/polyswarm/client/rules.py:71 — the comment "(Raised twice in review.)" is review bookkeeping, not code context. Drop it.

Nothing blocking beyond item 1, and that may resolve to no code change if the 4.3.0 signature check comes back clean.

`test_ruleset_favorite_text` / `test_ruleset_unfavorite_text` assert rendered
lines ("Favorites used: N of M", "Favorited at:") that appear only when the
SDK parses those keys off the response. specs/04 row 3 says a render
assertion guards on the built resource attribute, not the method — otherwise
an absence-asserting test passes vacuously. Method and resource ship together
today, so this is the drift that row exists to stop rather than a live break.

Records the published 4.3.0 signatures in specs/05, read off the wheel rather
than inferred: `ruleset_list(self)` and `live_feed(self, since, rule_name,
family, polyscore_lower, polyscore_upper, community)`. Neither declares
**kwargs, so `require_sdk_kwargs`'s fail-open branch is unreachable against
the real floor and the floor tests' stand-ins match reality. That mattered:
had either taken **kwargs, the new options would have been forwarded to an
SDK that drops them and the caller would get an unfiltered list at exit 0.

specs/02 now also names `download stream --since` — a third --since that is
genuinely minutes with a 1440 default, the likeliest origin of the original
mistake. And drops a review-bookkeeping aside from rules.py.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/02, 03, 04, 05. Gitflow is correct (base develop, no pyproject.toml version bump, SDK PR linked under ## Requires), the floor stays at 4.3.0 with all three guards fired only on the new surfaces, and the spec updates track the code. Three things worth acting on, none blocking.

1. specs/05-sdk-contract.md does not cover exc.request.result (spec drift)

src/polyswarm/client/rules.py:100 reads getattr(exc.request, 'result', None) as the fallback refusal message, but the §"What the CLI imports from the SDK" row only documents exc.request.errors['code']:

Also RequestException, caught by rules favorite (client/rules.py) to read the machine-readable FAVORITE_LIMIT refusal off exc.request.errors['code'].

That table exists precisely to enumerate what the CLI reaches for on the SDK's request object — .result is a second such reach, and it is the one with no cassette behind it (test_ruleset_favorite_limit_text's recorded envelope carries both counters, so the .result branch is only exercised by test_favorite_limit_without_counters_uses_the_server_message, whose request is a Mock). Add the attribute to the row, or drop the fallback and keep the static 'Favorite limit reached.' string.

2. live feed help text ships implementation rationale to users

src/polyswarm/client/live.py:44-46 and :49-50 put reviewer-facing justification into polyswarm live feed --help:

  • 'Ids are 17-digit numbers; click.INT matches every other id option in the CLI and rejects a typo before it reaches the server.'
  • 'A negative is refused here rather than silently meaning unbounded.'

Both are arguments for the type choice, not descriptions of behaviour. Move them to comments; the --help line only needs "Ids are 17-digit numbers" and "Must be non-negative". Every other option in the group is one sentence of behaviour.

3. Missing test: --since 0 is forwarded, unlike --max-results 0

specs/05 §Current floor lists --since 0 as a behaviour the CLI relies on and cannot enforce, and the help promises "Pass 0 for no time filter at all". Meanwhile --max-results 0 is deliberately dropped before the SDK (live.py:79), pinned by test_zero_max_results_is_unbounded_and_never_reaches_the_sdk. The two zeros mean the same thing to the user and take opposite paths in the code, and only one path is tested — test_plain_feed_forwards_neither_new_kwarg pins 86400 but nothing pins that --since 0 reaches live_feed as 0 rather than being filtered out the way max_results is. A one-line assertion in LiveFeedOptionsTest (self._invoke('--since', '0')live_feed.call_args[0][1] == 0) closes it and would catch a future refactor that folds since into the same conditional-kwargs block.

--max-results 0 is deliberately dropped before the SDK while --since 0 must
reach it, because 0 is how the server is told to apply no time filter. The
two zeros mean the same thing to a user and take opposite paths in the code,
and only one was pinned. Verified the new assertion catches the refactor it
names: folding `since` into the conditional-kwargs block makes it arrive as
None and fails that test alone.

`live feed --help` was carrying the rationale for the option TYPES — why
click.INT, why a negative is refused — which is reviewer context, not
behaviour a user needs at the prompt. Moved to a comment; the help lines now
read like every other option in the group.

specs/05 also now documents `exc.request.result`, the second attribute
`rules favorite` reaches for on the SDK's request object. The table exists to
enumerate exactly that, and only `.errors['code']` was listed.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Review

Checked the diff against AGENTS.md and specs/0105. No correctness, contract, or gitflow defects found. What I verified, then two minor notes.

Verified clean

  • Gitflow — base is develop, pyproject.toml version untouched, the SDK PR is linked under a Requires section, and there are no ticket IDs or AI trailers in the commit messages or PR body. Matches AGENTS.md §Gitflow and specs/05 §Coordinated changes.
  • Floor guardsPolyswarm overrides neither live_feed nor ruleset_list (src/polyswarm/polyswarm.py), so require_sdk_kwargs inspects the real SDK signature and the fail-open VAR_KEYWORD branch cannot be tripped by a CLI-side **kwargs wrapper. --since 0 (positional, must reach the SDK) and --max-results 0 (dropped, must not trip the guard) correctly take opposite paths, and both are pinned.
  • Exit codesexceptions.PolyswarmException maps to 2 via ExceptionHandlingGroup; RequestException never reaches the transport-MRO branch because it is caught earlier as api_exceptions.PolyswarmException. ExitCodeHierarchyTest pins that dependency, which is the right call — it is otherwise silent.
  • Cassettes — the new .vcr files look genuinely recorded, not copied: content-length: 39 is self-consistent with the recorded {"id":"…","favorite":1} body, and the list vs. detail serializers differ exactly as the PR body claims (new_results_count present in ruleset_list, absent from ruleset_view). The existing live feed cassettes pass --since 9999999 explicitly, so the default change does not invalidate them.
  • --since unit claim — cross-checked in-repo: historical list --since is seconds (src/polyswarm/client/historical.py:66) and download stream --since is minutes with IntRange(1, 2880)/default 1440 (src/polyswarm/client/download.py:62, src/polyswarm/polyswarm.py:218). The specs/02 note describes both accurately.
  • Formattersruleset_favorite added to base/text/json per specs/03 §Adding a renderable resource; the resource carries no hash digest, so the hashes.py formatters are correctly left alone.
  • Test guardstests/_sdk_guards.py keys on method / resource / parsed-attribute / parameter as specs/04 §Staying honest prescribes, and the probe payloads carry the keys they probe, so a silent skip-everything cannot arise.

Minor notes (non-blocking)

  1. specs/01-architecture.md §Support is now the odd spec out. Line 77 attributes parse_hashes to src/polyswarm/utils.py, but it lives in src/polyswarm/client/utils.py — and this PR adds a second shared helper (require_sdk_kwargs) to that same module, which 01 does not mention at all. specs/04 and 05 were corrected during this PR to say client/utils.py; 01 was not. Pre-existing drift, but AGENTS.md ("if a spec is thin or missing for an area you are changing, fill it in in the same PR") points at fixing it here, since you are adding to that file.

  2. require_sdk_kwargss fail-open branch is untested. specs/05 argues it is unreachable against the real 4.3.0 signatures, which is true today — but it is product code, and a future SDK that grows **kwargs on either method would silently forward the new options to an SDK that drops them, which is exactly the failure that spec paragraph describes. A test patching ruleset_list with a def f(self, **kwargs) stand-in and asserting exit 0 would pin it alongside the two existing floor stand-in tests.

`require_sdk_kwargs` returns early when the installed method declares
**kwargs, forwarding rather than false-refusing. Published 4.3.0 declares
none, so the branch is unreachable today — but it is product code, and an SDK
that grew one would take it and silently forward options the SDK drops.
Verified the test discriminates: deleting the branch makes it fail alone.

specs/01 §Support was the last spec still attributing `parse_hashes` to the
top-level `utils.py`; it lives in `client/utils.py`, which is also where this
change adds `require_sdk_kwargs` and `SDK_FLOOR`. specs/04 and 05 were
corrected earlier in this PR, 01 was missed.
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown

Reviewed against AGENTS.md and specs/01-05. Gitflow is correct (base develop, no pyproject.toml version bump, "## Requires" links the SDK PR). The floor-guard design, the exit-2 mapping, the generator iteration, the getattr render legs, the formatter method added on BaseOutput/JSONOutput/TextOutput, and the spec updates for every area touched all check out. Cassette URIs/ids line up with the invocations, test_live_feed_* pass an explicit --since so the 1440 -> 86400 default change does not touch them, and the re-recorded hunt bodies carry rule_id/rule_modified/source_rule_changed as null so the unguarded historical text tests stay valid. --include-counts is gone from the tree entirely.

One finding.

tests/_sdk_guards.py:43-48 - the resource-attribute guards are keyed on one attribute but gate tests that assert several.

needs_tracking_fields probes only rule_count; needs_provenance_fields probes only source_rule_changed. The tests they gate need more than that:

  • test_ruleset_tracking_fields_render_with_zero_distinct_from_absent asserts favorite, favorited_at, rule_count, historical_hunt_count, new_results_count.
  • test_ruleset_staleness_marker_renders_beside_the_count asserts new_results_count AND new_results_counted_at - neither of which the probe payload _RULESET even carries.
  • test_hunt_provenance_fields_render_with_the_reference_point asserts rule_id and rule_modified.

Against an SDK that parses the probed key but not the rest (a partial paired SDK, or one attribute renamed), the guard evaluates true, the tests run, and they FAIL rather than skip - and test_ruleset_none_and_false_fields_are_omitted passes vacuously for the unprobed fields. That is precisely the failure mode specs/04 "Staying honest on both installs the pin permits" row 3 legislates against, and precisely the bug an earlier commit in this PR fixed on the signature side when it made _accepts require every named parameter. The two guard families should be symmetric: require every attribute the gated tests actually read, the way _accepts requires every parameter. specs/04 row 3 under-specifies the same way (hasattr of a built resource against a single attr) and should move to the plural with it.

Nits, no action needed unless you care: test_a_kwargs_sdk_fails_open_rather_than_false_refusing exercises rules list but lives in LiveFeedOptionsTest. And the branch name carries an internal ticket ID - the AGENTS.md rule only covers commit messages, PR titles and descriptions (all clean here), but the branch name is public on this PR too.

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