Apply the documented limit to GET /v1/dev/user/goals when include_inactive is true#9977
Apply the documented limit to GET /v1/dev/user/goals when include_inactive is true#9977ZachL111 wants to merge 3 commits into
Conversation
…ctive is true
get_goals clamps limit to [1, 1000] and then branches. The default branch calls
goals_db.get_user_goals(uid, limit=limit), which is bounded. The include_inactive branch
called goals_db.get_all_goals(uid, include_inactive=True), which has no limit parameter at
all, so the clamp was dead code on that branch.
The result is that GET /v1/dev/user/goals?include_inactive=true&limit=5 returns every goal
the user has ever created, while the same request with include_inactive=false correctly
returns 5. That contradicts the endpoint's own documented parameter, published in
docs/api-reference/openapi.json:
- **limit**: Maximum number of goals to return
and it contradicts the comment directly above the branch, which states the clamp exists so
"an oversized limit cannot stream the whole collection".
The bound belongs at this call site rather than in the shared helper. get_all_goals is
deliberately the "fetch everything" helper for five other production callers that need the
full set: the single-goal lookup in this same router, routers/goals.py, mcp.py and
mcp_sse.py. Unbounded is correct where no limit is advertised, and routers/goals.py
deliberately declares no limit parameter, pinned by test_workstream_router_contract.py. The
dev route advertises a limit and then drops it, which is the asymmetry being fixed.
Tests (tests/unit/test_dev_goals_limit_include_inactive.py):
- include_inactive=true with limit=5 over 25 goals returns 5
- the clamp ceiling still applies: limit=99999 over 1500 goals returns 1000
- the active-only branch still delegates its limit to get_user_goals unchanged
Verification (run in backend/):
- pytest new test plus test_workstream_router_contract.py and test_goals_id_fallback.py:
31 passed
- prove-fail: with routers/developer.py reverted to origin/main and confirmed byte-identical
(git diff origin/main empty), the two include_inactive tests fail with assert 25 == 5 and
assert 1500 == 1000, while the active-only test still passes. Re-applied: 3 passed
- black --line-length 120 --skip-string-normalization --check: clean
- pyright routers/developer.py: 0 errors
- scripts/check_module_stub_pollution.py: 733 files, 0 violations
- no signature change, so no OpenAPI or generated-client regeneration is needed
3ce8541 to
fb4e1e7
Compare
|
Note on the red Backend unit suite: the failure is not from this change. The failing test is This PR touches routers/developer.py and adds tests/unit/test_dev_goals_limit_include_inactive.py. #9968 replaces those pinned anchors with |
Git-on-my-level
left a comment
There was a problem hiding this comment.
Thanks for the focused fix and the regression coverage here. I agree this endpoint should not ignore limit when include_inactive=true, and keeping the existing fetch-everything helper behavior for other callers is the right constraint.
I’m requesting one change before this lands: the new slice bounds the response, but it still calls goals_db.get_all_goals(uid, include_inactive=True) without any storage/query limit, so /v1/dev/user/goals?include_inactive=true&limit=5 can still stream every historical goal for the user before trimming in Python. That leaves the performance/abuse concern from the existing clamp comment unresolved ("an oversized limit cannot stream the whole collection"), even though the returned payload length is now correct.
Could you push the bound down to the data-access/query path for this call site without changing the fetch-everything semantics of the other callers? For example, an optional limit/order-aware path in the goals DB helper (defaulting to current behavior for existing callers) or a dedicated bounded helper for this route would address the original issue more completely. It would also be good for the regression test to assert the bounded helper/query path is used, not only that the final list is sliced.
I also could not validate the new backend test locally in this review environment because the checkout does not have the backend Python dependencies installed (fastapi import fails during collection), and the GitHub Backend unit suite is currently red. The author’s note points to an unrelated stale-fixture failure, but this should still get a clean maintainer/CI pass before merge.
by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.
Addresses review feedback. The previous revision sliced the list get_all_goals returned, which
made the payload length correct but still streamed every historical goal for the user before
trimming in Python. That left the actual cost the clamp exists to prevent unresolved, and its own
comment says so: "an oversized limit cannot stream the whole collection".
get_all_goals gains an opt-in keyword-only limit that bounds the read at the query:
if limit is not None:
query = query.order_by('created_at', direction=firestore.Query.DESCENDING).limit(limit)
Two details that are not obvious:
Ordering is required, not decorative. The function sorts by created_at descending after
fetching, so a bare .limit(n) would hand back an arbitrary n documents that merely look sorted
afterwards. Ordering the query by the same key makes the bounded page the newest n goals, which
is what the unbounded path already returned.
The limit is only accepted together with include_inactive=True, and raises ValueError otherwise.
That shape carries no equality filter, so ordering by created_at is served by Firestore's
automatic single-field index. Combining a limit with the is_active filter would need a composite
index this project does not declare, and Firestore answers a missing composite index with an
opaque 500, so the unsupported combination fails loudly here rather than in production. Every
goal written by create_goal carries created_at (_new_goal_payload sets it), so the ordering does
not drop normally created documents.
The default is unchanged: all five existing callers omit limit and keep fetch-everything
behaviour (/v1/dev/user/goals/{goal_id}, routers/goals.py, mcp.py, mcp_sse.py).
Tests now assert the bounded path is taken, not only that the final list is short:
- the router delegates the clamped limit to the helper (asserted on the captured kwargs)
- the clamp ceiling of 1000 is delegated the same way
- the DB helper pushes both order_by('created_at') and limit(n) into the query
- omitting limit pushes neither, so existing callers still fetch everything
- a limit with include_inactive=False raises ValueError
- the active-only branch still delegates to get_user_goals unchanged
Verification (run in backend/):
- pytest new test plus test_goals_id_fallback.py and test_workstream_router_contract.py:
34 passed
- prove-fail: with routers/developer.py and database/goals.py reverted to origin/main and
confirmed byte-identical (git diff origin/main empty), four tests fail with
"get_all_goals() got an unexpected keyword argument 'limit'" and the two control tests
(active-only branch, unbounded default) still pass. Re-applied: 6 passed
- black --line-length 120 --skip-string-normalization --check: clean
- pyright routers/developer.py database/goals.py: 0 errors
- scripts/check_module_stub_pollution.py: 733 files, 0 violations
Ratchet baseline conflicted as usual. main lowered routers/developer.py to 2184; this branch adds three comment lines explaining the delegated bound, so the baseline is 2187 and the justification is rewritten to describe the query-level bound rather than the earlier slice-at-the-route approach. app/ and web/ held at origin/main so the pre-commit formatters cannot introduce churn.
|
You are right, and the new head fixes it properly. Slicing the returned list corrected the Taking your first option: if limit is not None:
query = query.order_by('created_at', direction=firestore.Query.DESCENDING).limit(limit)Two things worth calling out, since neither is obvious from the diff: The ordering is load-bearing, not decorative. The function sorts by The limit is only accepted with On your second point, the tests now assert the bounded path is taken rather than only that the
Prove-fail against origin/main (confirmed byte-identical first): four tests fail with On the environment note: that is fair, and the red Backend unit suite was not from this PR. The |
|
Correction to what I said earlier about the memory adapter failures. I said the fix for them was #9968 and that landing it would clear the red. That is no longer Main fixed the same problem itself in 60eda16 ("test(memory): freeze vector adapter fixture Verified on origin/main at 7429104 with nothing applied: test_chat_memory_adapter.py 11 So nothing needs to be merged to unblock this. Any PR still showing that failure just needs |
kodjima33
left a comment
There was a problem hiding this comment.
Dev API goals limit fix. Approve-only — existing CHANGES_REQUESTED review outstanding.
Current head pushes the clamped limit into the bounded goals query and adds regression coverage, resolving this automation review blocker.
Git-on-my-level
left a comment
There was a problem hiding this comment.
Thanks for the update — the current head addresses the blocker from my earlier review.
The fix now pushes the clamped limit into the Firestore query for the include_inactive=true route, keeps the existing fetch-everything behavior for callers that omit limit, and the regression tests assert both the router delegation and the bounded query path. I also re-ran the targeted backend coverage locally after syncing the backend Python environment:
python -m pytest tests/unit/test_dev_goals_limit_include_inactive.py tests/unit/test_goals_id_fallback.py tests/unit/test_workstream_router_contract.py -q
Result: 34 passed.
I’m not formally approving from this automation run because the PR is still under the maintainer-review gate (needs-maintainer-review / security-sensitive API surface), but from a code-review perspective this looks ready for human maintainer sign-off.
by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with need human response.
GET /v1/dev/user/goals ignores its own documented
limitwheninclude_inactive=true, andreturns every goal the user has ever created.
routers/developer.py clamps the limit and then branches:
get_user_goalsis bounded and ends inreturn goals[:limit].get_all_goals(database/goals.py) has no
limitparameter at all, no.limit()and no slice, so the clampis dead code on that branch. The exact thing the comment says it prevents, streaming the whole
collection, is what happens.
The endpoint documents the parameter in its own docstring, published in
docs/api-reference/openapi.json:
So
?include_inactive=true&limit=5returns everything, while?include_inactive=false&limit=5correctly returns 5.
Why the fix is at the call site
get_all_goalsis deliberately the fetch-everything helper. It has five other productioncallers that legitimately need the full set: the single-goal lookup at developer.py, the
/v1/goals/allroute in routers/goals.py, and the MCP goal reads in mcp.py and mcp_sse.py.Adding a limit to the helper would change all of them.
Unbounded is correct where no limit is advertised.
routers/goals.py::get_all_goalsdeclaresno
limitparameter at all, and that is pinned bytests/unit/test_workstream_router_contract.py. The dev route advertises a limit and then drops
it, which is the asymmetry this fixes. The sibling dev route at developer.py:1354 clamps
correctly.
No signature change, so no OpenAPI or generated-client regeneration is required.
Tests
tests/unit/test_dev_goals_limit_include_inactive.py:
include_inactive=truewithlimit=5over 25 goals returns 5limit=99999over 1500 goals returns 1000get_user_goalsunchangedThe seam is
monkeypatch.setattr(developer.goals_db, ...)plus a direct handler call, matchingthe existing pattern in test_workstream_router_contract.py. No
sys.modulesmutation.Verification
Run in
backend/:31 passed
(
git diff origin/mainempty), the two include_inactive tests fail withassert 25 == 5andassert 1500 == 1000, while the active-only test still passes. Re-applied: 3 passedblack --line-length 120 --skip-string-normalization --check: cleanpyright routers/developer.py: 0 errorsscripts/check_module_stub_pollution.py: 733 files, 0 violationsscripts/pr-preflight --suggest: no product invariants affected, no failure-classdeclaration required
.github/scripts/product_file_line_count_ratchet_baseline/backend-routers.json is raised from
2186 to 2189 with the required one-line raise_justifications entry