Contract — fix-pagination-clamp-signal

Behavioural contract

listTasks(db, filter) — repository function

Signature change (option (a) — typed return).

Before:

export function listTasks(db: Database, filter?: ListTasksFilter): Task[];

After:

export interface ListTasksResult {
  readonly tasks: readonly Task[];
  readonly clamped_limit: number | null;
}

export function listTasks(db: Database, filter?: ListTasksFilter): ListTasksResult;

clamped_limit semantics:

Caller passes Repository computes clamped_limit
filter.limit undefined (default 50) limit = 50 null
filter.limit ≤ MAX_LIMIT (500) limit = filter.limit null
filter.limit > MAX_LIMIT (500) limit = 500 (clamped) requested integer (e.g. 1000)
filter.limit exactly 500 (boundary) limit = 500 null (no clamp; 500 is allowed verbatim)

The clamp itself is unchanged. Only the new return field signals when it fired.

The boundary case is > (strict), matching the existing source code’s rawLimit > MAX_LIMIT ? MAX_LIMIT : rawLimit. A request for exactly 500 is not clamped.

task_list MCP tool

Input schema change. TaskListInputSchema.limit drops .max(500).

Before:

limit: z.number().int().positive().max(500).optional(),

After:

limit: z.number().int().positive().optional(),

Rationale: with the schema rejecting limit > 500, the clamp in the repository was unreachable from the tool layer. Relaxing the schema allows the clamp signal to be observable via clamped_limit.

Payload change. The handler return shape gains a clamped_limit field.

Before:

{ tasks: Task[]; total_count: number }

After:

{ tasks: Task[]; total_count: number; clamped_limit: number | null }

Wrapped by α middleware as {ok: true, data: {tasks, total_count, clamped_limit}} per the standard envelope.

Backwards compatibility. data.tasks and data.total_count retain their meaning. Only the new clamped_limit field is additive.

Logger. When the repository returns clamped_limit !== null, the handler emits exactly one INFO line via ctx.logger:

[task_list] clamped limit: requested=<N> max=500

Where <N> is the requested-but-clamped integer. No log line when clamped_limit === null.

Invariants preserved

  • MAX_LIMIT === 500 (constant unchanged).
  • DEFAULT_LIMIT === 50 (constant unchanged).
  • Clamping behavior unchanged — server still returns at most 500 rows.
  • Sort order unchanged: created_at DESC, id DESC.
  • Soft-delete discipline unchanged.
  • Prepared-statement cache unchanged.
  • Other β tools (task_create, task_get, task_update, task_next_actions) untouched.
  • Writeback / FSM / state-machine surfaces untouched.

Test obligations

Per acceptance criteria 4:

Repository tests (src/__tests__/domains/tasks/repository.test.ts)

Required assertions (new or updated):

  1. listTasks(db, { limit: 1000 }) returns clamped_limit === 1000.
  2. listTasks(db, { limit: 100 }) returns clamped_limit === null.
  3. listTasks(db, {}) (default) returns clamped_limit === null (default 50 is not clamped).
  4. listTasks(db, { limit: 500 }) (boundary) returns clamped_limit === null.
  5. listTasks(db, { limit: 501 }) returns clamped_limit === 501 (just-over).
  6. The existing ‘clamps limit > 500 to 500’ test extended to assert the signal.
  7. tasks array remains a readonly Task[] and still satisfies all existing semantics (sort, soft-delete, project filter, status filter, pagination, default limit 50, etc.).

Tool tests (src/__tests__/domains/tasks/tools.test.ts)

  1. The existing ‘Zod schema rejects limit > 500’ test inverts to ‘Zod schema accepts limit > 500 (clamps in repository)’.
  2. The existing ‘Zod schema accepts limit=500 (boundary)’ remains true.
  3. New: task_list payload includes clamped_limit field (presence assertion + value).
  4. New: when task_list is called with limit: 1000, the handler emits one INFO line via ctx.logger. Use a spy logger.
  5. New: when task_list is called with limit: 100, no INFO line is emitted.
  6. Existing total_count and tasks assertions continue to pass via the new envelope shape.

Logger spy

A test-side spy logger satisfies the requirement: const calls: unknown[][] = []; const logger = (...args: unknown[]) => calls.push(args);. The handler must be tested via the public task_list registration flow (or by direct call into a closure that captures ctx.logger).

Edge cases

  • filter.limit = 0 — already rejected by Zod’s .positive(). Unchanged.
  • filter.limit = -1 — already rejected by Zod’s .positive(). Unchanged.
  • filter.limit = 1.5 — already rejected by Zod’s .int(). Unchanged.
  • filter.limit = Number.MAX_SAFE_INTEGER — accepted, clamps to 500, clamped_limit = MAX_SAFE_INTEGER. (No new bound.)
  • filter.limit = 500 exactly — not clamped, clamped_limit = null.
  • filter.limit = 501 — clamped, clamped_limit = 501.

Non-goals

  • Changing MAX_LIMIT or DEFAULT_LIMIT.
  • Changing what gets clamped (still SQL LIMIT, no offset clamp).
  • Adding rate-limiting or per-call throttling.
  • Adding a clamped_offset signal (the contract is for limit only).
  • Touching any other β tool.
  • Restructuring the prepared-statement cache.
  • Changing the α middleware envelope ({ok, data, error}).

Back to top

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

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