Claude Fable 5.1 & GPT-6 Astra packages are live

Filtering

Free · MIT

Filter parameters that are expressive without being injectable — allowlisted fields, typed operators, index-backed queries, and bounded cost.

190 lines8.4 KB Open Ai API
Target models
GPT-6 AstraGPT-5.6GPT-5.5GPT-5 FamilyFuture GPT Models
Name
filtering
Category
API
Description
Filter parameters that are expressive without being injectable — allowlisted fields, typed operators, index-backed queries, and bounded cost.
License
MIT
Author
Agent.md maintainers
Last verified
2026-08-23
Reviewed by
unreviewed

#Scope contract

FILE_ISOLATION: Modify only files inside the scope the task names. Reading elsewhere is allowed; writing outside it is not, and a needed out-of-scope change is reported, not made. SIGNATURE_PINNING: Before implementing, write the exact signatures you will add or change (name, parameters, return type). Implement to those signatures; if one must change, say so before changing it. TYPE_CONTRACTS: Every public function carries explicit parameter and return types. No any, untyped dict, or interface{} at a module boundary.


#Purpose

Rules for query filtering on list endpoints. Filtering sits directly on top of the database, which makes it the place where API design, SQL injection and query performance meet. Three requirements, in priority order:

  1. Safe — no client string ever reaches SQL as structure.
  2. Bounded — no filter combination can produce an unindexed full scan.
  3. Predictable — the same query means the same thing next release.

#Syntax: pick one and hold it

StyleExampleTrade-off
Flat equality?status=paid&currency=EURSimplest; no ranges
Bracketed operators?total[gte]=1000&status[in]=paid,voidReadable, expressive. Default
Prefixed operators?minTotal=1000&maxTotal=5000Explicit; parameter count grows
RSQL / OData?filter=total>=1000;status==paidVery expressive; needs a real parser
JSON in a query param?filter={"total":{"$gte":1000}}Awkward to encode; invites Mongo-style injection

Bracketed operators cover almost every real need without a grammar to maintain. Whatever you choose, use it on every list endpoint — a per-endpoint dialect is a permanent tax on client authors.

Repeated parameters mean IN, and it should be documented:

ini
?status=paid&status=refunded        →  status IN ('paid','refunded')
?tag=eu&tag=priority                →  tag = 'eu' AND tag = 'priority'   (also valid — pick one, document it)

#Allowlist the field and the operator

This is the whole security story. Never map client input to SQL structure by interpolation.

ts
const FILTERABLE = {
  status:    { column: "status",       ops: ["eq", "in"],               type: "enum"   },
  totalCents:{ column: "total_cents",  ops: ["eq","gte","lte"],         type: "int"    },
  createdAt: { column: "created_at",   ops: ["gte","lte"],              type: "date"   },
  email:     { column: "email",        ops: ["eq", "startsWith"],       type: "string" },
} as const;

const OPS = { eq: "=", gte: ">=", lte: "<=", in: "IN" } as const;

function clause(field: string, op: string, raw: unknown) {
  const spec = FILTERABLE[field];
  if (!spec) throw new BadRequest(`Unknown filter field: ${field}`);
  if (!spec.ops.includes(op)) throw new BadRequest(`Operator ${op} not allowed on ${field}`);
  const value = coerce(spec.type, raw);              // throws on a bad value
  return { sql: `${spec.column} ${OPS[op]} ?`, value };   // column from the table, never from input
}

Three properties matter:

  • The column name comes from your table, keyed by an alias. A client-supplied column name interpolated into SQL is injection even if the value is parameterised. → Security/sql-injection
  • The operator comes from a fixed map. OPS[op] with an unknown key is undefined, not a fragment.
  • Values are parameterised and type-coerced. Reject ?totalCents[gte]=abc with 400, do not coerce it to 0.

Exposing your internal column names in the API also freezes your schema — the alias layer means a column rename is not a breaking change.


#Bound the cost

An expressive filter language lets a client construct a query nobody planned for.

  • Every filterable field must be indexed, or explicitly documented as slow and rate-limited more strictly. → Database/indexes
  • Cap the number of filters per request (e.g. 10) and the size of an in list (e.g. 100).
  • Leading-wildcard search (%term%) cannot use a B-tree index. Offer startsWith instead, or route full-text search to a trigram/GIN index or a search engine — not to LIKE '%…%' on a large table.
  • Always combine filtering with pagination and a bounded limit. → API/pagination
  • Tenant scoping is not a filter. It is applied server-side to every query, regardless of what the client sent. → Security/authorization

Check the plan for the worst legal combination, not the common one:

sql
-- The worst request a client may legally send, planned before it is shipped
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE tenant_id = $1
  AND status = ANY($2)                 -- in: 100 values
  AND total_cents >= $3
  AND created_at BETWEEN $4 AND $5
ORDER BY created_at DESC, id DESC
LIMIT 100;

A Seq Scan or a Rows Removed by Filter in the hundreds of thousands means the filter combination is not index-backed and must be narrowed or indexed before release. → Database/query-optimization


#Semantics worth defining once

  • Multiple fields combine with AND. If you need OR, add it explicitly rather than overloading repeated parameters.
  • Absent versus empty: ?status= should be a 400, not "match everything" and not "match empty string".
  • Null matching needs an explicit operator (?deletedAt[isNull]=true) — = NULL never matches.
  • Ranges are inclusive unless the operator says otherwise, and dates are RFC 3339 UTC.
  • Case sensitivity is a documented property per field, backed by a matching index (lower(email) needs an expression index).

Document every filterable field, its operators and its type in the OpenAPI document, so clients and generators see the same contract. → API/open-api


#Anti-patterns

Anti-patternWhy it failsFix
Client-supplied column name in SQLInjection, even with parameterised valuesAlias → column allowlist
Operator string interpolated?op=; DROP reaches the parserFixed operator map
Values not type-checkedabc coerced to 0; wrong resultsCoerce and reject
Unindexed filterable fieldFull scan on demandIndex it or document and limit it
LIKE '%term%' on a large tableCannot use a B-tree indexstartsWith, trigram index, or a search engine
Unbounded in listOne request scans everythingCap the list length
No cap on filter countUnplanned query shapesLimit filters per request
Filtering without paginationUnbounded result setAlways paginate
Tenant scope passed as a filterClient can omit or change itServer-side, unconditional
Per-endpoint filter dialectsEvery client re-learns the APIOne syntax everywhere
Empty value means "all"Surprising and easy to send accidentally400 on empty
Internal column names in the APISchema changes become breakingAlias layer
Undocumented filtersDiscovered by trial and error, then depended onDeclare in OpenAPI

#Checklist

  • One filter syntax is used across every list endpoint
  • Filterable fields come from an explicit allowlist mapping alias → column
  • Allowed operators are declared per field
  • Operators resolve through a fixed map, never string interpolation
  • Values are type-coerced and rejected with 400 when invalid
  • All values are passed as bound parameters
  • Every filterable field is indexed, or documented as slow and rate-limited
  • in list length and filters-per-request are capped
  • Substring search does not use a leading-wildcard LIKE on large tables
  • Filtering is always combined with pagination and a bounded limit
  • Tenant scoping is applied server-side and cannot be influenced by input
  • Combination semantics, null handling and case sensitivity are documented
  • The worst legal filter combination has been checked with EXPLAIN ANALYZE
  • Filters are declared in the OpenAPI document