skip to content

A matters listing must hide matters walled off by a separate permissions store — how do you build page seven?

level: middleimportance: must knowfreq 55%

answer

  1. the predicate lives in another store
  2. the permitted set's size decides
  3. push identifiers in, or replicate them
  4. one batched call, never one per row

basics

~20 s

Get the permission predicate and the rows into the same place. Either fetch the permitted identifiers and push them into the query, or replicate the permission data beside the matters, or batch-check a candidate page in one call.

solid answer

~50 s

Three shapes, and the size of the permitted set picks one. If the set is small and enumerable, fetch the identifiers from the permissions store and push them into the query as a set-membership predicate: the database then owns filtering, ordering and paging, and every page is full. If the set is large or defined by a rule rather than a list, you cannot ship it in a predicate — replicate the permission data into the same database so the predicate becomes a local `EXISTS` on an indexed table, and accept that the copy lags its source. If neither is possible, over-fetch a candidate page and check it in ONE batched call rather than one call per row, refilling under a cap. That last shape is honest but gives up exact totals and meaningful offsets, and its latency is a remote call you do not control.

code

sql · 8 lines
sql
-- Shape 1: the permitted identifiers were fetched from the permissions
-- store and ride into the query as a predicate, so the database pages.
SELECT m.id, m.title, m.opened_on
FROM matter m
WHERE m.id IN (:permitted_matter_ids)           -- 312 ids, fetched before this call
  AND (m.opened_on, m.id) < (:cursor_opened_on, :cursor_id)
ORDER BY m.opened_on DESC, m.id DESC
LIMIT 26;                                        -- 25 wanted, +1 to answer 'is there more'

go deeper

for a junior

Know that a listing must never show a record the caller is not allowed to see, and that removing rows after the database has already selected and counted them is what makes a page come back short.

for a middle

Be able to draw all three shapes and say which one a stated permitted-set size calls for: identifiers pushed into the query, permission data replicated locally, or one batched check over a candidate page with a refill cap.

for a senior

Show that you sized the permitted set before choosing, that the remote authorization call has a timeout and a stated failure direction, and that you can say what each shape costs in totals, offsets and tail latency.

for a principal

Own the consequence: the shape you pick fixes whether listing cost scales with a customer's permissions, and changing it later means running two paths in parallel long enough to prove they agree.

## The difficulty in one sentence A listing endpoint has to answer *page seven of the matters this lawyer may open*. The matters are rows in a relational database. The ethical walls that hide some of them are not a column on that table — they live in a conflicts system, a relation-tuple store, an external decision service or a directory. **The predicate that decides visibility is not available where the paging happens**, and everything below is a way of getting the two into the same place. The tempting move is to fetch twenty-five rows and drop the ones the permissions store rejects. It fails for a reason worth stating precisely: paging is arithmetic over the row set *the database* sees. Remove rows after the database has counted them and the page arrives short, the next offset no longer names the position the caller thinks it does, and any total you computed describes a different set from the one you returned. That argument is well trodden. What follows is what you do instead. ## Shape 1 — push the identifier set into the query Ask the permissions store which matters this principal may see, then send those identifiers to the database as a predicate: a set-membership test, a join against a temporary set, or a values list. The database now owns the whole job — filtering, ordering, paging, counting — and every page comes back full. This is the right default, and it has exactly one bound: the size of the permitted set. - A few hundred identifiers ride in a predicate without comment. - A few thousand begin to dominate statement parsing, planning and the round trip that carried them. - Two hundred thousand is not a predicate. Statement size, planner work and network transfer all scale with the caller's permissions rather than with the size of the page — the opposite of what a paged endpoint is for. - A set defined by a rule (*every matter in the practice group, and the group keeps growing*) cannot be enumerated at all. Two escapes before you abandon the shape. **Invert it**: ask for the identifiers the principal may NOT see, when the deny set is the small one — with ethical walls it usually is, because a wall is an exception. **Project it onto columns you already store**: if visibility follows client, office or practice group, the predicate becomes a condition on those columns and no identifier list is needed. Both work only while the permission model is simple enough to express that way. ## Shape 2 — put the predicate next to the data If the permitted set cannot be bounded, move the permission data rather than the answer. Maintain a copy of *who may see which matter* in the same database as the matters, indexed, so the predicate becomes a local `EXISTS`. Paging, ordering and counting all work again, and the per-page cost stops growing with the caller's permissions. You pay in freshness: the copy trails its source by however long propagation takes, and on a removal that delay is a window in which the listing still shows a walled matter. That trade needs a written budget and a detector, and it is its own subject. ## Shape 3 — batch the check Sometimes neither works: the permitted set is unbounded and the permission data cannot be replicated. Then you do judge rows after fetching them — but in one call, not twenty-five. 1. Fetch a candidate page, deliberately larger than the page you owe. 2. Send the whole candidate page to the permissions store in ONE batched request and receive the subset that passes. 3. If the page is still short, continue from the last candidate's cursor and refill — with a hard cap on refills, so a caller who may see almost nothing cannot walk the whole table inside one request. What batching buys is latency and load: one round trip instead of twenty-five whose latencies add, and one authorization request per page instead of per row. What it does not buy is correctness of the surrounding arithmetic — you cannot offer an exact total, offsets are meaningless so the endpoint must be cursor-paged, and your response time now carries the slow tail of a remote call. Give that call a timeout and decide what a timeout means: for a listing, failing closed returns fewer matters than the lawyer may see, which is a correctness complaint; failing open returns matters behind a wall, which is the incident. ## Choosing | shape | permitted set | per-page cost | exact total | freshness | |---|---|---|---|---| | identifiers pushed into the query | small, enumerable | grows with the set | yes | exact | | permission data replicated locally | any | flat, indexed | yes | trails the source | | batched check on a candidate page | any | one remote call plus refills | no | exact | ## What an interviewer is listening for - That you noticed the permission data is in another store — that is the entire difficulty, and the rest is consequence. - That *how large can the permitted set get, for the largest customer, in two years* is a question you asked before choosing. - That you named each shape's cost instead of presenting one as correct. - That the refill loop is capped and the remote call has a timeout with a stated failure direction.

  • The set of matters this lawyer may NOT see is far smaller than the set they may. What changes?
    Invert the predicate: fetch the denied identifiers and exclude them instead of enumerating the allowed ones. An ethical wall is an exception, so the deny set is often two orders of magnitude smaller and fits in a predicate long after the allow set stopped fitting. The inversion is only safe if the permissions store can answer deny-set queries completely — a partial deny list silently permits everything it omits.
  • The batched authorization call times out while refilling page seven. What do you return?
    The rows already judged and permitted — a short page — and a cursor that resumes from the last candidate you judged. Never the unjudged candidates. A listing that fails closed loses rows the lawyer was entitled to see, which surfaces as a complaint; failing open shows a matter behind a wall, which is the incident the wall exists to prevent. Record the truncation so short pages are visible as an operational signal.
  • A caller may see nine matters in a table of ten million. What stops one request scanning all of them?
    The refill cap bounds the work per request, but the real fix is a predicate the database can drive from an index: a replicated permission table indexed by principal, with the sort key alongside it, so the scan starts inside the permitted rows rather than filtering the whole table down to nine. If you cannot build that index, cap the walk and say so in the contract rather than letting one listing request read the table.

saying these in an interview costs you the question

  • Fetch the page, then filter it — the framework sorts the rest out
  • A set-membership predicate is fine at any size; databases are fast
  • Call the permissions store once per row; it is only twenty-five calls
  • If the permission data is elsewhere, correct paging is impossible
  • Over-fetching guarantees a full page, so no refill cap is needed