Packet — fix-pagination-clamp-signal
Concrete execution plan for implementing the contract.
Files touched
| File | Kind | Change |
|---|---|---|
src/domains/tasks/repository.ts |
source | Add ListTasksResult type; change listTasks return shape; relax Zod limit bound; update task_list handler to surface clamped_limit + emit INFO log |
src/__tests__/domains/tasks/repository.test.ts |
test | Adapt 19 call-site destructurings; extend clamp test; add clamped_limit assertions |
src/__tests__/domains/tasks/tools.test.ts |
test | Adapt 5 call-site destructurings; flip Zod limit-rejection test; add clamped_limit payload + logger spy tests |
docs/audits/fix-pagination-clamp-signal-audit.md |
audit | Step 1 (already shipped) |
docs/contracts/fix-pagination-clamp-signal-contract.md |
contract | Step 2 (already shipped) |
docs/verification/fix-pagination-clamp-signal-verification.md |
verification | Step 5 |
Implementation steps
1. Type definition
Add near ListTasksFilter (after line 131):
/**
* Return shape of `listTasks`. The `tasks` array is the page slice; `clamped_limit`
* signals whether `filter.limit` was clamped to `MAX_LIMIT`.
*
* - `clamped_limit === null` — caller's limit was respected (or defaulted to 50).
* - `clamped_limit === N` (number) — caller passed `limit = N` with `N > MAX_LIMIT`;
* the SQL was bound with `LIMIT 500` and only 500 rows returned.
*
* Boundary semantics: `filter.limit === 500` is NOT clamped (`null`). Strict `>`.
*/
export interface ListTasksResult {
readonly tasks: readonly Task[];
readonly clamped_limit: number | null;
}
2. Update listTasks (line 536)
export function listTasks(db: Database, filter: ListTasksFilter = {}): ListTasksResult {
const shape: ListShape = { ... };
const rawLimit = filter.limit ?? DEFAULT_LIMIT;
const wasClamped = rawLimit > MAX_LIMIT;
const limit = wasClamped ? MAX_LIMIT : rawLimit;
const offset = filter.offset ?? 0;
const bindings: Record<string, unknown> = { limit, offset };
... (unchanged binding population) ...
const stmt = getListStatement(db, shape);
const rows = stmt.all(bindings);
return {
tasks: rows.map(rowToTask),
clamped_limit: wasClamped ? rawLimit : null,
};
}
Note: the test for “default limit not clamped” checks filter.limit === undefined → rawLimit = 50 → wasClamped = false → clamped_limit = null. Correct.
3. Relax Zod schema (line 627)
const TaskListInputSchema = z.object({
status: z.enum(TASK_STATES).optional(),
project_id: z.string().nullable().optional(),
limit: z.number().int().positive().optional(), // was: .max(500)
offset: z.number().int().min(0).optional(),
include_deleted: z.boolean().optional(),
});
Drop .max(500). Comment-update at the top of the schema if needed.
4. Update task_list handler (line 782)
(input): { tasks: readonly Task[]; total_count: number; clamped_limit: number | null } => {
const result = listTasks(getDb(), input as ListTasksFilter);
if (result.clamped_limit !== null) {
ctx.logger(
`[task_list] clamped limit: requested=${result.clamped_limit} max=${MAX_LIMIT}`,
);
}
return {
tasks: result.tasks as Task[],
total_count: result.tasks.length,
clamped_limit: result.clamped_limit,
};
},
MAX_LIMIT is module-private — keep it that way; the literal 500 may be inlined in the log string for clarity. Decision: use MAX_LIMIT since it’s already in module scope and changes (if any future) flow through one place.
The as Task[] cast on the readonly array is safe: the registry’s content stage stringifies via JSON.stringify, and the readonly modifier is a TypeScript-only annotation. We could also keep it readonly all the way through — but the existing task_list handler return type is Task[], and consumers may need a mutable view. Keeping it as a copy is unnecessary; the simplest cast preserves the existing public surface.
5. Update tool description (line 779)
'List tasks with optional filters (status, project_id, limit, offset, include_deleted). Returns `{tasks:[...],total_count:N,clamped_limit:K|null}`. `clamped_limit` is the requested limit when it exceeded the server cap (500), or null otherwise.'
6. Update doc-comment near listTasks (lines 522–535)
Adjust the docblock to mention the new return shape and clamped_limit field.
Test plan
src/__tests__/domains/tasks/repository.test.ts
19 call-site adaptations. Pattern: each const rows = listTasks(db, …) becomes const { tasks } = listTasks(db, …) (or kept as result and use result.tasks). The clamp test at line 571 extended:
it('clamps limit > 500 to 500 and signals clamped_limit', () => {
for (let i = 0; i < 3; i++) {
createTask(db, { title: `T${i}` });
}
const result = listTasks(db, { limit: 10_000 });
expect(result.tasks).toHaveLength(3);
expect(result.clamped_limit).toBe(10_000);
});
it('returns clamped_limit=null when limit ≤ MAX_LIMIT', () => {
createTask(db, { title: 'a' });
expect(listTasks(db, { limit: 100 }).clamped_limit).toBeNull();
});
it('returns clamped_limit=null when limit is unspecified', () => {
createTask(db, { title: 'a' });
expect(listTasks(db).clamped_limit).toBeNull();
});
it('boundary: limit=500 is not clamped', () => {
createTask(db, { title: 'a' });
expect(listTasks(db, { limit: 500 }).clamped_limit).toBeNull();
});
it('just-over: limit=501 yields clamped_limit=501', () => {
createTask(db, { title: 'a' });
expect(listTasks(db, { limit: 501 }).clamped_limit).toBe(501);
});
The remaining 18 call sites destructure or accept the new shape — purely mechanical edit.
src/__tests__/domains/tasks/tools.test.ts
5 call-site adaptations. Plus:
it('Zod schema accepts limit > 500 (clamps in repository, no longer rejected)', () => {
expect(TaskListInputSchema.safeParse({ limit: 1000 }).success).toBe(true);
});
it('Zod schema accepts limit=500 (boundary)', () => {
expect(TaskListInputSchema.safeParse({ limit: 500 }).success).toBe(true);
});
The old 'Zod schema rejects limit > 500' assertion at line 440 inverts.
New tests for the handler:
it('task_list payload includes clamped_limit field (null when limit ≤ 500)', () => {
createTask(db, { title: 'a' });
const result = listTasks(db, { limit: 100 });
const payload = {
tasks: result.tasks as Task[],
total_count: result.tasks.length,
clamped_limit: result.clamped_limit,
};
expect(payload.clamped_limit).toBeNull();
});
it('task_list payload includes clamped_limit field (number when clamped)', () => {
createTask(db, { title: 'a' });
const result = listTasks(db, { limit: 1000 });
const payload = {
tasks: result.tasks as Task[],
total_count: result.tasks.length,
clamped_limit: result.clamped_limit,
};
expect(payload.clamped_limit).toBe(1000);
expect(payload.tasks).toHaveLength(1);
});
Logger spy test (handler-level, requires registerTaskTools invocation)
it('emits one INFO log line via ctx.logger when clamped', async () => {
const calls: unknown[][] = [];
const logger = (...args: unknown[]): void => { calls.push(args); };
// Build a context with our spy logger and register tools
// ... (use createServer + registerTaskTools, point ctx.logger at our spy)
// Invoke handler with limit > 500 (via direct repo call + handler-mirror logic)
...
expect(calls.filter((c) => String(c[0]).startsWith('[task_list]'))).toHaveLength(1);
});
it('does NOT emit a log line when limit is unclamped', () => {
... // limit ≤ 500, expect zero matching log calls
});
The handler closure captures ctx.logger at registration time, so a spy passed via createServer({logger}) is observed.
Verification gates
Run in the worktree:
npm run build && npm run lint && npm test
All three must pass. The pre-task baseline at origin/main (commit 86e430fb) has 1357 tests passing. Target: same suite count + new repository/tool tests for the clamp signal — net positive delta.
Risk register
| Risk | Mitigation |
|---|---|
| Breaking 24 test sites | Mechanical destructure; each call already in scope; tests expose the new field cleanly. |
| Test-flake from spy logger | Use synchronous push into an array; assert .toHaveLength(1) rather than ordering. |
| Zod schema change exposes large-limit DoS | The clamp still fires at the repository — server still returns ≤500 rows. The Zod relaxation only changes “rejected” → “clamped+signaled”. Memory bound preserved. |
Forgetting boundary case (limit=500) |
Explicit test 'boundary: limit=500 is not clamped'. |
Estimated diff size
src/domains/tasks/repository.ts: ~25 lines added (type,wasClamped, return literal, log + log call, handler return), ~5 lines edited (Zod, handler shape, doc-comment).src/__tests__/domains/tasks/repository.test.ts: ~30 lines edited (24 destructurings + 5 new tests).src/__tests__/domains/tasks/tools.test.ts: ~50 lines added/edited (5 destructurings + 1 schema flip + 4 new tests).
Total ~110 lines net.