@happyvertical/smrt-content 0.43.6 → 0.43.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "version": "1.0.0",
3
3
  "timestamp": 0,
4
4
  "packageName": "@happyvertical/smrt-content",
5
- "packageVersion": "0.43.6",
5
+ "packageVersion": "0.43.7",
6
6
  "objects": {
7
7
  "@happyvertical/smrt-content:ContentAsset": {
8
8
  "name": "contentasset",
@@ -3,14 +3,14 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-content",
6
- "packageVersion": "0.43.6",
6
+ "packageVersion": "0.43.7",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "f12a4f99476e438583ae373a728ec72c0e17bd64c20aac8573d2eb077b8b9706",
11
- "packageJson": "8e1d6dcf2f2881de966d7040e5476e30771402eed16745b459267d07569288fb",
10
+ "manifest": "d66564d5b8787c71a15e674bfec24cc36057de587e91fbfaecf1a61afdf1475e",
11
+ "packageJson": "c0b8a05640a8f008359579678854308a0ccb7d179b87cc40e3911db7c4934713",
12
12
  "agents": "9bfa2ade9b555e0c49fe38053f0413a36bb497eb9fce51c2b4628e228a6e98f2",
13
- "moduleDoc:agents/content-list.md": "8a06598b43c584d9ac8fabac6f6c18eb4fc99f32355398d13abb8e56297deb0a"
13
+ "moduleDoc:agents/content-list.md": "8c4a1ce4eb6e2dea884bb8632b49bf3186bfec920b9bc13d210a4cc5f5f2038d"
14
14
  },
15
15
  "exports": [
16
16
  ".",
@@ -10098,7 +10098,7 @@
10098
10098
  {
10099
10099
  "path": "agents/content-list.md",
10100
10100
  "module": "content-list",
10101
- "content": "<!-- Module doc for packages/content/AGENTS.md. Linked from the Modules table there. -->\n\n# ContentList: shared adapter, server query, URL state, saved views\n\n`ContentList` reads every row, column, filter, and action through one shared\nadapter (`src/svelte/content-list-controller.ts`) over a `DataTableController`,\nand can source those rows either from a client array or from the bounded\ncontent query endpoint. This doc covers both modes end to end.\n\n## ContentList migration (#2451)\n\n`ContentList` no longer holds bespoke local state. `src/svelte/content-list-controller.ts`\nis the single adapter every presentation reads from, and one shared\n`DataTableController` (from `@happyvertical/smrt-ui/data`) owns search, filters,\nsorting, page, and selection.\n\n| Before | After |\n|--------|-------|\n| local `searchTerm`/`selectedType`/`selectedStatus` runes | controller commands `setSearch` / `setFilters` (stable filter ids `type`, `status`) |\n| `filteredContents` `$derived` per view | `toContentListRows` → `selectContentListRows` → `paginateContentListRows`, computed once for all three modes |\n| bespoke `<table>` markup in compact mode | smrt-ui `DataTable` with the shared columns plus per-column cell snippets |\n| no selection | checkbox selection in every mode via `toggleRowSelection` / `setSelectedRows` |\n| `getViewHref` called inline three times | `resolveContentHref` / `contentListRowActions` (one eligibility source) |\n\nProps are unchanged and still exported as `ContentListProps`: `apiBaseUrl`,\n`contents`, `type` (still locks and hides the type filter), `defaultViewMode`\n(still seeds once), `onEdit`, `onDelete`, `onAdd`, `controls`, `getViewHref`.\nNew optional props: `loading`, `error`, `onRetry`, and `dataSurface`\n(`{ registry, descriptor? }`).\n\nAdapter exports (also re-exported from `./svelte`): `createContentListController`,\n`buildContentListColumns`, `buildContentListSurfaceDescriptor`,\n`toContentListRows`, `selectContentListRows`, `paginateContentListRows`,\n`contentListFilters`, `readContentListFilter`, `applyContentListFilter`,\n`contentListRowActions`, `resolveContentHref`, `selectableContentListRowIds`,\n`resolveSelectedContentListRows`, `resolveSelectedContents`, plus the\n`CONTENT_LIST_*` identity constants.\n\nNotes:\n\n- Controller modes are all `manual`: the adapter owns search, filters, sorting,\n and paging in **every** presentation, and the compact table receives\n `data={pageRows}` and no `totalRows` at all (see \"ContentList owns paging in\n EVERY presentation\" below for why that prop is withheld rather than computed).\n Letting DataTable filter locally over already-filtered rows re-ran the\n transform with subtly different semantics (untrimmed search, its own equality\n rules), so the two presentations could disagree. The component clamps the page\n with `controller.clampPage(queryRows.length)`. #2452 replaces the local\n implementation of that transform with a server query behind the same contract.\n- A `type` prop lock is enforced against live state, not just against the prop:\n a data-surface `set-filters` or `reset` command that drops the type filter is\n re-applied by the lock effect (equality-guarded, so it settles). The unlocked\n branch clears the filter only on an actual lock-REMOVAL transition — tracked\n with a non-reactive `previousLockedType` — because clearing on every run would\n also discard a type filter restored from a link or a saved view, and clearing\n never would strand the old lock after the prop went away.\n- Selection may only address durable rows. All three presentations render a\n disabled, explained checkbox for `identified: false` rows, page select-all\n skips them, and a normalization effect re-dispatches `setSelectedRows` without\n any non-durable id, which covers data-surface commands too.\n- Compact mode renders a content-owned `select` column (header + cell snippets)\n instead of passing `selectable` to DataTable. DataTable has no per-row\n selection predicate, so its header select-all addresses the synthetic id of an\n unidentified row; the normalization effect then strips it and the header stays\n indeterminate forever. Because column order is reconciled from the\n controller's known column ids, the structural `select` and `actions` ids are\n part of `CONTENT_LIST_TABLE_COLUMN_IDS` — omit them and selection renders\n behind every data column.\n- Only rendered columns are published to a data surface. `description` is a\n hidden, search-only column so search still reaches the deck; the descriptor\n additionally declares the `id` row-key column, which the surface contract\n requires but the table never renders.\n- Rows without a durable `id` (or repeating one) still render, keyed by\n position, but are marked `identified: false`;\n `resolveSelectedContentListRows` drops them so a bulk action can never act on\n an unaddressable row. `ContentData` has no expiry or site field, so the\n `site` column is derived from `url`/`source`.\n- Column ids are public identifiers and do not always match the model field, so\n the descriptor's `fieldName` comes from an explicit map\n (`publish` → `publish_date`, `updated` → `updatedAt`); the derived `site`\n column advertises no field at all rather than a nonexistent one.\n- Filter values are normalized per column (`type` via `normalizeContentType`,\n everything else via `normalizeContentToken`) through\n `normalizeContentListFilterValue`, and a blank value clears the filter — an\n `equals ''` filter would silently exclude every row.\n- The card presentations render their own page controls (smrt-ui `Pagination`\n dispatching `setPage`) and their own polite refresh status, because DataTable\n — which owns both in compact mode — is not mounted there. A page size arriving\n from a saved view or a surface command would otherwise strand the operator on\n page one, and a refresh over retained rows would be silent.\n- `dataSurface` registers the compact table only. Agent addressability for the\n grid and detailed presentations lands with #2456.\n- Compact mode stays mounted for empty and loading results — DataTable renders\n its own `empty` snippet and loading row — because it owns the mounted surface:\n swapping it for the shared empty panel unregisters the surface, and an agent\n whose own search returned nothing then gets `not_found` on the command that\n would undo it. The shared loading/empty panels are the card presentations'\n only; the `error` branch still replaces the list in every mode, since a load\n failure is host-driven rather than surface-driven.\n\n## ContentList server-backed mode (#2452)\n\n`ContentList` gained three optional, independent opt-ins. Omit all three and the\ncomponent behaves exactly as it did after #2451 — `ContentWorkspaceRoute` passes\n`contents` and nothing else and is unchanged.\n\n| Prop | Type | Effect |\n|---|---|---|\n| `query` | `ContentListQuerySource` | Rows come from `POST /api/v1/contents/query`; `contents` is ignored |\n| `urlState` | `ContentListUrlStateBinding` | Restore from, and publish to, a query string |\n| `savedViews` | `ContentListSavedViewStore` | Save / apply / delete named views |\n\n### The query seam\n\n`query.bind()` is called **once**, during component initialization, and returns\na `ContentListQueryBinding`. `remoteQuery(collection, transport)` from\n`@happyvertical/smrt-svelte/web` satisfies that interface structurally, so its\n`$effect` teardown is registered in `ContentList`'s own scope and disposed with\nit:\n\n```svelte\nconst transport = createContentListQueryTransport({ apiBaseUrl: '/api/v1' });\n<ContentList query={{ bind: () => remoteQuery(collection, transport) }} … />\n```\n\nThe binding type is declared **structurally rather than imported**: pulling\n`@happyvertical/smrt-svelte` (and through it `@happyvertical/smrt-web` and\n`@tanstack/db`) into `@happyvertical/smrt-content/svelte` would put the browser\ndata runtime in a barrel that public content pages import, defeating the\ncode-split boundary that runtime's own AGENTS.md ratifies. smrt-web mirrors\n`smrt-types` for the same reason.\n\n### Three id namespaces\n\nA list value crosses three vocabularies that do not agree, and the bridge is\nexplicit in both directions:\n\n| Namespace | Example | Owner |\n|---|---|---|\n| adapter column id | `updated` | `content-list-controller.ts` |\n| `ContentData` field | `updatedAt` | `mock-smrt-client.ts` |\n| server data-query field id | `updated_at` | the registered `Content` model |\n\n`CONTENT_LIST_QUERY_FIELDS` maps column → server field\n(`publish` → `publish_date`, `updated` → `updated_at`), and\n`ROW_FIELD_TO_CONTENT_KEY` maps result row → `ContentData`\n(`updated_at` → `updatedAt`, `created_at` → `createdAt`).\n`content-list-query.test.ts` asserts the first map against the *real*\n`buildContentQuerySchema()` — field ids, declared types, and declared filter\noperators — so a model rename breaks a test rather than a production query.\n\n`site` has **no** server field: it is derived in the browser from\n`url`/`source`. A filter or sort on it is dropped from the request and reported.\n\n### The local transform is the local-mode path only\n\nIn server mode the returned rows *are* the answer — the server already applied\nsearch, filters, sort, and page — so `selectContentListRows` /\n`paginateContentListRows` must not run over them. Running them again re-filters\nwith different semantics (untrimmed search, case-insensitive comparison, a\n`site` predicate the server never saw) and can hide rows the server returned.\n`totalRows` therefore comes from `result.total`, not from the page length.\n\n**Clamping moves the operator, so it acts only on a count that is exactly\nright.** Two findings in a row were \"the clamp acted on a number that wasn't the\ntotal\", so the rule is stated as a set rather than patched case by case:\n\n| Input | Clamp against it? |\n|---|---|\n| local mode row count | yes — the supplied array IS the whole result set |\n| server total, `exact` | yes |\n| server total, `estimated` | **no** — an approximation can hide a page that really exists |\n| server total, `unavailable` | **no** — the count is unknown; `rows.length` is the page, not the total |\n| no response yet for this query | **no** — a page restored from a link survives until its own count arrives |\n| a settled response for a DIFFERENT query | **no** — the binding holds the previous total while a new request is in flight |\n| a page-size change | n/a — `setPageSize` resets the page itself |\n\n**ContentList owns paging in EVERY presentation, compact included, and\ndeliberately never passes `totalRows` to DataTable.** DataTable runs its own\n`clampPage(totalRows)` effect against the SAME controller, with no authority\nrule and no notion of which query a total belongs to — so for two rounds the\nclamp fixes above were live in the card modes and bypassed in compact, where an\n`estimated` total clamped a real page away and a stale total reset a restored\none.\n\nOne prop cannot serve both jobs: `totalRows` drives that clamp AND DataTable's\npager, so any total authoritative enough to clamp against is also the only total\nthe pager can show. Passing an authoritative-only total silences the clamp but\nleaves compact with no pager on an `estimated` total while the card modes still\nshow one, and then the two modes disagree about which pages exist — a worse bug\nthan the one being fixed. So ContentList keeps one clamp (its own effect, with\nthe authority rule) and one pager (its own `<Pagination>`, driven by\n`pageableRowCount`, which accepts an estimate because SHOWING a page and MOVING\nthe operator are different questions). The same reasoning already made the\nselection column content-owned in compact mode.\n\n**Invariant: the presentations must never disagree about which pages exist or\nwhich rows are reachable.** Anything about which page is requested, which pages\nare offered, or which rows come back belongs in `describePaging` in the test\nsuite, which runs the suite in both `grid` and `compact`. `defaultViewMode`\ndefaults to `grid`, so a plain `renderList` test proves only the arm where\nDataTable is not mounted.\n\n`estimated` is a deliberate choice, not an oversight. Clamping on an estimate\nstrands rows the operator cannot then reach; not clamping can offer a page that\ncomes back empty, which is visible and self-correcting. Hiding reachable rows is\nthe worse failure — the same reasoning as \"truncation only when it narrows\".\nShowing a pager is a different question, so `pageableRowCount` still accepts an\nestimate through `contentListQueryTotalValue`; only\n`contentListQueryExactTotal` feeds the clamp.\n\nSelection normalization also changes shape in server mode: `rows` is only the\ncurrent page, so membership cannot be the durability test (it would clear the\nselection on every page change). Only the adapter's synthetic ids are stripped.\n\n### One page-size ceiling, one page size\n\n`maxPageSize` is resolved ONCE, by `resolveContentListMaxPageSize`, as the\nminimum of every configured limit and the schema's `maxPageLimit` — every\ncandidate narrows, so a host that sets `query.request.maxPageSize` as a server\nrow budget does not lose it to a looser `urlState.options.maxPageSize`. That one\nnumber is passed to the controller seed, the URL sanitizer, the saved-view\nsanitizer and the translator, which is what makes the size the UI pages by and\nthe size the server applies the same number *by construction*.\n\nTwo page sizes are unusable in server mode, and BOTH are coerced against live\nstate (a saved view, a link, and a data-surface `set-page-size` all arrive after\nmount) and reported:\n\n| Live page size | Coerced to | Why |\n|---|---|---|\n| `null` | the seed | the endpoint always applies a limit |\n| `> maxPageSize` | `maxPageSize` | the translator clamps the request, so leaving the controller higher makes `totalPages` compute 1 and hide the page controls |\n\nThe seed itself is clamped to the ceiling too: a `defaultPageSize` above it\nwould otherwise seed a page size the request silently reduces.\n\n`pageSize: null` means \"show everything\", and the query endpoint has no way to\nexpress that. Left alone, a null page size renders one page of `limit` rows with\nno page controls and no way to reach the rest, so in server mode it is\n**coerced and reported**:\n\n- supplying `query` seeds the controller's `pageSize` from\n `query.request.defaultPageSize` (default 50), clamped to the ceiling;\n- that same number is handed to the URL layer as its `defaultPageSize`, so a\n link that omits `size` restores the seed instead of overwriting it with the\n local `null`, and a link this list writes omits `size` while at the default;\n- a null arriving later — `?size=all`, a saved view, a data-surface\n `set-page-size` — is coerced by an effect against live state, and the\n translator reports the same coercion for a direct caller.\n\nBoth report a `pageSize` / `unpaginated-unsupported` drop in the notice. Local\nmode keeps the historical unpaginated list unchanged; the coercion is\nserver-mode-only.\n\n### Search, and what the protocol cannot express\n\nThe protocol has `filter` only — no search primitive. `state.search` becomes an\n`any` of `like` predicates over the adapter's searchable columns (`title`,\n`description`, `author`), with the wildcards added client-side and the\noperator's own `%`/`_` escaped with a backslash.\n\n**Known dialect gap:** PostgreSQL and DuckDB honour a backslash as the default\n`LIKE` escape; SQLite has none and the collection query builder emits no\n`ESCAPE` clause, so on SQLite an escaped `%` matches the two literal characters.\nThat fails closed (empty result) rather than open (every row); a portable fix\nneeds an `ESCAPE` clause at the collection/SQL boundary.\n\n**Second known dialect gap — where an absent value sorts.** A sort term is\n`<field> <ASC|DESC>` and nothing more: `buildOrderBySql` splits the term on\nwhitespace and discards everything after the direction, and `DataQuerySort`\ncarries only a field and a direction, so `NULLS FIRST`/`NULLS LAST` cannot be\nexpressed from this package at all. The placement is therefore whatever the\ndialect defaults to — PostgreSQL and DuckDB put NULLs LAST ascending, SQLite\nputs them FIRST — which changes **which rows land on page one**.\n\nThe local comparator is aligned to ONE documented choice: **absent sorts last\nascending and first descending**, matching the SQL standard, the PostgreSQL and\nDuckDB defaults, and therefore the production dialects. A SQLite-backed\ndeployment will disagree with local mode on where absent values fall. That\ndivergence is dialect-level and not fixable here; fixing it needs a nulls\nplacement in the sort term at the `@happyvertical/sql` / `SmrtCollection`\nboundary, and a slot for it in `DataQuerySort`. A test pins SQLite's observed\nordering so this note cannot rot.\n\n`isNull` / `isNotNull` map to a **null-valued `eq` / `ne`**. That is null-aware\nend to end and is not a comparison against NULL: the protocol scalar type admits\n`null`, the request normalizer rejects a null value only for\n`gt`/`gte`/`lt`/`lte`/`like`, and `buildWhere` lowers `{ field: null }` to\n`IS NULL` and `{ 'field !=': null }` to `IS NOT NULL`.\n\nDropped rather than sent, because sending them would fail the *whole* request:\n\n- `notContains` — the executor refuses to negate a `like`. The only operator\n with no server expression.\n- `contains` / `startsWith` / `endsWith` on a datetime column — `like` is\n string-only in the request normalizer.\n- an unparseable datetime value — the normalizer requires an RFC 3339 instant.\n\nThe translator also enforces the normalizer's **input caps**, because exceeding\none 400s the entire list rather than degrading it. Each is capped and reported:\nat most 100 `in`/`notIn` values, at most 50 filter nodes (counting every\n`all`/`any` container, so search costs 4 and the outer `all` 1), at most 50\nprojection fields — each of which must also be a declared projectable field id\nof 1-256 characters, or it is dropped rather than 400ing the list — a request id\nof at most 128 characters, at most 4096 characters per filter value, and 100 000\nbytes for the whole serialized request (100 values of 4096 characters is inside\nevery per-value cap and still five times that limit, so the newest filter\nbranches are shed until it fits).\n\n**Measure what the server measures.** `boundRequestSize` weighs the NORMALIZED\nrequest, not the body as sent, and the normalizer injects `schema.defaultSort`\nwhen `sort` is absent — 84 bytes the client had not counted, so a request of\n99 917-100 000 bytes passed the client check and 400ed at the server. The\ntranslator therefore always emits `sort`, falling back to\n`CONTENT_LIST_QUERY_DEFAULT_SORT` (the schema default) rather than omitting the\nkey. The invariant to preserve: the client's byte count is never smaller than\ncore's.\n\n**Validate the input, never the value derived from it.** `new Date()` rolls an\nimpossible calendar date forward — `2026-02-31` becomes `2026-03-03` — so\nchecking the *produced* instant against the RFC 3339 pattern reports nothing and\nthe query silently targets a day the link never named. The translator re-derives\nthe year/month/day and compares, exactly as `normalizedInstant` does server-side,\nand drops the filter with a report. A date-only value (`2026-02-01`) is still\naccepted and widened to midnight UTC; only impossible days are refused.\n\n**Truncation is only permissible when it NARROWS.** Every cap above degrades a\nrequest rather than failing it, but degrading is only honest when the answer\nstays a subset of the question:\n\nDirection is a property of the OPERATOR, not of the site — the same cap narrows\nan `in` and widens a `notIn`, and the same truncation widens a `contains` and\ndoes neither to an `eq`. So the classification is made per operator, and where a\nchange is neither narrowing nor widening the filter is not sent at all:\n\n| Bound hit | Effect on the result | What happens |\n|---|---|---|\n| `in` list past 100 values | narrows — a disjunct is removed | capped, reported as `out-of-range` |\n| `notIn` list past 100 values, or carrying an entry that cannot be sent faithfully | **widens** — an exclusion is removed | the whole filter is left out, reported as `filter-widened` |\n| filter-node, OR-branch, or request-byte budget | **widens** — a conjunct is shed | reported as `filter-widened` |\n| a shortened `contains` or `startsWith` pattern, or a shortened search term | **widens** — a shorter pattern matches a superset | sent, reported as `filter-widened` |\n| a shortened `endsWith` pattern | **widens**, but only because the TRAILING characters are the ones kept | sent, reported as `filter-widened` |\n| a shortened scalar comparand (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`) | **neither** — `gt`/`gte` would widen, `lt`/`lte` would NARROW and hide rows, `eq`/`ne` would name a third row entirely | the filter is left out, reported as `filter-widened` |\n| page size, offset, projection count | does not add rows | reported as `out-of-range` |\n\n**Every path lands in one of FOUR states, and every one of them reports.** The\npartition is four-way, not three — an `in` list cut to its first hundred values\nis a genuine SUBSET, which is allowed and is not a widening:\n\n| State | Example | Reported as |\n|---|---|---|\n| applied exactly | anything within every bound | nothing to report |\n| applied as a true SUPERSET | a shortened `like` pattern | `filter-widened` |\n| applied as a true SUBSET | an `in` list past 100 values, or one carrying an entry that could not be used | `out-of-range` / `unsupported-value` |\n| not applied at all | a `notIn` that cannot be carried, a shed conjunct, a comparand too long to send | `filter-widened` |\n\nThe fourth column is the point: a state without a report is a fifth state, and\nit is the one that hides. An unusable entry in an `in` list used to be exactly\nthat — dropped silently, so the toolbar stated a three-value filter while the\nquery asked for one and the rows the operator listed vanished without a word.\nAny new bound must land in one of the four rows above *and* report.\n\n**Never emit a value the caller did not name.** The list path already refused to\nsend a shortened entry; the scalar path used to send one and merely relabel the\nreport, which was affirmatively wrong for half the operators — telling an\noperator the list \"may include rows it would have excluded\" while `lt` was\nquietly hiding rows. A comparand that cannot be sent whole is now not sent at\nall, which is uniformly a widening and is honestly reported as one.\n\n**Which end of a pattern survives decides whether it widens.**\n`boundLikeSource` keeps the LEADING code points for `contains` (`%abc%`) and\n`startsWith` (`abc%`) — anything containing or starting with `abcdef` also\ncontains or starts with `abc`. A suffix pattern (`%abc`) is the mirror image, so\nit keeps the TRAILING ones: truncating `%…END` from the front would name a\ndifferent ending, dropping the row the operator asked for while picking up\nunrelated ones. That is neither a superset nor a subset, and no honest label\nexists for it.\n\nA widening `notIn` is never PARTIALLY applied. The cap keeps arrival order, so a\nliteral `null` past the hundredth entry is the entry shed — and the executor\nthen takes its \"no null listed\" arm and unions `IS NULL` back in, returning\nevery absent-valued row the caller listed `null` to exclude. Dropping the filter\nwhole is wider still, but it is *honest*: the operator is told the filter is not\nbeing applied instead of being told it was \"clamped\", which would imply it still\nworks.\n\nThat distinction is the whole point of the `filter-widened` reason. An operator\nwho is told a list was clamped reasonably assumes the answer is a subset of what\nthey asked for. Telling them that when the truth is \"this now returns rows you\nexcluded\" is worse than telling them nothing.\n\n**Measure in the server's unit.** `dataQueryScalar` tests `value.length`, which\ncounts UTF-16 code units, so an astral character costs TWO. The bounders iterate\nby code point — never splitting a surrogate pair — but charge `character.length`\nper code point plus one for an escape. Charging one per code point made a search\nof 4093 ASCII characters plus one emoji measure 4094 client-side and 4097\nserver-side: a hard 400 and a whole-list error panel.\n\nAnd mirror the normalizer's **validity rules**, not only its numeric caps. A\ndatetime input is held to three separate checks before it is parsed, because\n`Date` will happily accept and silently reinterpret what fails each one:\n\n| Check | Refuses | Why |\n|---|---|---|\n| calendar round-trip | `2026-02-31` | `Date` rolls it to March 3, so the query targets a day the link never named |\n| an offset on a time-bearing value | `2026-02-01T00:00` | no offset means `Date` reads it as LOCAL time, so the identical link submits a different instant per viewer — nine hours apart between London and Tokyo |\n| the `T` separator | `2026-02-01 10:00:00Z` | a space leaves the ISO grammar, so `Date` falls through to its implementation-defined legacy parser: the same hazard moved from timezone to engine |\n| four-digit year | `+275760-09-13` | serializes to an expanded-year form the server refuses outright |\n\nThe rule is \"a time must carry an offset\", not \"a time must state its seconds\":\n`2026-02-01T12:30Z` and `2026-02-01T12:30+09:00` each name one instant for every\nreader and are accepted. A bare calendar day (`2026-02-01`) is accepted too —\n`Date` reads it as UTC midnight everywhere. Lower-case `t`/`z` is fine: RFC 3339\n§5.6 permits it, and the value is canonicalized through `toISOString()` before\nit is sent, so the server only ever sees the strict upper-case form it insists\non.\n\n**Canonicalize before parsing, too.** RFC 3339 §5.6 permits a lower-case\n`t`/`z`, but ECMA-262's Date Time String Format specifies the upper-case forms,\nso `new Date('2026-02-01t12:30z')` runs on engine-specific heuristics rather\nthan the spec — the same objection that rules out the space separator. V8 reads\nit as UTC, which is luck rather than a guarantee, so the value is upper-cased\nbefore it reaches `Date`. `toUpperCase()` is locale-independent and this grammar\nhas no other letters, so the canonical form means exactly what the input did.\n\n**Validate and parse the SAME string.** Checking `text.trim()` and then parsing\n`text` let `\"2026-02-01 \"` through, and V8's ISO parser rejects the whitespace\nand falls back to the legacy parser — which reads a bare date as LOCAL midnight,\nreintroducing the per-viewer divergence with no drop reported at all. The\nvalidator returns the exact string to parse rather than a verdict about a\ndifferent one.\n\n### Filter case: tokens are folded, free text is not\n\n`normalizeContentListFilterValue` folds case for the **token** columns only —\n`type`, `status`, `state`, whose domain is a fixed lowercase vocabulary the\nmodel writes. Every other column holds text a person typed, and its value\nbecomes a server-side `eq` or `like` compared against the STORED text: folding\n`NASA` to `%nasa%` would miss `NASA Update` on PostgreSQL or DuckDB. The helper\ndates from #2451, when every comparison was local and folding everything was\nharmless; server mode invalidated that assumption.\n\nPreserving case is safe locally because the local evaluator compares through\n`textValue()`, which lower-cases BOTH sides at compare time — a case-preserving\nstored value still matches case-insensitively there. The `type`-lock predicate\n(`isContentListFilterExactly`) goes through the same helper, so the lock still\nsettles rather than re-dispatching.\n\n**Free-text server matching is therefore dialect-dependent, and the protocol\ncannot make it uniform.** There is no `ilike` in the operator vocabulary, so:\n\n| Backend | `like` / `eq` on free text |\n|---|---|\n| PostgreSQL, DuckDB | case-SENSITIVE |\n| SQLite | case-insensitive for ASCII |\n\nLocal mode is case-insensitive everywhere. Do not describe free-text filtering\nas uniform; a portable fix needs a case-insensitive operator at the collection\nboundary.\n\n### NULL semantics are aligned\n\nThe same shared link must return the same rows whether the host passed a `query`\nor not, so every null-sensitive operator now agrees across the two modes. Two\ndifferent alignments were needed, in opposite directions, because the *meaning*\ndiffers per operator:\n\n| Operator | Server lowering | Alignment |\n|---|---|---|\n| `eq`, `in`, `like` (`contains`/`startsWith`/`endsWith`) | plain predicate | already agreed — both exclude an absent row |\n| `in` with a `null` listed | `IS NULL OR IN (…)` | already aligned |\n| `ne` (value ≠ null) | `IS NULL OR <> v` | server gained the union: \"not v\" includes rows with no value, as the local evaluator has always said |\n| `notIn` (no `null` listed) | `IS NULL OR (AND of <> v)` | same |\n| `notIn` WITH a `null` listed | `(AND of <> v) AND IS NOT NULL` | **no union**: a listed `null` says absent rows are excluded too |\n| `eq null` / `ne null`, and `in`/`notIn` carrying a listed `null` | `IS NULL` / `IS NOT NULL` | the translator forwards a literal `null` rather than coercing it away, and the local evaluator resolves it through `isAbsentContentValue` |\n| `ne null` (`isNotNull`) | `IS NOT NULL` | untouched — a union would match every row |\n| `gt`, `gte`, `lt`, `lte` — asked for directly | plain predicate | the LOCAL evaluator gained null-awareness |\n| `gt`, `gte`, `lt`, `lte` — reached through `not` | `IS NULL OR <predicate>` | server gained the union, so the negation is a true complement |\n| `isNull` / `isNotNull` | `IS NULL` / `IS NOT NULL` | the LOCAL evaluator gained null-awareness |\n\n### A value that reads as absent is still a value\n\n`null` in a filter list means \"and rows with no value at all\". It was silently\ndiscarded in THREE separate layers before this was tracked to one shape: each\nlayer tested the value for PRESENCE rather than for VALIDITY, and `null` reads\nas absent to any check written with truthiness. `normalizedFilterValue` is the\ncorrection — it returns three outcomes, not two: a string, the value `null`, or\n`undefined` for genuinely unusable, which is the only case a caller may drop.\n\nEvery layer a filter value crosses, and what each does with a `null` entry:\n\n| Layer | Behaviour |\n|---|---|\n| data-surface `set-filters` → controller | passes through unchanged |\n| `sanitizeContentListViewState` | **preserves** it (was: silently dropped) |\n| controller state (`setFilters` / `replaceState`) | passes through unchanged |\n| URL serialization | writes the `\\0` token |\n| URL parse | reads the token back as `null` |\n| saved-view write (`hydrateDataTableSnapshot`) | JSON, so `null` is native |\n| saved-view read | native |\n| `restoreContentListSavedView` | preserves it (shares the sanitizer above) |\n| translator (`coerceValue`) | **preserves** it (fixed earlier) |\n| request → `normalizeFilter` | accepts a null scalar for `eq`/`ne` |\n| executor (`conditionToDnf`) | lowers a listed null to `IS NULL` / `IS NOT NULL` |\n| local evaluator | `matchesAbsentContentValue` answers the same predicate |\n\n**The URL token is collision-free by construction, not by being unlikely.**\n`escapeListEntry` doubles every backslash a real value contains, so the only\ntwo-character sequences a real entry can begin with are `\\\\` and `\\,`. A LONE\nbackslash followed by `0` is therefore unreachable from any string — including\nthe literal two characters `\\0`, which serialize as `\\\\0` and read back as\nthemselves. It survives percent-encoding as `%5C0`. Writing `null` as the word\nwould have been ambiguous with an author actually called \"null\".\n\nA **scalar** null comparand needs no token at all: `equals null` and `isNull`\nare the same predicate — both lower to `eq null`, and the local evaluator\nanswers them identically — so it is written as the valueless operator, which\nalready has a query-string form.\n\n**The bug was never really about `null`.** The same shape catches any value that\na truthiness check reads as absent, so `0`, `false`, and the empty string are\ntested alongside it. An empty entry WITHIN a list (`?author.in=a,`) is the empty\nstring, which is a real value for a column that stores one; an entirely empty\nparameter (`?author.in=`) is a list with no values and is still refused and\nreported. A blank SCALAR still clears the filter, matching\n`applyContentListFilter`.\n\n**A literal `null` has to survive the TRANSLATOR too.** `coerceValue` reports a\nnull as unusable, which is right for a value that arrived as text and wrong for\none a caller wrote deliberately: dropping it sent `notIn ['Ada']` for\n`notIn ['Ada', null]` and returned exactly the rows the caller excluded — the\nexecutor's correct lowering undone one layer up. A literal null is now carried\nthrough `in`/`notIn` lists and accepted as an `equals`/`notEquals` comparand,\nand the local evaluator resolves it to absence, so a data-surface `set-filters`\nmeans the same thing in both modes. Such a value cannot arrive from a link or a\nsaved view — the sanitizer refuses a non-text filter value — so the reachable\npath is an agent command.\n\n**A listed `null` inverts the rule, and getting that wrong is a data leak of the\nworst kind — a filter returning exactly the rows it was asked to exclude.**\n`notIn ['Ada', null]` means \"not Ada, and not blank\", so it must NOT gain the\n`IS NULL` union. It is reachable from the wire (`normalizeFilter` accepts a null\nlist entry) and through `not(in ['Ada', null])`. Without the distinction,\n`in [x, null]` and its own negation both match the absent row: a predicate\noverlapping its negation. The executor tests assert every list shape\n(no null / with null / null-only / inverted) partitions the rows with no overlap\nand no gap.\n\n**The ordered comparisons were aligned on the LOCAL side, not the server's.**\n`ContentListRow` flattens every field to display text, so an absent value read\nas `''` — which sorts below everything, made `publish_date lt X` match every\nnever-published row, and made `isNull` match nothing at all. The original\n`ContentData` still distinguishes absent from empty, so\n`isAbsentContentValue()` consults it and the null-sensitive operators exclude an\nabsent value exactly as SQL's three-valued logic does.\n\n**Absence is decided before any text comparison.** The flattened row reads an\nabsent value as empty text, so comparing it as text answers a question about\n`''` rather than about absence — and the two differ for every operator once a\nBLANK comparand is involved (`author equals ''` matched an absent row locally\nand no row in SQL; `author notEquals ''` did the reverse).\n`matchesAbsentContentValue` decides from the operator alone, following the SQL\nthe executor emits: `equals` matches only a null comparand, `notEquals` matches\nunless the comparand is null, `in`/`notIn` turn on whether the list carries a\n`null`, and `like` and every ordered comparison match nothing.\n\n**A display fallback is presentation, never data.** Two columns substitute a\nlabel when the content carries no value — `type` reads `content` and `title`\nreads `Untitled content`. Comparing the label made `?type=content` return every\nuntyped row on a client-array list and none on a server-backed one, and made a\nsearch for `untitled` find rows whose title is simply missing.\n`comparisonValue()` reads what is stored for those two columns instead: `null`\nwhen the content has no value, empty text when it is genuinely blank, and the\nflattened text otherwise, which is already faithful. Both local filtering and\nlocal search go through it. The label keeps rendering; `isNull` is how an\noperator asks for the rows behind it. This is the direction the\ndata means: \"no publish date\" is not \"published before X\". It also aligns the\nblank-comparand case (`?author.lt=`, `?author.gte=`), where the flattened `''`\nused to compare equal and the two modes disagreed, and it leaves a column that\ngenuinely stores `''` (`title`, `name`) comparing as present in both modes.\n\n**A negation must be a complement.** `not` is part of the endpoint's accepted\ngrammar even though the translator never emits one, and a direct HTTP consumer\nsending `not(gt 'B')` used to get a bare `<= 'B'` — leaving a row with no value\nmatching NEITHER the predicate nor its negation. `conditionToDnf` therefore\ntakes a `negated` flag: an ordered comparison reached through an odd number of\n`not`s unions `IS NULL`, while the same operator asked for directly does not.\n`eq` reached by negating `ne` gets no union, because the complement of\n\"IS NULL OR <> v\" is \"= v\", which excludes NULL by construction. The executor\ntests assert that **every** operator in the grammar partitions the rows against\nits own negation — no overlap, no gap — including through `all`/`any`\ncontainers and a double negation. `like` is the sole exclusion: negating one is\nrefused outright.\n\nThe `ne`/`notIn` union costs a second DNF branch each, a negated ordered\ncomparison costs a second too, and an `all` multiplies,\nso the translator also mirrors `MAX_CONTENT_QUERY_OR_BRANCHES` (128) and drops\nfilters before the executor would refuse the whole request. The mirror is exact\nrather than conservative — including the listed-`null` case, which costs one\nbranch rather than two — and it handles De Morgan under `not`, so a future\nnegating emitter cannot silently under-count.\n\n### Scope is application-supplied and server-derived\n\nA `DataQueryRequest` carries no authority. Tenancy is applied inside\n`executeContentQuery` (fail-closed to global rows). Site, organization, or\nworkspace narrowing is the host's: the framework models neither site nor\norganization, so a host calls `executeContentQuery(collection, body, { scope })`\nfrom its own route with conditions derived from the authenticated context —\nnever from the request body.\n\n**`undefined` and `[]` mean OPPOSITE things, and getting that backwards is an\nauthorization fail-open.** A host builds a scope from an allowed-resource list —\nthe sites, workspaces, or organizations this principal may see:\n\n```ts\nconst scope = permittedSiteIds.map((siteId) => ({ siteId }));\n```\n\nThat list is empty exactly when the principal may see nothing. An empty array\ntherefore means \"the set of permitted conditions is empty\" and matches no rows;\nomit the option entirely to mean \"this deployment applies no application\nscope\". Treating the empty array as absent turned *access to zero sites* into\n*access to every row in the tenant* — the precise failure the two-layer scope\ndesign exists to prevent, in the seam chosen as the application's scoping\nmechanism.\n\nAn empty scope lowers to a predicate that matches nothing (`id IS NULL`, false\nfor every row on every dialect) rather than throwing: \"you may see nothing\" is a\nlegitimate authorization state, and answering it with a 500 would be wrong. It\nis ANDed into every branch like any other scope condition, so no caller filter\nshape can escape it, and it survives alongside tenancy rather than being\nreplaced by it. A malformed condition — an empty OBJECT, which states no\nconstraint at all — is still a programming error and still throws.\n\n### Mirrored constants are self-enforcing\n\nEvery number this package copies from the schema or from\n`@happyvertical/smrt-core` is bound to its source by a test, because a\nhand-copied limit that drifts is the defect this issue kept producing: lowering\nthe server's page limit while the client still seeds and pages by the old one\nstrands rows with the whole suite green.\n\nWhere core exports the constant the assertion is direct; where it does not, the\nassertion pins core's observable BEHAVIOUR — the largest value it accepts and\nthe smallest it refuses — rather than being skipped. Add a cross-assertion\nalongside any new mirrored number. The field map, projectable fields, operator\nvocabulary and search fields are asserted against the real\n`buildContentQuerySchema()` for the same reason.\n\n### Documented limits\n\n- Offset paging only; the schema declares `supports.cursorPagination: false`.\n- `body` is not queryable — it is a document, and the envelope caps a scalar at\n 4096 characters. Read it through `GET /api/v1/contents/{id}`.\n- `metadata` path filtering is unavailable: JSON columns get no filter operators.\n- There is no ETag or version slot in the canonical envelope; `queryFingerprint`\n and `freshness.asOf` serve that role.\n- `maxResultBytes` on a host-supplied schema must be at least\n `CONTENT_QUERY_MIN_RESULT_BYTES`. The row budget is that number minus the\n envelope reserve, so a smaller one leaves nothing for rows and every query\n answers with an empty page flagged `truncated` — indistinguishable from \"no\n content matched\". It is refused with a plain `Error` naming the minimum\n rather than a `DataQueryValidationError`, because `schema` is trusted adapter\n configuration: a typed validation error would surface as a 400 and blame the\n caller for the host's mistake.\n\n **Where this actually runs:** `executeContentQuery` checks it on every\n request, so a misconfigured schema can never serve a query — but through the\n generated route an untyped error becomes an opaque 500, and the message\n naming the minimum reaches only the server log. Call\n `assertContentQuerySchema(schema)` once where the schema is configured to\n fail next to the mistake instead. The check is applied uniformly across query\n modes, `count` included: a schema too small to serve its own row mode is\n misconfigured whatever this request asked for, and letting `count` through\n would hide that until the first rows query. A zero or negative budget is\n refused too — it previously meant \"use the default\" for rows and \"zero\n budget\" for facets, which is two answers to one question.\n- **A row is never dropped to fit the byte budget — only shortened.** Offset\n paging advances by the requested LIMIT, not by the number of rows actually\n returned, so a dropped row is skipped on its own page and on every page after\n it: silent, permanent data loss. `DataQueryResult`'s offset page is\n `{ kind, offset, limit, hasMore }` with no next-offset slot, and the\n normalizer refuses a `nextCursor` on an offset page, so a continuation offset\n cannot express \"resume at 170\" either. Instead the page's budget is allocated\n **floor-first, then max-min fair**: every row is seated at its irreducible\n minimum before any surplus is shared, and the surplus goes to the smallest\n APPETITE (cost minus floor) first. Ordering by current cost instead would let\n a row that needs nothing take an even share while a large, mostly-irreducible\n row is starved below its own floor — declaring a feasible page impossible.\n Seating the floors first makes the guarantee unconditional: if the floors fit,\n the page is served. That guarantee lives in `allocateRowBytes`, which is\n exported and unit-tested, because a row's floor is dominated by the\n projection's key bytes — identical across the rows of one page — so the\n disparity that breaks cost-first ordering is not reachable through\n `executeContentQuery` itself.\n\n Within a row, the fields are levelled to a shared byte cap computed in one\n sorted walk (water-filling), not searched for by halving and re-measuring —\n the old search re-serialized the row and every field on every step, which\n turned an ordinary wide page at the default budget into ~9x the cost of the\n same unbounded read. HOW a field gives way depends on its declared type:\n\n | declared type | how it gives way | why |\n |---|---|---|\n | `string` | levelled to the shared cap | any prefix of free text is still free text |\n | `datetime` | `null`, or not at all | **no prefix of an RFC 3339 instant is valid** — a shortened one makes the adapter emit a value that breaks the type it declared, and the normalizer then rejects the whole page with `must be an RFC 3339 instant`, blaming the caller |\n | `json` | `null`, or not at all | a document has no incremental shortening |\n | number, boolean, `null` | never | already minimal |\n | the identity field | never | it is the row's address; emptying it fails result normalization |\n | ANY field whose reduced form is no smaller | never | `{}` and `[]` are TWO bytes, so nulling them costs four |\n\n That last row is a floor rule as much as a shrink rule. **A row's floor is the\n size it can be reduced TO, so it can never exceed the size the row already\n is.** Taking the reduced size as the floor unconditionally overstates it by\n two bytes per empty-document field per row, which refuses pages that would\n have fitted — and the refusal repeats for that page forever. The invariant\n `floor <= cost` is now stated by `measureRow` and held defensively by\n `allocateRowBytes`, rather than being an accident of a caller's short-circuit.\n\n The rule generalizes: **a value may be shortened only when its type accepts\n arbitrary prefixes.** Any format-constrained type added later is all-or-nothing\n by default. An all-or-nothing field is dropped only when the row cannot fit\n with it KEPT — losing a whole value to save a few bytes is a last resort, so a\n 200 KB `metadata` blob goes immediately while a 26-byte `updated_at` survives\n whenever the strings can absorb the difference.\n\n Byte accounting is exact at the boundary: each row charges the array separator\n that follows it, so an N-row page is refunded the one separator it does not\n need. Without that refund a page whose true size exactly equals the budget is\n needlessly shortened, or — if it is irreducible — refused outright.\n\n Shortening is already a reported state (`truncated` plus its warning) and it\n leaves offset paging exact. Only a page whose FLOORS exceed the budget fails,\n and it fails loudly rather than answering with a page that quietly omits rows.\n- A restored page size is clamped to `maxPageSize` (default\n `CONTENT_LIST_MAX_PAGE_SIZE`, 200, matching the schema's `maxPageLimit`), and\n the clamp is reported. The ceiling is resolved once and applied to **both**\n restore paths — a saved view is not a way around a limit a host set for links.\n- The server bounds its own answer: it shortens over-long values to fit\n `maxResultBytes`, flagging `truncated` with a warning. It does **not** drop\n rows — see the bullet above — precisely because the next page is computed from\n `page * limit`, so a dropped row would be skipped on the following page too.\n `ContentList` reads those flags — from the binding when it exposes them, and\n otherwise off the envelope its own `execute` resolved — and renders them in\n the same notice as the drops.\n- A `json` field (`metadata`, `tags`) is validated as a document, not a scalar,\n and `canonicalJson` rejects the WHOLE result when a nested value breaks any of\n its rules. `executeContentQuery` therefore bounds a JSON document itself —\n 65536-character strings, 1000-item containers, depth 16, finite numbers, no\n cycles, plain values only — flagging `truncated` rather than failing the page.\n It also drops the keys `plainObject` forbids (`__proto__`, `constructor`,\n `prototype`) and builds with a null prototype, because that is the *reachable*\n rule: `metadata` is the documented extension point, it is writable through the\n REST API and through `mirror()` ingestion, and `JSON.parse` of the stored\n column creates an own `__proto__` property — so one row could otherwise make\n every query projecting metadata return 400 for the whole page, permanently.\n- Facet values are held to the SAME shared byte budget as rows. Two text facets\n of 200 distinct 4096-character values are inside every per-value cap and still\n several times the 1 MB result limit; values are dropped and the facet and the\n result are flagged rather than the response being refused.\n- A capped offset moves the caller's page marker AND says so. `?page=9000&size=200`\n caps the request offset at 1 000 000; the translation returns the\n `effectivePage` the request actually reads, and the redirect is reported in\n the notice (\"page 9000 cannot be loaded — the list stops at page 5001\"). That\n drop is held apart from the rest, because the corrective dispatch re-enters\n the query effect in the same flush and the second translation caps nothing —\n storing it with the others would erase it before it was ever rendered. It\n stands until the operator moves off the page they landed on.\n- The pagers never advertise a page the endpoint cannot fetch. `totalPages` is\n capped at `floor(MAX_OFFSET / pageSize) + 1`. `clampPage` deliberately keeps\n using the TRUE total, so a crafted `?page=` still reaches the query effect and\n gets the notice instead of being silently clamped first. There is only one\n pager to cap, because ContentList renders `<Pagination>` itself in every view\n mode and hands DataTable no total to derive a second page count from.\n- A retry replaces the rendered rows, so it also replaces the completeness\n flags. `retry()`'s envelope is read through the same path as `execute()`'s;\n discarding it left a \"rows are missing\" notice standing over a complete page.\n\n### URL state and saved views\n\n`urlState` is router-agnostic on purpose: `params` is read **once** at\ninitialization, and every later change is handed back through `onChange(params,\nstate)` with foreign parameters preserved, so a SvelteKit host calls\n`replaceState`, a hash router rewrites the fragment, and a test passes a plain\n`URLSearchParams`. Restoration goes through `applyContentListViewState`, which\nmerges over current state rather than dispatching `setSearch`/`setFilters` —\nthose would reset the restored page.\n\n**INVARIANT: no exported path may apply unvalidated state to a controller.**\n`applyContentListViewState` sanitizes its patch, because it is the one\napplication point the package publishes and it is routinely composed with\nuntrusted values. That is what makes\n`applyContentListViewState(controller, store.snapshot.state)` safe even though\nthe store's read path deliberately returns a raw\n{@link RawContentListViewSnapshot} — keeping the raw payload is what lets\n`restoreContentListSavedView` still report a stale view's drops. Sanitization is\nidempotent, so ContentList's own already-validated patches are unaffected. Only\nthe patch is sanitized, never the merged result: the sanitizer never emits\nselection, so sanitizing the merge would clear the operator's selection.\n\nTwo URL-serialization rules the round trip depends on:\n\n- an `in`/`notIn` entry containing the list separator is escaped with a\n backslash on write and unescaped on read, so `author in [\"Smith, John\"]`\n restores as one value rather than two — a silently *different* query;\n- a parameter is owned (and therefore removable while rewriting) by its **base\n name** only. A host's `facet.contains=` carries a known operator suffix but\n names no ContentList column, so it survives. That is narrower than the\n recognizer in `readContentListViewStateFromSearchParams`, which still reports\n an operator-suffixed unknown column so a crafted `evil.contains=` stays\n visible: reporting a refusal and deleting a parameter are different acts.\n\nThe `type` prop lock still wins after a restore: the lock effect enforces\nagainst live state, so a restored `?type=document` is replaced by the locked\nvalue. The lock is folded into the restored patch rather than re-applied\nafterwards, because the restore replaces the whole filter set and the lock's\n`setFilters` resets paging — a locked list opening `?page=3` would otherwise\nland on page 1, silently.\n\n### A restored value the toolbar cannot show\n\nThe two toolbar selects publish a display vocabulary\n(`CONTENT_LIST_TYPE_OPTIONS`, `CONTENT_LIST_STATUS_OPTIONS`), but the sanitizer\naccepts any non-blank token, so a link can restore a filter the select has no\noption for — `?status=review` (a real `Content.status`), or a typo like\n`?type=artcile`. The select would show nothing while a live predicate emptied\nthe list.\n\nBoth selects handle it identically, and do two things rather than one:\n\n1. the value is rendered as an extra option, so the toolbar tells the truth\n about what is constraining the list and the operator can clear it;\n2. it is reported in the notice, so an empty result always has an explanation.\n\nA select can only offer a single `equals` value, though, and a link can restore\nmuch more than that. **INVARIANT: the select's displayed state either matches\nthe live predicate exactly, or the operator is told it does not.** Three states,\nall reachable from a shared link:\n\n| Live filter | Select shows | Reported |\n|---|---|---|\n| `equals` with a listed value | the value | no |\n| `equals` with an unlisted value (`?status=embargoed`, a typo) | the value, as an extra option | yes |\n| anything else — a list value (`?status.in=draft,review`), a valueless operator (`?status.isNull=1`), an inverted one (`?status.notEquals=draft`), or two filters on one column | a DISABLED summary of the real predicate | yes |\n\nThe disabled summary carries a U+001F-prefixed sentinel value rather than a\nU+0000 one: the HTML tokenizer rewrites a NUL inside an attribute value to\nU+FFFD, so a server-rendered option would hydrate with a value the select was\nnever given and read as no selection — the exact state the summary exists to\nprevent. A client-only mount bypasses attribute parsing, so only a parse\nround-trip test catches it.\n\nThe third row is the one that matters: a value-only read reports nothing for a\nlist value and reports `draft` for `notEquals draft` — the exact inverse of the\nquery. `readContentListSelectFilter` is operator-aware and is the seam that\nkeeps the control from misstating the query; `readContentListFilter` stays\nvalue-only and must not drive a control. Choosing any real option replaces every\nfilter on that column, so the operator is never stuck.\n\n`review` is now offered outright. `deleted` is deliberately not: that is the\ntrash lifecycle (#2454), and offering it here would imply a restore/purge\naffordance this list does not have. `Content.type` is freeform, so its option\nlist is a display vocabulary rather than the model's domain.\n\nEverything a restore or a translation refused is reported in one dismissible\nnotice rather than thrown — a stale link or an out-of-date saved view must still\nopen the list, minus the parts that are no longer meaningful.\n"
10101
+ "content": "<!-- Module doc for packages/content/AGENTS.md. Linked from the Modules table there. -->\n\n# ContentList: shared adapter, server query, URL state, saved views\n\n`ContentList` reads every row, column, filter, and action through one shared\nadapter (`src/svelte/content-list-controller.ts`) over a `DataTableController`,\nand can source those rows either from a client array or from the bounded\ncontent query endpoint. This doc covers both modes end to end.\n\n## Realtime, freshness, and background workflows (#2455)\n\nA server-backed list consumes the complete reactive seam exposed by\n`remoteQuery(...)`: initial loading, stale-while-refreshing rows, error, retry,\nlast-updated time, and an optional query-shaped live subscription. ContentList\nsubscribes after its first request. The remote-query controller carries that\nlive intent across query changes, so search/filter/page changes rebind to the\nnew exact query rather than subscribing to the whole collection. Browser\n`offline` keeps usable rows on screen and announces their freshness; `online`\ncalls the live handle's `reconnect()` (which refreshes the exact request before\nresubscribing), or falls back to `refresh()` when the transport has no live\nhandle. A refresh failure over existing rows is an inline error and never\nreplaces them with an empty/error-only screen.\n\nLong-running actions share a framework-free\n`createContentListJobController()`. Give that controller to action controls and\npass it to ContentList as `jobs`. Its `submit({ actionId, submissionKey,\ntarget }, start)` method coalesces duplicate active submission keys before\ncalling `start`, publishes the accepted job id/progress, and leaves a rejected\nsubmission failed until an explicit retry. Server progress enters through\n`update(job)`; terminal state cannot be reverted by an out-of-order progress\nevent. Row-targeted pending work disables that row's selection/edit/delete\ncontrols.\n\nJob targets are deliberately precise:\n\n- `{ kind: 'rows', rowIds }` refreshes only when at least one affected row is\n visible;\n- `{ kind: 'query', queryKey }` refreshes only when the key equals\n `contentListQueryRequestKey(activeRequest)`.\n\nOnly a transition to `succeeded` triggers that refresh. Failed jobs remain\nvisible with their error and retry affordance, so error recovery cannot report\nsuccess or refresh as though the action applied. The tracker is exported from\n`@happyvertical/smrt-content/svelte` for the bulk-action slice to reuse rather\nthan creating a second pending-state machine.\n\n## ContentList migration (#2451)\n\n`ContentList` no longer holds bespoke local state. `src/svelte/content-list-controller.ts`\nis the single adapter every presentation reads from, and one shared\n`DataTableController` (from `@happyvertical/smrt-ui/data`) owns search, filters,\nsorting, page, and selection.\n\n| Before | After |\n|--------|-------|\n| local `searchTerm`/`selectedType`/`selectedStatus` runes | controller commands `setSearch` / `setFilters` (stable filter ids `type`, `status`) |\n| `filteredContents` `$derived` per view | `toContentListRows` → `selectContentListRows` → `paginateContentListRows`, computed once for all three modes |\n| bespoke `<table>` markup in compact mode | smrt-ui `DataTable` with the shared columns plus per-column cell snippets |\n| no selection | checkbox selection in every mode via `toggleRowSelection` / `setSelectedRows` |\n| `getViewHref` called inline three times | `resolveContentHref` / `contentListRowActions` (one eligibility source) |\n\nProps are unchanged and still exported as `ContentListProps`: `apiBaseUrl`,\n`contents`, `type` (still locks and hides the type filter), `defaultViewMode`\n(still seeds once), `onEdit`, `onDelete`, `onAdd`, `controls`, `getViewHref`.\nNew optional props: `loading`, `error`, `onRetry`, and `dataSurface`\n(`{ registry, descriptor? }`).\n\nAdapter exports (also re-exported from `./svelte`): `createContentListController`,\n`buildContentListColumns`, `buildContentListSurfaceDescriptor`,\n`toContentListRows`, `selectContentListRows`, `paginateContentListRows`,\n`contentListFilters`, `readContentListFilter`, `applyContentListFilter`,\n`contentListRowActions`, `resolveContentHref`, `selectableContentListRowIds`,\n`resolveSelectedContentListRows`, `resolveSelectedContents`, plus the\n`CONTENT_LIST_*` identity constants.\n\nNotes:\n\n- Controller modes are all `manual`: the adapter owns search, filters, sorting,\n and paging in **every** presentation, and the compact table receives\n `data={pageRows}` and no `totalRows` at all (see \"ContentList owns paging in\n EVERY presentation\" below for why that prop is withheld rather than computed).\n Letting DataTable filter locally over already-filtered rows re-ran the\n transform with subtly different semantics (untrimmed search, its own equality\n rules), so the two presentations could disagree. The component clamps the page\n with `controller.clampPage(queryRows.length)`. #2452 replaces the local\n implementation of that transform with a server query behind the same contract.\n- A `type` prop lock is enforced against live state, not just against the prop:\n a data-surface `set-filters` or `reset` command that drops the type filter is\n re-applied by the lock effect (equality-guarded, so it settles). The unlocked\n branch clears the filter only on an actual lock-REMOVAL transition — tracked\n with a non-reactive `previousLockedType` — because clearing on every run would\n also discard a type filter restored from a link or a saved view, and clearing\n never would strand the old lock after the prop went away.\n- Selection may only address durable rows. All three presentations render a\n disabled, explained checkbox for `identified: false` rows, page select-all\n skips them, and a normalization effect re-dispatches `setSelectedRows` without\n any non-durable id, which covers data-surface commands too.\n- Compact mode renders a content-owned `select` column (header + cell snippets)\n instead of passing `selectable` to DataTable. DataTable has no per-row\n selection predicate, so its header select-all addresses the synthetic id of an\n unidentified row; the normalization effect then strips it and the header stays\n indeterminate forever. Because column order is reconciled from the\n controller's known column ids, the structural `select` and `actions` ids are\n part of `CONTENT_LIST_TABLE_COLUMN_IDS` — omit them and selection renders\n behind every data column.\n- Only rendered columns are published to a data surface. `description` is a\n hidden, search-only column so search still reaches the deck; the descriptor\n additionally declares the `id` row-key column, which the surface contract\n requires but the table never renders.\n- Rows without a durable `id` (or repeating one) still render, keyed by\n position, but are marked `identified: false`;\n `resolveSelectedContentListRows` drops them so a bulk action can never act on\n an unaddressable row. `ContentData` has no expiry or site field, so the\n `site` column is derived from `url`/`source`.\n- Column ids are public identifiers and do not always match the model field, so\n the descriptor's `fieldName` comes from an explicit map\n (`publish` → `publish_date`, `updated` → `updatedAt`); the derived `site`\n column advertises no field at all rather than a nonexistent one.\n- Filter values are normalized per column (`type` via `normalizeContentType`,\n everything else via `normalizeContentToken`) through\n `normalizeContentListFilterValue`, and a blank value clears the filter — an\n `equals ''` filter would silently exclude every row.\n- The card presentations render their own page controls (smrt-ui `Pagination`\n dispatching `setPage`) and their own polite refresh status, because DataTable\n — which owns both in compact mode — is not mounted there. A page size arriving\n from a saved view or a surface command would otherwise strand the operator on\n page one, and a refresh over retained rows would be silent.\n- `dataSurface` registers the compact table only. Agent addressability for the\n grid and detailed presentations lands with #2456.\n- Compact mode stays mounted for empty and loading results — DataTable renders\n its own `empty` snippet and loading row — because it owns the mounted surface:\n swapping it for the shared empty panel unregisters the surface, and an agent\n whose own search returned nothing then gets `not_found` on the command that\n would undo it. The shared loading/empty panels are the card presentations'\n only; the `error` branch still replaces the list in every mode, since a load\n failure is host-driven rather than surface-driven.\n\n## ContentList server-backed mode (#2452)\n\n`ContentList` gained three optional, independent opt-ins. Omit all three and the\ncomponent behaves exactly as it did after #2451 — `ContentWorkspaceRoute` passes\n`contents` and nothing else and is unchanged.\n\n| Prop | Type | Effect |\n|---|---|---|\n| `query` | `ContentListQuerySource` | Rows come from `POST /api/v1/contents/query`; `contents` is ignored |\n| `urlState` | `ContentListUrlStateBinding` | Restore from, and publish to, a query string |\n| `savedViews` | `ContentListSavedViewStore` | Save / apply / delete named views |\n\n### The query seam\n\n`query.bind()` is called **once**, during component initialization, and returns\na `ContentListQueryBinding`. `remoteQuery(collection, transport)` from\n`@happyvertical/smrt-svelte/web` satisfies that interface structurally, so its\n`$effect` teardown is registered in `ContentList`'s own scope and disposed with\nit:\n\n```svelte\nconst transport = createContentListQueryTransport({ apiBaseUrl: '/api/v1' });\n<ContentList query={{ bind: () => remoteQuery(collection, transport) }} … />\n```\n\nThe binding type is declared **structurally rather than imported**: pulling\n`@happyvertical/smrt-svelte` (and through it `@happyvertical/smrt-web` and\n`@tanstack/db`) into `@happyvertical/smrt-content/svelte` would put the browser\ndata runtime in a barrel that public content pages import, defeating the\ncode-split boundary that runtime's own AGENTS.md ratifies. smrt-web mirrors\n`smrt-types` for the same reason.\n\n### Three id namespaces\n\nA list value crosses three vocabularies that do not agree, and the bridge is\nexplicit in both directions:\n\n| Namespace | Example | Owner |\n|---|---|---|\n| adapter column id | `updated` | `content-list-controller.ts` |\n| `ContentData` field | `updatedAt` | `mock-smrt-client.ts` |\n| server data-query field id | `updated_at` | the registered `Content` model |\n\n`CONTENT_LIST_QUERY_FIELDS` maps column → server field\n(`publish` → `publish_date`, `updated` → `updated_at`), and\n`ROW_FIELD_TO_CONTENT_KEY` maps result row → `ContentData`\n(`updated_at` → `updatedAt`, `created_at` → `createdAt`).\n`content-list-query.test.ts` asserts the first map against the *real*\n`buildContentQuerySchema()` — field ids, declared types, and declared filter\noperators — so a model rename breaks a test rather than a production query.\n\n`site` has **no** server field: it is derived in the browser from\n`url`/`source`. A filter or sort on it is dropped from the request and reported.\n\n### The local transform is the local-mode path only\n\nIn server mode the returned rows *are* the answer — the server already applied\nsearch, filters, sort, and page — so `selectContentListRows` /\n`paginateContentListRows` must not run over them. Running them again re-filters\nwith different semantics (untrimmed search, case-insensitive comparison, a\n`site` predicate the server never saw) and can hide rows the server returned.\n`totalRows` therefore comes from `result.total`, not from the page length.\n\n**Clamping moves the operator, so it acts only on a count that is exactly\nright.** Two findings in a row were \"the clamp acted on a number that wasn't the\ntotal\", so the rule is stated as a set rather than patched case by case:\n\n| Input | Clamp against it? |\n|---|---|\n| local mode row count | yes — the supplied array IS the whole result set |\n| server total, `exact` | yes |\n| server total, `estimated` | **no** — an approximation can hide a page that really exists |\n| server total, `unavailable` | **no** — the count is unknown; `rows.length` is the page, not the total |\n| no response yet for this query | **no** — a page restored from a link survives until its own count arrives |\n| a settled response for a DIFFERENT query | **no** — the binding holds the previous total while a new request is in flight |\n| a page-size change | n/a — `setPageSize` resets the page itself |\n\n**ContentList owns paging in EVERY presentation, compact included, and\ndeliberately never passes `totalRows` to DataTable.** DataTable runs its own\n`clampPage(totalRows)` effect against the SAME controller, with no authority\nrule and no notion of which query a total belongs to — so for two rounds the\nclamp fixes above were live in the card modes and bypassed in compact, where an\n`estimated` total clamped a real page away and a stale total reset a restored\none.\n\nOne prop cannot serve both jobs: `totalRows` drives that clamp AND DataTable's\npager, so any total authoritative enough to clamp against is also the only total\nthe pager can show. Passing an authoritative-only total silences the clamp but\nleaves compact with no pager on an `estimated` total while the card modes still\nshow one, and then the two modes disagree about which pages exist — a worse bug\nthan the one being fixed. So ContentList keeps one clamp (its own effect, with\nthe authority rule) and one pager (its own `<Pagination>`, driven by\n`pageableRowCount`, which accepts an estimate because SHOWING a page and MOVING\nthe operator are different questions). The same reasoning already made the\nselection column content-owned in compact mode.\n\n**Invariant: the presentations must never disagree about which pages exist or\nwhich rows are reachable.** Anything about which page is requested, which pages\nare offered, or which rows come back belongs in `describePaging` in the test\nsuite, which runs the suite in both `grid` and `compact`. `defaultViewMode`\ndefaults to `grid`, so a plain `renderList` test proves only the arm where\nDataTable is not mounted.\n\n`estimated` is a deliberate choice, not an oversight. Clamping on an estimate\nstrands rows the operator cannot then reach; not clamping can offer a page that\ncomes back empty, which is visible and self-correcting. Hiding reachable rows is\nthe worse failure — the same reasoning as \"truncation only when it narrows\".\nShowing a pager is a different question, so `pageableRowCount` still accepts an\nestimate through `contentListQueryTotalValue`; only\n`contentListQueryExactTotal` feeds the clamp.\n\nSelection normalization also changes shape in server mode: `rows` is only the\ncurrent page, so membership cannot be the durability test (it would clear the\nselection on every page change). Only the adapter's synthetic ids are stripped.\n\n### One page-size ceiling, one page size\n\n`maxPageSize` is resolved ONCE, by `resolveContentListMaxPageSize`, as the\nminimum of every configured limit and the schema's `maxPageLimit` — every\ncandidate narrows, so a host that sets `query.request.maxPageSize` as a server\nrow budget does not lose it to a looser `urlState.options.maxPageSize`. That one\nnumber is passed to the controller seed, the URL sanitizer, the saved-view\nsanitizer and the translator, which is what makes the size the UI pages by and\nthe size the server applies the same number *by construction*.\n\nTwo page sizes are unusable in server mode, and BOTH are coerced against live\nstate (a saved view, a link, and a data-surface `set-page-size` all arrive after\nmount) and reported:\n\n| Live page size | Coerced to | Why |\n|---|---|---|\n| `null` | the seed | the endpoint always applies a limit |\n| `> maxPageSize` | `maxPageSize` | the translator clamps the request, so leaving the controller higher makes `totalPages` compute 1 and hide the page controls |\n\nThe seed itself is clamped to the ceiling too: a `defaultPageSize` above it\nwould otherwise seed a page size the request silently reduces.\n\n`pageSize: null` means \"show everything\", and the query endpoint has no way to\nexpress that. Left alone, a null page size renders one page of `limit` rows with\nno page controls and no way to reach the rest, so in server mode it is\n**coerced and reported**:\n\n- supplying `query` seeds the controller's `pageSize` from\n `query.request.defaultPageSize` (default 50), clamped to the ceiling;\n- that same number is handed to the URL layer as its `defaultPageSize`, so a\n link that omits `size` restores the seed instead of overwriting it with the\n local `null`, and a link this list writes omits `size` while at the default;\n- a null arriving later — `?size=all`, a saved view, a data-surface\n `set-page-size` — is coerced by an effect against live state, and the\n translator reports the same coercion for a direct caller.\n\nBoth report a `pageSize` / `unpaginated-unsupported` drop in the notice. Local\nmode keeps the historical unpaginated list unchanged; the coercion is\nserver-mode-only.\n\n### Search, and what the protocol cannot express\n\nThe protocol has `filter` only — no search primitive. `state.search` becomes an\n`any` of `like` predicates over the adapter's searchable columns (`title`,\n`description`, `author`), with the wildcards added client-side and the\noperator's own `%`/`_` escaped with a backslash.\n\n**Known dialect gap:** PostgreSQL and DuckDB honour a backslash as the default\n`LIKE` escape; SQLite has none and the collection query builder emits no\n`ESCAPE` clause, so on SQLite an escaped `%` matches the two literal characters.\nThat fails closed (empty result) rather than open (every row); a portable fix\nneeds an `ESCAPE` clause at the collection/SQL boundary.\n\n**Second known dialect gap — where an absent value sorts.** A sort term is\n`<field> <ASC|DESC>` and nothing more: `buildOrderBySql` splits the term on\nwhitespace and discards everything after the direction, and `DataQuerySort`\ncarries only a field and a direction, so `NULLS FIRST`/`NULLS LAST` cannot be\nexpressed from this package at all. The placement is therefore whatever the\ndialect defaults to — PostgreSQL and DuckDB put NULLs LAST ascending, SQLite\nputs them FIRST — which changes **which rows land on page one**.\n\nThe local comparator is aligned to ONE documented choice: **absent sorts last\nascending and first descending**, matching the SQL standard, the PostgreSQL and\nDuckDB defaults, and therefore the production dialects. A SQLite-backed\ndeployment will disagree with local mode on where absent values fall. That\ndivergence is dialect-level and not fixable here; fixing it needs a nulls\nplacement in the sort term at the `@happyvertical/sql` / `SmrtCollection`\nboundary, and a slot for it in `DataQuerySort`. A test pins SQLite's observed\nordering so this note cannot rot.\n\n`isNull` / `isNotNull` map to a **null-valued `eq` / `ne`**. That is null-aware\nend to end and is not a comparison against NULL: the protocol scalar type admits\n`null`, the request normalizer rejects a null value only for\n`gt`/`gte`/`lt`/`lte`/`like`, and `buildWhere` lowers `{ field: null }` to\n`IS NULL` and `{ 'field !=': null }` to `IS NOT NULL`.\n\nDropped rather than sent, because sending them would fail the *whole* request:\n\n- `notContains` — the executor refuses to negate a `like`. The only operator\n with no server expression.\n- `contains` / `startsWith` / `endsWith` on a datetime column — `like` is\n string-only in the request normalizer.\n- an unparseable datetime value — the normalizer requires an RFC 3339 instant.\n\nThe translator also enforces the normalizer's **input caps**, because exceeding\none 400s the entire list rather than degrading it. Each is capped and reported:\nat most 100 `in`/`notIn` values, at most 50 filter nodes (counting every\n`all`/`any` container, so search costs 4 and the outer `all` 1), at most 50\nprojection fields — each of which must also be a declared projectable field id\nof 1-256 characters, or it is dropped rather than 400ing the list — a request id\nof at most 128 characters, at most 4096 characters per filter value, and 100 000\nbytes for the whole serialized request (100 values of 4096 characters is inside\nevery per-value cap and still five times that limit, so the newest filter\nbranches are shed until it fits).\n\n**Measure what the server measures.** `boundRequestSize` weighs the NORMALIZED\nrequest, not the body as sent, and the normalizer injects `schema.defaultSort`\nwhen `sort` is absent — 84 bytes the client had not counted, so a request of\n99 917-100 000 bytes passed the client check and 400ed at the server. The\ntranslator therefore always emits `sort`, falling back to\n`CONTENT_LIST_QUERY_DEFAULT_SORT` (the schema default) rather than omitting the\nkey. The invariant to preserve: the client's byte count is never smaller than\ncore's.\n\n**Validate the input, never the value derived from it.** `new Date()` rolls an\nimpossible calendar date forward — `2026-02-31` becomes `2026-03-03` — so\nchecking the *produced* instant against the RFC 3339 pattern reports nothing and\nthe query silently targets a day the link never named. The translator re-derives\nthe year/month/day and compares, exactly as `normalizedInstant` does server-side,\nand drops the filter with a report. A date-only value (`2026-02-01`) is still\naccepted and widened to midnight UTC; only impossible days are refused.\n\n**Truncation is only permissible when it NARROWS.** Every cap above degrades a\nrequest rather than failing it, but degrading is only honest when the answer\nstays a subset of the question:\n\nDirection is a property of the OPERATOR, not of the site — the same cap narrows\nan `in` and widens a `notIn`, and the same truncation widens a `contains` and\ndoes neither to an `eq`. So the classification is made per operator, and where a\nchange is neither narrowing nor widening the filter is not sent at all:\n\n| Bound hit | Effect on the result | What happens |\n|---|---|---|\n| `in` list past 100 values | narrows — a disjunct is removed | capped, reported as `out-of-range` |\n| `notIn` list past 100 values, or carrying an entry that cannot be sent faithfully | **widens** — an exclusion is removed | the whole filter is left out, reported as `filter-widened` |\n| filter-node, OR-branch, or request-byte budget | **widens** — a conjunct is shed | reported as `filter-widened` |\n| a shortened `contains` or `startsWith` pattern, or a shortened search term | **widens** — a shorter pattern matches a superset | sent, reported as `filter-widened` |\n| a shortened `endsWith` pattern | **widens**, but only because the TRAILING characters are the ones kept | sent, reported as `filter-widened` |\n| a shortened scalar comparand (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`) | **neither** — `gt`/`gte` would widen, `lt`/`lte` would NARROW and hide rows, `eq`/`ne` would name a third row entirely | the filter is left out, reported as `filter-widened` |\n| page size, offset, projection count | does not add rows | reported as `out-of-range` |\n\n**Every path lands in one of FOUR states, and every one of them reports.** The\npartition is four-way, not three — an `in` list cut to its first hundred values\nis a genuine SUBSET, which is allowed and is not a widening:\n\n| State | Example | Reported as |\n|---|---|---|\n| applied exactly | anything within every bound | nothing to report |\n| applied as a true SUPERSET | a shortened `like` pattern | `filter-widened` |\n| applied as a true SUBSET | an `in` list past 100 values, or one carrying an entry that could not be used | `out-of-range` / `unsupported-value` |\n| not applied at all | a `notIn` that cannot be carried, a shed conjunct, a comparand too long to send | `filter-widened` |\n\nThe fourth column is the point: a state without a report is a fifth state, and\nit is the one that hides. An unusable entry in an `in` list used to be exactly\nthat — dropped silently, so the toolbar stated a three-value filter while the\nquery asked for one and the rows the operator listed vanished without a word.\nAny new bound must land in one of the four rows above *and* report.\n\n**Never emit a value the caller did not name.** The list path already refused to\nsend a shortened entry; the scalar path used to send one and merely relabel the\nreport, which was affirmatively wrong for half the operators — telling an\noperator the list \"may include rows it would have excluded\" while `lt` was\nquietly hiding rows. A comparand that cannot be sent whole is now not sent at\nall, which is uniformly a widening and is honestly reported as one.\n\n**Which end of a pattern survives decides whether it widens.**\n`boundLikeSource` keeps the LEADING code points for `contains` (`%abc%`) and\n`startsWith` (`abc%`) — anything containing or starting with `abcdef` also\ncontains or starts with `abc`. A suffix pattern (`%abc`) is the mirror image, so\nit keeps the TRAILING ones: truncating `%…END` from the front would name a\ndifferent ending, dropping the row the operator asked for while picking up\nunrelated ones. That is neither a superset nor a subset, and no honest label\nexists for it.\n\nA widening `notIn` is never PARTIALLY applied. The cap keeps arrival order, so a\nliteral `null` past the hundredth entry is the entry shed — and the executor\nthen takes its \"no null listed\" arm and unions `IS NULL` back in, returning\nevery absent-valued row the caller listed `null` to exclude. Dropping the filter\nwhole is wider still, but it is *honest*: the operator is told the filter is not\nbeing applied instead of being told it was \"clamped\", which would imply it still\nworks.\n\nThat distinction is the whole point of the `filter-widened` reason. An operator\nwho is told a list was clamped reasonably assumes the answer is a subset of what\nthey asked for. Telling them that when the truth is \"this now returns rows you\nexcluded\" is worse than telling them nothing.\n\n**Measure in the server's unit.** `dataQueryScalar` tests `value.length`, which\ncounts UTF-16 code units, so an astral character costs TWO. The bounders iterate\nby code point — never splitting a surrogate pair — but charge `character.length`\nper code point plus one for an escape. Charging one per code point made a search\nof 4093 ASCII characters plus one emoji measure 4094 client-side and 4097\nserver-side: a hard 400 and a whole-list error panel.\n\nAnd mirror the normalizer's **validity rules**, not only its numeric caps. A\ndatetime input is held to three separate checks before it is parsed, because\n`Date` will happily accept and silently reinterpret what fails each one:\n\n| Check | Refuses | Why |\n|---|---|---|\n| calendar round-trip | `2026-02-31` | `Date` rolls it to March 3, so the query targets a day the link never named |\n| an offset on a time-bearing value | `2026-02-01T00:00` | no offset means `Date` reads it as LOCAL time, so the identical link submits a different instant per viewer — nine hours apart between London and Tokyo |\n| the `T` separator | `2026-02-01 10:00:00Z` | a space leaves the ISO grammar, so `Date` falls through to its implementation-defined legacy parser: the same hazard moved from timezone to engine |\n| four-digit year | `+275760-09-13` | serializes to an expanded-year form the server refuses outright |\n\nThe rule is \"a time must carry an offset\", not \"a time must state its seconds\":\n`2026-02-01T12:30Z` and `2026-02-01T12:30+09:00` each name one instant for every\nreader and are accepted. A bare calendar day (`2026-02-01`) is accepted too —\n`Date` reads it as UTC midnight everywhere. Lower-case `t`/`z` is fine: RFC 3339\n§5.6 permits it, and the value is canonicalized through `toISOString()` before\nit is sent, so the server only ever sees the strict upper-case form it insists\non.\n\n**Canonicalize before parsing, too.** RFC 3339 §5.6 permits a lower-case\n`t`/`z`, but ECMA-262's Date Time String Format specifies the upper-case forms,\nso `new Date('2026-02-01t12:30z')` runs on engine-specific heuristics rather\nthan the spec — the same objection that rules out the space separator. V8 reads\nit as UTC, which is luck rather than a guarantee, so the value is upper-cased\nbefore it reaches `Date`. `toUpperCase()` is locale-independent and this grammar\nhas no other letters, so the canonical form means exactly what the input did.\n\n**Validate and parse the SAME string.** Checking `text.trim()` and then parsing\n`text` let `\"2026-02-01 \"` through, and V8's ISO parser rejects the whitespace\nand falls back to the legacy parser — which reads a bare date as LOCAL midnight,\nreintroducing the per-viewer divergence with no drop reported at all. The\nvalidator returns the exact string to parse rather than a verdict about a\ndifferent one.\n\n### Filter case: tokens are folded, free text is not\n\n`normalizeContentListFilterValue` folds case for the **token** columns only —\n`type`, `status`, `state`, whose domain is a fixed lowercase vocabulary the\nmodel writes. Every other column holds text a person typed, and its value\nbecomes a server-side `eq` or `like` compared against the STORED text: folding\n`NASA` to `%nasa%` would miss `NASA Update` on PostgreSQL or DuckDB. The helper\ndates from #2451, when every comparison was local and folding everything was\nharmless; server mode invalidated that assumption.\n\nPreserving case is safe locally because the local evaluator compares through\n`textValue()`, which lower-cases BOTH sides at compare time — a case-preserving\nstored value still matches case-insensitively there. The `type`-lock predicate\n(`isContentListFilterExactly`) goes through the same helper, so the lock still\nsettles rather than re-dispatching.\n\n**Free-text server matching is therefore dialect-dependent, and the protocol\ncannot make it uniform.** There is no `ilike` in the operator vocabulary, so:\n\n| Backend | `like` / `eq` on free text |\n|---|---|\n| PostgreSQL, DuckDB | case-SENSITIVE |\n| SQLite | case-insensitive for ASCII |\n\nLocal mode is case-insensitive everywhere. Do not describe free-text filtering\nas uniform; a portable fix needs a case-insensitive operator at the collection\nboundary.\n\n### NULL semantics are aligned\n\nThe same shared link must return the same rows whether the host passed a `query`\nor not, so every null-sensitive operator now agrees across the two modes. Two\ndifferent alignments were needed, in opposite directions, because the *meaning*\ndiffers per operator:\n\n| Operator | Server lowering | Alignment |\n|---|---|---|\n| `eq`, `in`, `like` (`contains`/`startsWith`/`endsWith`) | plain predicate | already agreed — both exclude an absent row |\n| `in` with a `null` listed | `IS NULL OR IN (…)` | already aligned |\n| `ne` (value ≠ null) | `IS NULL OR <> v` | server gained the union: \"not v\" includes rows with no value, as the local evaluator has always said |\n| `notIn` (no `null` listed) | `IS NULL OR (AND of <> v)` | same |\n| `notIn` WITH a `null` listed | `(AND of <> v) AND IS NOT NULL` | **no union**: a listed `null` says absent rows are excluded too |\n| `eq null` / `ne null`, and `in`/`notIn` carrying a listed `null` | `IS NULL` / `IS NOT NULL` | the translator forwards a literal `null` rather than coercing it away, and the local evaluator resolves it through `isAbsentContentValue` |\n| `ne null` (`isNotNull`) | `IS NOT NULL` | untouched — a union would match every row |\n| `gt`, `gte`, `lt`, `lte` — asked for directly | plain predicate | the LOCAL evaluator gained null-awareness |\n| `gt`, `gte`, `lt`, `lte` — reached through `not` | `IS NULL OR <predicate>` | server gained the union, so the negation is a true complement |\n| `isNull` / `isNotNull` | `IS NULL` / `IS NOT NULL` | the LOCAL evaluator gained null-awareness |\n\n### A value that reads as absent is still a value\n\n`null` in a filter list means \"and rows with no value at all\". It was silently\ndiscarded in THREE separate layers before this was tracked to one shape: each\nlayer tested the value for PRESENCE rather than for VALIDITY, and `null` reads\nas absent to any check written with truthiness. `normalizedFilterValue` is the\ncorrection — it returns three outcomes, not two: a string, the value `null`, or\n`undefined` for genuinely unusable, which is the only case a caller may drop.\n\nEvery layer a filter value crosses, and what each does with a `null` entry:\n\n| Layer | Behaviour |\n|---|---|\n| data-surface `set-filters` → controller | passes through unchanged |\n| `sanitizeContentListViewState` | **preserves** it (was: silently dropped) |\n| controller state (`setFilters` / `replaceState`) | passes through unchanged |\n| URL serialization | writes the `\\0` token |\n| URL parse | reads the token back as `null` |\n| saved-view write (`hydrateDataTableSnapshot`) | JSON, so `null` is native |\n| saved-view read | native |\n| `restoreContentListSavedView` | preserves it (shares the sanitizer above) |\n| translator (`coerceValue`) | **preserves** it (fixed earlier) |\n| request → `normalizeFilter` | accepts a null scalar for `eq`/`ne` |\n| executor (`conditionToDnf`) | lowers a listed null to `IS NULL` / `IS NOT NULL` |\n| local evaluator | `matchesAbsentContentValue` answers the same predicate |\n\n**The URL token is collision-free by construction, not by being unlikely.**\n`escapeListEntry` doubles every backslash a real value contains, so the only\ntwo-character sequences a real entry can begin with are `\\\\` and `\\,`. A LONE\nbackslash followed by `0` is therefore unreachable from any string — including\nthe literal two characters `\\0`, which serialize as `\\\\0` and read back as\nthemselves. It survives percent-encoding as `%5C0`. Writing `null` as the word\nwould have been ambiguous with an author actually called \"null\".\n\nA **scalar** null comparand needs no token at all: `equals null` and `isNull`\nare the same predicate — both lower to `eq null`, and the local evaluator\nanswers them identically — so it is written as the valueless operator, which\nalready has a query-string form.\n\n**The bug was never really about `null`.** The same shape catches any value that\na truthiness check reads as absent, so `0`, `false`, and the empty string are\ntested alongside it. An empty entry WITHIN a list (`?author.in=a,`) is the empty\nstring, which is a real value for a column that stores one; an entirely empty\nparameter (`?author.in=`) is a list with no values and is still refused and\nreported. A blank SCALAR still clears the filter, matching\n`applyContentListFilter`.\n\n**A literal `null` has to survive the TRANSLATOR too.** `coerceValue` reports a\nnull as unusable, which is right for a value that arrived as text and wrong for\none a caller wrote deliberately: dropping it sent `notIn ['Ada']` for\n`notIn ['Ada', null]` and returned exactly the rows the caller excluded — the\nexecutor's correct lowering undone one layer up. A literal null is now carried\nthrough `in`/`notIn` lists and accepted as an `equals`/`notEquals` comparand,\nand the local evaluator resolves it to absence, so a data-surface `set-filters`\nmeans the same thing in both modes. Such a value cannot arrive from a link or a\nsaved view — the sanitizer refuses a non-text filter value — so the reachable\npath is an agent command.\n\n**A listed `null` inverts the rule, and getting that wrong is a data leak of the\nworst kind — a filter returning exactly the rows it was asked to exclude.**\n`notIn ['Ada', null]` means \"not Ada, and not blank\", so it must NOT gain the\n`IS NULL` union. It is reachable from the wire (`normalizeFilter` accepts a null\nlist entry) and through `not(in ['Ada', null])`. Without the distinction,\n`in [x, null]` and its own negation both match the absent row: a predicate\noverlapping its negation. The executor tests assert every list shape\n(no null / with null / null-only / inverted) partitions the rows with no overlap\nand no gap.\n\n**The ordered comparisons were aligned on the LOCAL side, not the server's.**\n`ContentListRow` flattens every field to display text, so an absent value read\nas `''` — which sorts below everything, made `publish_date lt X` match every\nnever-published row, and made `isNull` match nothing at all. The original\n`ContentData` still distinguishes absent from empty, so\n`isAbsentContentValue()` consults it and the null-sensitive operators exclude an\nabsent value exactly as SQL's three-valued logic does.\n\n**Absence is decided before any text comparison.** The flattened row reads an\nabsent value as empty text, so comparing it as text answers a question about\n`''` rather than about absence — and the two differ for every operator once a\nBLANK comparand is involved (`author equals ''` matched an absent row locally\nand no row in SQL; `author notEquals ''` did the reverse).\n`matchesAbsentContentValue` decides from the operator alone, following the SQL\nthe executor emits: `equals` matches only a null comparand, `notEquals` matches\nunless the comparand is null, `in`/`notIn` turn on whether the list carries a\n`null`, and `like` and every ordered comparison match nothing.\n\n**A display fallback is presentation, never data.** Two columns substitute a\nlabel when the content carries no value — `type` reads `content` and `title`\nreads `Untitled content`. Comparing the label made `?type=content` return every\nuntyped row on a client-array list and none on a server-backed one, and made a\nsearch for `untitled` find rows whose title is simply missing.\n`comparisonValue()` reads what is stored for those two columns instead: `null`\nwhen the content has no value, empty text when it is genuinely blank, and the\nflattened text otherwise, which is already faithful. Both local filtering and\nlocal search go through it. The label keeps rendering; `isNull` is how an\noperator asks for the rows behind it. This is the direction the\ndata means: \"no publish date\" is not \"published before X\". It also aligns the\nblank-comparand case (`?author.lt=`, `?author.gte=`), where the flattened `''`\nused to compare equal and the two modes disagreed, and it leaves a column that\ngenuinely stores `''` (`title`, `name`) comparing as present in both modes.\n\n**A negation must be a complement.** `not` is part of the endpoint's accepted\ngrammar even though the translator never emits one, and a direct HTTP consumer\nsending `not(gt 'B')` used to get a bare `<= 'B'` — leaving a row with no value\nmatching NEITHER the predicate nor its negation. `conditionToDnf` therefore\ntakes a `negated` flag: an ordered comparison reached through an odd number of\n`not`s unions `IS NULL`, while the same operator asked for directly does not.\n`eq` reached by negating `ne` gets no union, because the complement of\n\"IS NULL OR <> v\" is \"= v\", which excludes NULL by construction. The executor\ntests assert that **every** operator in the grammar partitions the rows against\nits own negation — no overlap, no gap — including through `all`/`any`\ncontainers and a double negation. `like` is the sole exclusion: negating one is\nrefused outright.\n\nThe `ne`/`notIn` union costs a second DNF branch each, a negated ordered\ncomparison costs a second too, and an `all` multiplies,\nso the translator also mirrors `MAX_CONTENT_QUERY_OR_BRANCHES` (128) and drops\nfilters before the executor would refuse the whole request. The mirror is exact\nrather than conservative — including the listed-`null` case, which costs one\nbranch rather than two — and it handles De Morgan under `not`, so a future\nnegating emitter cannot silently under-count.\n\n### Scope is application-supplied and server-derived\n\nA `DataQueryRequest` carries no authority. Tenancy is applied inside\n`executeContentQuery` (fail-closed to global rows). Site, organization, or\nworkspace narrowing is the host's: the framework models neither site nor\norganization, so a host calls `executeContentQuery(collection, body, { scope })`\nfrom its own route with conditions derived from the authenticated context —\nnever from the request body.\n\n**`undefined` and `[]` mean OPPOSITE things, and getting that backwards is an\nauthorization fail-open.** A host builds a scope from an allowed-resource list —\nthe sites, workspaces, or organizations this principal may see:\n\n```ts\nconst scope = permittedSiteIds.map((siteId) => ({ siteId }));\n```\n\nThat list is empty exactly when the principal may see nothing. An empty array\ntherefore means \"the set of permitted conditions is empty\" and matches no rows;\nomit the option entirely to mean \"this deployment applies no application\nscope\". Treating the empty array as absent turned *access to zero sites* into\n*access to every row in the tenant* — the precise failure the two-layer scope\ndesign exists to prevent, in the seam chosen as the application's scoping\nmechanism.\n\nAn empty scope lowers to a predicate that matches nothing (`id IS NULL`, false\nfor every row on every dialect) rather than throwing: \"you may see nothing\" is a\nlegitimate authorization state, and answering it with a 500 would be wrong. It\nis ANDed into every branch like any other scope condition, so no caller filter\nshape can escape it, and it survives alongside tenancy rather than being\nreplaced by it. A malformed condition — an empty OBJECT, which states no\nconstraint at all — is still a programming error and still throws.\n\n### Mirrored constants are self-enforcing\n\nEvery number this package copies from the schema or from\n`@happyvertical/smrt-core` is bound to its source by a test, because a\nhand-copied limit that drifts is the defect this issue kept producing: lowering\nthe server's page limit while the client still seeds and pages by the old one\nstrands rows with the whole suite green.\n\nWhere core exports the constant the assertion is direct; where it does not, the\nassertion pins core's observable BEHAVIOUR — the largest value it accepts and\nthe smallest it refuses — rather than being skipped. Add a cross-assertion\nalongside any new mirrored number. The field map, projectable fields, operator\nvocabulary and search fields are asserted against the real\n`buildContentQuerySchema()` for the same reason.\n\n### Documented limits\n\n- Offset paging only; the schema declares `supports.cursorPagination: false`.\n- `body` is not queryable — it is a document, and the envelope caps a scalar at\n 4096 characters. Read it through `GET /api/v1/contents/{id}`.\n- `metadata` path filtering is unavailable: JSON columns get no filter operators.\n- There is no ETag or version slot in the canonical envelope; `queryFingerprint`\n and `freshness.asOf` serve that role.\n- `maxResultBytes` on a host-supplied schema must be at least\n `CONTENT_QUERY_MIN_RESULT_BYTES`. The row budget is that number minus the\n envelope reserve, so a smaller one leaves nothing for rows and every query\n answers with an empty page flagged `truncated` — indistinguishable from \"no\n content matched\". It is refused with a plain `Error` naming the minimum\n rather than a `DataQueryValidationError`, because `schema` is trusted adapter\n configuration: a typed validation error would surface as a 400 and blame the\n caller for the host's mistake.\n\n **Where this actually runs:** `executeContentQuery` checks it on every\n request, so a misconfigured schema can never serve a query — but through the\n generated route an untyped error becomes an opaque 500, and the message\n naming the minimum reaches only the server log. Call\n `assertContentQuerySchema(schema)` once where the schema is configured to\n fail next to the mistake instead. The check is applied uniformly across query\n modes, `count` included: a schema too small to serve its own row mode is\n misconfigured whatever this request asked for, and letting `count` through\n would hide that until the first rows query. A zero or negative budget is\n refused too — it previously meant \"use the default\" for rows and \"zero\n budget\" for facets, which is two answers to one question.\n- **A row is never dropped to fit the byte budget — only shortened.** Offset\n paging advances by the requested LIMIT, not by the number of rows actually\n returned, so a dropped row is skipped on its own page and on every page after\n it: silent, permanent data loss. `DataQueryResult`'s offset page is\n `{ kind, offset, limit, hasMore }` with no next-offset slot, and the\n normalizer refuses a `nextCursor` on an offset page, so a continuation offset\n cannot express \"resume at 170\" either. Instead the page's budget is allocated\n **floor-first, then max-min fair**: every row is seated at its irreducible\n minimum before any surplus is shared, and the surplus goes to the smallest\n APPETITE (cost minus floor) first. Ordering by current cost instead would let\n a row that needs nothing take an even share while a large, mostly-irreducible\n row is starved below its own floor — declaring a feasible page impossible.\n Seating the floors first makes the guarantee unconditional: if the floors fit,\n the page is served. That guarantee lives in `allocateRowBytes`, which is\n exported and unit-tested, because a row's floor is dominated by the\n projection's key bytes — identical across the rows of one page — so the\n disparity that breaks cost-first ordering is not reachable through\n `executeContentQuery` itself.\n\n Within a row, the fields are levelled to a shared byte cap computed in one\n sorted walk (water-filling), not searched for by halving and re-measuring —\n the old search re-serialized the row and every field on every step, which\n turned an ordinary wide page at the default budget into ~9x the cost of the\n same unbounded read. HOW a field gives way depends on its declared type:\n\n | declared type | how it gives way | why |\n |---|---|---|\n | `string` | levelled to the shared cap | any prefix of free text is still free text |\n | `datetime` | `null`, or not at all | **no prefix of an RFC 3339 instant is valid** — a shortened one makes the adapter emit a value that breaks the type it declared, and the normalizer then rejects the whole page with `must be an RFC 3339 instant`, blaming the caller |\n | `json` | `null`, or not at all | a document has no incremental shortening |\n | number, boolean, `null` | never | already minimal |\n | the identity field | never | it is the row's address; emptying it fails result normalization |\n | ANY field whose reduced form is no smaller | never | `{}` and `[]` are TWO bytes, so nulling them costs four |\n\n That last row is a floor rule as much as a shrink rule. **A row's floor is the\n size it can be reduced TO, so it can never exceed the size the row already\n is.** Taking the reduced size as the floor unconditionally overstates it by\n two bytes per empty-document field per row, which refuses pages that would\n have fitted — and the refusal repeats for that page forever. The invariant\n `floor <= cost` is now stated by `measureRow` and held defensively by\n `allocateRowBytes`, rather than being an accident of a caller's short-circuit.\n\n The rule generalizes: **a value may be shortened only when its type accepts\n arbitrary prefixes.** Any format-constrained type added later is all-or-nothing\n by default. An all-or-nothing field is dropped only when the row cannot fit\n with it KEPT — losing a whole value to save a few bytes is a last resort, so a\n 200 KB `metadata` blob goes immediately while a 26-byte `updated_at` survives\n whenever the strings can absorb the difference.\n\n Byte accounting is exact at the boundary: each row charges the array separator\n that follows it, so an N-row page is refunded the one separator it does not\n need. Without that refund a page whose true size exactly equals the budget is\n needlessly shortened, or — if it is irreducible — refused outright.\n\n Shortening is already a reported state (`truncated` plus its warning) and it\n leaves offset paging exact. Only a page whose FLOORS exceed the budget fails,\n and it fails loudly rather than answering with a page that quietly omits rows.\n- A restored page size is clamped to `maxPageSize` (default\n `CONTENT_LIST_MAX_PAGE_SIZE`, 200, matching the schema's `maxPageLimit`), and\n the clamp is reported. The ceiling is resolved once and applied to **both**\n restore paths — a saved view is not a way around a limit a host set for links.\n- The server bounds its own answer: it shortens over-long values to fit\n `maxResultBytes`, flagging `truncated` with a warning. It does **not** drop\n rows — see the bullet above — precisely because the next page is computed from\n `page * limit`, so a dropped row would be skipped on the following page too.\n `ContentList` reads those flags — from the binding when it exposes them, and\n otherwise off the envelope its own `execute` resolved — and renders them in\n the same notice as the drops.\n- A `json` field (`metadata`, `tags`) is validated as a document, not a scalar,\n and `canonicalJson` rejects the WHOLE result when a nested value breaks any of\n its rules. `executeContentQuery` therefore bounds a JSON document itself —\n 65536-character strings, 1000-item containers, depth 16, finite numbers, no\n cycles, plain values only — flagging `truncated` rather than failing the page.\n It also drops the keys `plainObject` forbids (`__proto__`, `constructor`,\n `prototype`) and builds with a null prototype, because that is the *reachable*\n rule: `metadata` is the documented extension point, it is writable through the\n REST API and through `mirror()` ingestion, and `JSON.parse` of the stored\n column creates an own `__proto__` property — so one row could otherwise make\n every query projecting metadata return 400 for the whole page, permanently.\n- Facet values are held to the SAME shared byte budget as rows. Two text facets\n of 200 distinct 4096-character values are inside every per-value cap and still\n several times the 1 MB result limit; values are dropped and the facet and the\n result are flagged rather than the response being refused.\n- A capped offset moves the caller's page marker AND says so. `?page=9000&size=200`\n caps the request offset at 1 000 000; the translation returns the\n `effectivePage` the request actually reads, and the redirect is reported in\n the notice (\"page 9000 cannot be loaded — the list stops at page 5001\"). That\n drop is held apart from the rest, because the corrective dispatch re-enters\n the query effect in the same flush and the second translation caps nothing —\n storing it with the others would erase it before it was ever rendered. It\n stands until the operator moves off the page they landed on.\n- The pagers never advertise a page the endpoint cannot fetch. `totalPages` is\n capped at `floor(MAX_OFFSET / pageSize) + 1`. `clampPage` deliberately keeps\n using the TRUE total, so a crafted `?page=` still reaches the query effect and\n gets the notice instead of being silently clamped first. There is only one\n pager to cap, because ContentList renders `<Pagination>` itself in every view\n mode and hands DataTable no total to derive a second page count from.\n- A retry replaces the rendered rows, so it also replaces the completeness\n flags. `retry()`'s envelope is read through the same path as `execute()`'s;\n discarding it left a \"rows are missing\" notice standing over a complete page.\n\n### URL state and saved views\n\n`urlState` is router-agnostic on purpose: `params` is read **once** at\ninitialization, and every later change is handed back through `onChange(params,\nstate)` with foreign parameters preserved, so a SvelteKit host calls\n`replaceState`, a hash router rewrites the fragment, and a test passes a plain\n`URLSearchParams`. Restoration goes through `applyContentListViewState`, which\nmerges over current state rather than dispatching `setSearch`/`setFilters` —\nthose would reset the restored page.\n\n**INVARIANT: no exported path may apply unvalidated state to a controller.**\n`applyContentListViewState` sanitizes its patch, because it is the one\napplication point the package publishes and it is routinely composed with\nuntrusted values. That is what makes\n`applyContentListViewState(controller, store.snapshot.state)` safe even though\nthe store's read path deliberately returns a raw\n{@link RawContentListViewSnapshot} — keeping the raw payload is what lets\n`restoreContentListSavedView` still report a stale view's drops. Sanitization is\nidempotent, so ContentList's own already-validated patches are unaffected. Only\nthe patch is sanitized, never the merged result: the sanitizer never emits\nselection, so sanitizing the merge would clear the operator's selection.\n\nTwo URL-serialization rules the round trip depends on:\n\n- an `in`/`notIn` entry containing the list separator is escaped with a\n backslash on write and unescaped on read, so `author in [\"Smith, John\"]`\n restores as one value rather than two — a silently *different* query;\n- a parameter is owned (and therefore removable while rewriting) by its **base\n name** only. A host's `facet.contains=` carries a known operator suffix but\n names no ContentList column, so it survives. That is narrower than the\n recognizer in `readContentListViewStateFromSearchParams`, which still reports\n an operator-suffixed unknown column so a crafted `evil.contains=` stays\n visible: reporting a refusal and deleting a parameter are different acts.\n\nThe `type` prop lock still wins after a restore: the lock effect enforces\nagainst live state, so a restored `?type=document` is replaced by the locked\nvalue. The lock is folded into the restored patch rather than re-applied\nafterwards, because the restore replaces the whole filter set and the lock's\n`setFilters` resets paging — a locked list opening `?page=3` would otherwise\nland on page 1, silently.\n\n### A restored value the toolbar cannot show\n\nThe two toolbar selects publish a display vocabulary\n(`CONTENT_LIST_TYPE_OPTIONS`, `CONTENT_LIST_STATUS_OPTIONS`), but the sanitizer\naccepts any non-blank token, so a link can restore a filter the select has no\noption for — `?status=review` (a real `Content.status`), or a typo like\n`?type=artcile`. The select would show nothing while a live predicate emptied\nthe list.\n\nBoth selects handle it identically, and do two things rather than one:\n\n1. the value is rendered as an extra option, so the toolbar tells the truth\n about what is constraining the list and the operator can clear it;\n2. it is reported in the notice, so an empty result always has an explanation.\n\nA select can only offer a single `equals` value, though, and a link can restore\nmuch more than that. **INVARIANT: the select's displayed state either matches\nthe live predicate exactly, or the operator is told it does not.** Three states,\nall reachable from a shared link:\n\n| Live filter | Select shows | Reported |\n|---|---|---|\n| `equals` with a listed value | the value | no |\n| `equals` with an unlisted value (`?status=embargoed`, a typo) | the value, as an extra option | yes |\n| anything else — a list value (`?status.in=draft,review`), a valueless operator (`?status.isNull=1`), an inverted one (`?status.notEquals=draft`), or two filters on one column | a DISABLED summary of the real predicate | yes |\n\nThe disabled summary carries a U+001F-prefixed sentinel value rather than a\nU+0000 one: the HTML tokenizer rewrites a NUL inside an attribute value to\nU+FFFD, so a server-rendered option would hydrate with a value the select was\nnever given and read as no selection — the exact state the summary exists to\nprevent. A client-only mount bypasses attribute parsing, so only a parse\nround-trip test catches it.\n\nThe third row is the one that matters: a value-only read reports nothing for a\nlist value and reports `draft` for `notEquals draft` — the exact inverse of the\nquery. `readContentListSelectFilter` is operator-aware and is the seam that\nkeeps the control from misstating the query; `readContentListFilter` stays\nvalue-only and must not drive a control. Choosing any real option replaces every\nfilter on that column, so the operator is never stuck.\n\n`review` is now offered outright. `deleted` is deliberately not: that is the\ntrash lifecycle (#2454), and offering it here would imply a restore/purge\naffordance this list does not have. `Content.type` is freeform, so its option\nlist is a display vocabulary rather than the model's domain.\n\nEverything a restore or a translation refused is reported in one dismissible\nnotice rather than thrown — a stale link or an out-of-date saved view must still\nopen the list, minus the parts that are no longer meaningful.\n"
10102
10102
  }
10103
10103
  ]
10104
10104
  }