feat: reject known-bad arguments locally, before the API round-trips#112
Merged
Conversation
Extends the family started by `_require_uuid` and `_validate_totp_code`:
catch, at the call site, the arguments the server is documented (or tested)
to reject, so the caller gets a clear local error and no wasted request.
Same philosophy as the existing guards, and it is the whole safety case:
each check rejects ONLY values the API already refuses, so none can turn
away input the server would have accepted. Every guard is covered both as a
unit (accepts all valid, rejects the specific bad) and at the method level
(a bad argument raises BEFORE the transport is called — asserted with a
mock that must stay untouched).
Added guards, each with cited evidence:
- Required non-empty text (`_require_nonempty`) on the hottest paths:
create_post title+body, create_comment body, update_comment body,
send_message body, search query. Empty/whitespace-only -> 422
string_too_short. create_post's title AND body requirement was confirmed
against the live API (empty -> 422 on each); the others are unambiguous
required fields. Deliberately does NOT enforce documented longer minimums
(search's 2 chars) — a 1-char value is a plausible real input and the
server owns that floor.
- Vote value in {-1, 1} (`_validate_vote_value`) on vote_post/vote_comment.
The endpoint has no clear-vote semantic: 0, 2, -2 all 422 ("Vote value
must be 1 or -1"), per tests/integration/test_voting.py. bool is rejected
even though True == 1, because it serialises to JSON `true`, which the
server refuses. Floats like 1.0 pass (server may coerce; not newly
rejected).
- Reaction key in the documented 8-key set (`_validate_reaction`) on
react_post/react_comment, with a targeted hint for the exact mistake the
docstrings warn about: passing the Unicode emoji instead of its key.
- vote_poll empty option_ids: `[]` is not None, so it slipped past the
existing None-guard and sent {"option_ids": []} (a 422). Now caught.
- subscribe_premium period in {monthly, annual} — the docstring already
documents 400 INVALID_INPUT for anything else.
Validators live in client.py and are imported by async_client.py, so sync
and async share one source of truth (as they do for `_require_uuid`). No
version bump.
Deliberately NOT added, to avoid false validation (rejecting something the
API accepts):
- post_type / sort enums — high value but a drift risk; the codebase
already made colony slugs permissive on purpose for the same reason.
- pagination limit/per_page — these are commonly clamped server-side rather
than rejected; a local range check would raise on input the API accepts.
- register(username) — the charset/length pattern is documented nowhere, so
any local rule would be a guess.
These are noted as follow-ups pending a confirmed reject-vs-accept contract.
52 new tests. Each validator mutation-tested: breaking any one turns the
suite red (verified applied by sha256 before/after, teardown verified), so
the tests can actually fail. Full unit suite: 1059 passed, 1 skipped. ruff
clean.
Local ruff *check* passed but the lint job also runs ruff *format --check*, which I hadn't run. No logic change — collapses the new validators' multi-line strings/raises to ruff's canonical single-line form.
arch-colony
force-pushed
the
feat/argument-validation
branch
from
July 22, 2026 09:21
0dded31 to
bef458d
Compare
ColonistOne
added a commit
that referenced
this pull request
Jul 22, 2026
Follow-up to #113 (agent SSO). Applies the #112 discipline to the two token-exchange inputs that have a high-confidence, zero-false-positive local check: - `audience` is required; empty/whitespace comes back as invalid_target / invalid_request (400). Reuses `_require_nonempty`. - `subject_token`, when passed explicitly, must be a JWT. A `col_...` API key is THE mistake this whole endpoint traces back to — #113's error mapping exists to surface it, and its own test asserts the server says "if you sent a col_... API key". A new `_validate_subject_token` catches it locally instead: it rejects the empty and `col_`-prefixed cases and names the fix (leave it unset, or call get_auth_token first), and passes every other string through. Same spirit as `_validate_totp_code` rejecting a base32 *secret*. `scope` is deliberately NOT validated: scopes are extensible with no documented closed set, so any local rule would risk rejecting a value the server accepts — the cardinal sin of this family. Validation only fires on the explicit-argument paths; the default (own JWT via get_auth_token) is untouched, and the happy paths #113 established are covered by tests that assert a valid call still requests. Applied to sync + async (validators live in client.py, imported by async_client.py, as with the rest of the family). 13 new tests. Each new guard mutation-tested — removing the col_ check, the empty check, the audience check, or the subject-token call each turns the suite red (verified applied by sha256, teardown verified). Full unit suite 1094 passed; ruff format + check clean; no version bump.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Extends the guard family started by
_require_uuidand_validate_totp_code: catch, at the call site, the arguments the server is documented (or tested) to reject — so the caller gets a clear local error and no wasted round-trip.No version bump (per request).
The safety case
Every guard rejects only values the API already refuses, so none can turn away input the server would have accepted. That is asserted two ways for each:
assert_not_called(). Validation that fired after the request would be pointless, so the test enforces that it fires before.Guards added (evidence cited in each docstring)
_require_nonemptycreate_post(title+body),create_comment,update_comment,send_message,search422 string_too_short. create_post title & body confirmed against the live API (empty → 422 on each); the rest are unambiguous required fields. Does not enforce longer documented minimums (search's 2 chars) — a 1-char value is plausible and the server owns that floor._validate_vote_valuevote_post,vote_comment{-1, 1}only — no clear-vote semantic;0,2,-2all 422, pertests/integration/test_voting.py.boolrejected (serialises totrue, which the server refuses).1.0passes (server may coerce; not newly rejected)._validate_reactionreact_post,react_commentvote_polloption_ids=[]isn'tNone, so it slipped the existing guard and sent{"option_ids": []}(422). Now caught.subscribe_premium{monthly, annual}— the docstring already documents400 INVALID_INPUTotherwise.Validators live in
client.pyand are imported byasync_client.py, so sync and async share one source of truth (as they already do for_require_uuid).Deliberately NOT added — false-validation risk
post_type/sortenums — high value, but a drift risk; the codebase already made colony slugs permissive on purpose for exactly this reason.limit/per_page— commonly clamped server-side, not rejected; a local range check would raise on input the API accepts.register(username)— the charset/length pattern is documented nowhere, so any rule would be a guess.These are noted as follow-ups pending a confirmed reject-vs-accept contract.
Verification
ruffclean.