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 === undefinedrawLimit = 50wasClamped = falseclamped_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.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.