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):
listTasks(db, { limit: 1000 })returnsclamped_limit === 1000.listTasks(db, { limit: 100 })returnsclamped_limit === null.listTasks(db, {})(default) returnsclamped_limit === null(default 50 is not clamped).listTasks(db, { limit: 500 })(boundary) returnsclamped_limit === null.listTasks(db, { limit: 501 })returnsclamped_limit === 501(just-over).- The existing ‘clamps limit > 500 to 500’ test extended to assert the signal.
tasksarray remains areadonly 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)
- The existing ‘Zod schema rejects limit > 500’ test inverts to ‘Zod schema accepts limit > 500 (clamps in repository)’.
- The existing ‘Zod schema accepts limit=500 (boundary)’ remains true.
- New:
task_listpayload includesclamped_limitfield (presence assertion + value). - New: when
task_listis called withlimit: 1000, the handler emits one INFO line viactx.logger. Use a spy logger. - New: when
task_listis called withlimit: 100, no INFO line is emitted. - Existing
total_countandtasksassertions 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 = 500exactly — not clamped,clamped_limit = null.filter.limit = 501— clamped,clamped_limit = 501.
Non-goals
- Changing
MAX_LIMITorDEFAULT_LIMIT. - Changing what gets clamped (still SQL
LIMIT, no offset clamp). - Adding rate-limiting or per-call throttling.
- Adding a
clamped_offsetsignal (the contract is forlimitonly). - Touching any other β tool.
- Restructuring the prepared-statement cache.
- Changing the α middleware envelope ({ok, data, error}).