From b79e4606678c7c9430d46ed87bd291503c71c21c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 13:06:03 +0000 Subject: [PATCH 1/5] =?UTF-8?q?wip:=20page=20hit=20kind=20on=20/api/v1/sea?= =?UTF-8?q?rch=20=E2=80=94=20spec=20schema=20+=20searchAll=20page=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1 --- packages/client/src/index.ts | 7 +- packages/metadata-protocol/src/protocol.ts | 170 ++++++++++++++++-- packages/rest/src/rest-route-ledger.ts | 2 +- packages/spec/src/api/protocol.zod.ts | 66 ++++++- .../src/type-alias-convention.pin.test.ts | 17 +- 5 files changed, 235 insertions(+), 27 deletions(-) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ad080cbc09..95732239a2 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -4761,9 +4761,10 @@ export class ObjectStackClient { * * [#11924] Bound to `SearchAllResponse` (`@objectstack/spec/api`), the * contract #8140 had to leave missing: the route answers the whole body - * BARE, relaying `searchAll`'s `{ query, hits, totalObjects, totalHits, - * truncated }` verbatim, and conformance coverage on both the producer and - * the mount is what entitles the declaration (#3877). + * BARE, relaying `searchAll`'s `{ query, hits, pages, totalObjects, + * totalHits, truncated }` verbatim (#13216 added `pages` — published-page + * hits on an unscoped sweep), and conformance coverage on both the producer + * and the mount is what entitles the declaration (#3877). * ⚠️ `SearchResult` (`@objectstack/spec/contracts`) is STILL the near-miss * trap: it types the per-object `ISearchService.search`, whose `hits` carry * `score`/`document`, not this route's `object`/`title`/`snippet`/`record`. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 5e75b9816b..d97ba12334 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -50,6 +50,11 @@ import { ITEM_KEY_DISCRIMINATORS, itemDiscriminator, type MetadataItem, + // [#13216] The search sweep's page read carries the SAME org scope the + // REST `/meta` read doors derive — registry-gated, never a bare tenant id + // (#9454's read-side rule). One predicate on both doors means the swept + // page set and the served page set cannot drift. + organizationIdForMetaRead, } from '@objectstack/metadata-core'; // [#5532] One vocabulary of "which driver read errors are benign", shared with // `sys-metadata-repository.ts` in this package and with `DatabaseLoader` in @@ -79,7 +84,7 @@ import { } from '@objectstack/spec/data'; import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSpellingRefusal, unrecognisedMetaTypeRefusal, METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared'; import { applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec'; -import { type FormView, isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec/ui'; +import { type FormView, type I18nLabel, isAggregatedViewContainer, expandViewContainer, resolveI18nLabel } from '@objectstack/spec/ui'; // [#11350] Emitted-specifier pin. This module's inferred public declarations // structurally mention `FormFieldInput` (FormView `sections[].fields`), and // this file imports BOTH `@objectstack/spec` (root, for @@ -10470,6 +10475,17 @@ export class ObjectStackProtocolImplementation implements * whose table was never provisioned (`isMissingTableError`): it can hold no * rows, so "no hits from here" is the truth and it is skipped exactly as * before. Every other read failure propagates. See the `catch` below. + * + * ## [#13216] Published pages are swept too — into the sibling `pages` + * + * On an UNSCOPED sweep the response additionally carries `pages`: hits + * over the published pages the caller's own metadata read door serves, + * matched on name / label / description. Deliberately a SIBLING array, + * never members of `hits` — every `hits` element is a record with an + * `object`/`id` address, and an existing consumer iterating it must not + * receive an element whose address vocabulary it predates. The sweep's + * authorization story and matching rules are documented at the sweep + * itself, below the record loop. */ async searchAll(request: { q: string; @@ -10486,13 +10502,20 @@ export class ObjectStackProtocolImplementation implements snippet?: string; record: any; }>; + pages: Array<{ + kind: 'page'; + name: string; + title: string; + snippet?: string; + pageType?: string; + }>; totalObjects: number; totalHits: number; truncated: boolean; }> { const q = (request.q ?? '').trim(); if (!q) { - return { query: '', hits: [], totalObjects: 0, totalHits: 0, truncated: false }; + return { query: '', hits: [], pages: [], totalObjects: 0, totalHits: 0, truncated: false }; } const overallLimit = Math.max(1, Math.min(100, Number(request.limit ?? 20))); @@ -10501,13 +10524,31 @@ export class ObjectStackProtocolImplementation implements ? new Set(request.objects) : null; - // [#7643] SNIPPET tokens only. The engine does its own tokenisation - // inside the `$search` expansion (same split, same AND-of-terms rule), - // so these no longer decide what MATCHES — they decide where the - // excerpt is cut. Kept as a separate local rather than fed to the - // engine: the query text is what the contract carries, and a second - // pre-tokenised channel is exactly the divergence this card closed. - const terms = q.split(/\s+/).filter(Boolean).slice(0, 8); + // [#7643] SNIPPET tokens only, for the RECORD sweep. The engine does + // its own tokenisation inside the `$search` expansion (same split, + // same AND-of-terms rule), so these no longer decide what MATCHES on + // records — they decide where the excerpt is cut. Kept as a separate + // local rather than fed to the engine: the query text is what the + // contract carries, and a second pre-tokenised channel is exactly the + // divergence this card closed. [#13216] `allTerms` (uncapped) is what + // the PAGE sweep matches on — pages are metadata, not engine rows, so + // there is no expansion to delegate to, and matching on the capped + // list would silently drop terms 9+ from the page predicate while the + // engine still ANDs them for records. + const allTerms = q.split(/\s+/).filter(Boolean); + const terms = allTerms.slice(0, 8); + + // One producer of excerpt geometry, shared by record and page hits + // (#13216): the first term literally contained in `v` anchors a cut of + // 30 chars before / 90 after, ellipsized at whichever ends truncate. + const cutExcerpt = (v: string, excerptTerms: string[]): string | undefined => { + const lc = v.toLowerCase(); + const idx = excerptTerms.map(t => lc.indexOf(t.toLowerCase())).find(i => i >= 0); + if (idx == null || idx < 0) return undefined; + const start = Math.max(0, idx - 30); + const end = Math.min(v.length, idx + 90); + return (start > 0 ? '…' : '') + v.slice(start, end) + (end < v.length ? '…' : ''); + }; // [#11754] No `?.` and no `?? []` on this read. The invented empty // list made the whole sweep a silent no-op — zero hits, @@ -10674,14 +10715,8 @@ export class ObjectStackProtocolImplementation implements for (const f of searchableFields) { const v = row[f]; if (typeof v === 'string' && v) { - const lc = v.toLowerCase(); - const idx = terms.map(t => lc.indexOf(t.toLowerCase())).find(i => i >= 0); - if (idx != null && idx >= 0) { - const start = Math.max(0, idx - 30); - const end = Math.min(v.length, idx + 90); - snippet = (start > 0 ? '…' : '') + v.slice(start, end) + (end < v.length ? '…' : ''); - break; - } + snippet = cutExcerpt(v, terms); + if (snippet !== undefined) break; } } hits.push({ @@ -10731,9 +10766,110 @@ export class ObjectStackProtocolImplementation implements } } + // ── [#13216] Published-page sweep — the palette's page hits ───────── + // + // A custom page created and published at runtime renders perfectly and + // was absent from this door only (#13100 measured it): `searchAll` + // swept object RECORDS and nothing else, so the artifact an agent just + // grew into a running app could be reached by direct URL alone. + // + // ## The swept set IS the served set — zero new authorization surface + // + // Pages are read through {@link getMetaItems} — the SAME verb the REST + // `GET /meta/page` list door serves — never through a parallel read of + // the registry or the store. That single decision carries the whole + // authorization story (the 2026-08-29 ruling's basis, as direction 1 + // established it): + // + // - PUBLISHED only: `getMetaItems` reads `state: 'active'` overlay + // rows plus code-registered pages; drafts surface only under + // `previewDrafts`, which this sweep never passes. + // - Org scope: threaded through `organizationIdForMetaRead('page', + // context.tenantId)` — the registry-gated predicate every REST meta + // read door uses (#9454), so the sweep and the read door move + // together if `page` ever declares org override (today it does + // not, and the predicate answers env-wide). + // - Disabled-package and conversion handling ride along for free — + // whatever `getMetaItems` withholds, this sweep never saw. + // + // So a page hit surfaces to a caller exactly what `GET /meta/page` + // already answers that caller — name, label, description — never + // more, and search is not a second read door. The page's own audience + // gate (`assignedProfiles`) is measured to have no backend consumer + // on the read door today; it is enforced where it is enforced now, at + // page render — the delegation posture direction 1's ruling recorded + // (a second enforcement point here would be a NEW authorization + // surface, the very thing the ruling's basis excludes). + // + // ## Matching and shape + // + // - AND of terms, OR of fields (`name`, every locale value of + // `label` / `description`), case-folded — the same term semantics + // the engine expansion applies to records, restated here because + // metadata is not engine rows and there is no expansion to + // delegate to. + // - Capped at `perObject`: the page store is swept as one more + // container, not competing with records for `limit`. + // - SCOPED sweeps skip pages entirely: `?objects=lead` asks for + // records of `lead`, and answering pages there would widen a + // request the caller deliberately narrowed. + // - A failed page read PROPAGATES (#8896's rule one seam over): + // `getMetaItems` already discriminates the benign + // store-unprovisioned case and raises everything else as a 503, so + // this sweep adds no `catch` — a partial scan must not wear a + // whole one's answer. + const pageHits: Array<{ kind: 'page'; name: string; title: string; snippet?: string; pageType?: string }> = []; + if (!objectsFilter) { + const pageOrgId = organizationIdForMetaRead('page', request.context?.tenantId); + const served = await this.getMetaItems({ + type: 'page', + ...(pageOrgId !== undefined ? { organizationId: pageOrgId } : {}), + }); + const pageItems: unknown[] = Array.isArray(served) ? served : served.items; + const localeTexts = (v: unknown): string[] => { + if (typeof v === 'string') return v ? [v] : []; + if (v && typeof v === 'object') { + return Object.values(v).filter((s): s is string => typeof s === 'string' && s !== ''); + } + return []; + }; + for (const rawItem of pageItems) { + if (pageHits.length >= perObject) break; + const page = rawItem as { name?: unknown; label?: unknown; description?: unknown; type?: unknown } | null; + if (!page || typeof page.name !== 'string' || !page.name) continue; + const descTexts = localeTexts(page.description); + const texts = [page.name.toLowerCase(), ...localeTexts(page.label).map(t => t.toLowerCase()), ...descTexts.map(t => t.toLowerCase())]; + const matched = allTerms.every((t) => { + const lt = t.toLowerCase(); + return texts.some((x) => x.includes(lt)); + }); + if (!matched) continue; + // Excerpt from the DESCRIPTION only: a name/label match is + // already fully visible in the title, so absence is a correct + // answer there (#7643's rule, restated for metadata). + let snippet: string | undefined; + for (const d of descTexts) { + snippet = cutExcerpt(d, allTerms); + if (snippet !== undefined) break; + } + // Title through the SHARED label resolution (#6765) — the + // default-locale chain (`en` → any available) rather than a + // fourth hand-rolled spelling; this route carries no locale. + const title = resolveI18nLabel(page.label as I18nLabel | undefined, undefined) || page.name; + pageHits.push({ + kind: 'page', + name: page.name, + title, + ...(snippet !== undefined ? { snippet } : {}), + ...(typeof page.type === 'string' && page.type ? { pageType: page.type } : {}), + }); + } + } + return { query: q, hits, + pages: pageHits, totalObjects: objectsScanned, totalHits: hits.length, truncated: hits.length >= overallLimit, diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 6abd2d0ce8..9e079dead9 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -349,7 +349,7 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ // ── search ──────────────────────────────────────────────────────────────── { route: 'GET /api/v1/search', family: 'search', source: 'route-manager', disposition: 'sdk', client: 'search', responseSchema: 'SearchAllResponseSchema', - note: '[#11924] answers BARE (res.json(result), no envelope), so the named schema is the whole body. ⚠️ NOT `SearchResult` — that exported contract types the per-object ISearchService.search (hits of score/document), the #8140 near-miss trap. Filled with its conformance coverage: search-clone-route-schema-conformance.test.ts drives this mount, and metadata-protocol\'s search-clone-schema-conformance.test.ts parses the real searchAll producer' }, + note: '[#11924] answers BARE (res.json(result), no envelope), so the named schema is the whole body. ⚠️ NOT `SearchResult` — that exported contract types the per-object ISearchService.search (hits of score/document), the #8140 near-miss trap. Filled with its conformance coverage: search-clone-route-schema-conformance.test.ts drives this mount, and metadata-protocol\'s search-clone-schema-conformance.test.ts parses the real searchAll producer. [#13216] the body additionally carries `pages` — published-page hits swept by the same producer through the caller\'s own meta read verb; still this one named schema, no handler change (bare relay)' }, // ── email ───────────────────────────────────────────────────────────────── { route: 'POST /api/v1/email/send', family: 'email', source: 'route-manager', disposition: 'sdk', client: 'email.send' }, diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 5b80d67d49..96f83ad374 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2180,6 +2180,53 @@ export const SearchAllHitSchema = lazySchema(() => z.object({ ), })); +/** + * One PAGE hit of the global cross-object search (#13216). + * + * A published custom page has an end-user entry point in the ⌘K palette: + * `searchAll` additionally sweeps the pages the caller's ordinary metadata + * read door already serves (`getMetaItems({ type: 'page' })` — published + * state only, org-scoped through the same registry-derived predicate the + * REST `/meta` read doors use), so a page hit surfaces exactly what + * `GET /api/v1/meta/page` would have answered the same caller — never more. + * Opening the hit goes through the existing page routes/renderer, where the + * page's own audience gate (`assignedProfiles`) applies unchanged; the + * search response is not a second read door. + * + * NOT a member of {@link SearchAllHitSchema}'s array: page hits live in the + * sibling `pages` array so an existing consumer iterating `hits` (every one + * of which is a record with an `object`/`id` address) never receives an + * element whose address vocabulary it predates. `kind` is the self-describing + * discriminant for clients that flatten both arrays into one palette list. + */ +export const SearchAllPageHitSchema = lazySchema(() => z.object({ + kind: z.literal('page').describe( + 'Discriminant, always the literal `page` — kept on the hit (despite the dedicated array) so ' + + 'a client merging record and page hits into one palette list still holds a self-describing ' + + 'element.' + ), + name: z.string().describe( + 'Machine name of the page (`page.name`) — the address a client routes to; the same name the ' + + 'metadata read door serves the page under.' + ), + title: z.string().describe( + 'Display title: the page `label` resolved through the shared i18n label resolution ' + + '(exact tag → base language → regional sibling → `default` → `en` → any), falling back to ' + + 'the machine name when no label resolves.' + ), + snippet: z.string().optional().describe( + 'Excerpt cut around the first matched term in the page `description`, ellipsized at both ' + + 'ends when truncated — the same geometry as a record hit\'s snippet. ABSENT when the match ' + + 'is on the name or label only (the title already shows it) — absence is a correct answer, ' + + 'not a miss.' + ), + pageType: z.string().optional().describe( + 'The page\'s own declared `type` (`record` | `app` | `home` | …) when the served document ' + + 'carries one — a routing hint, deliberately typed as a plain string so a future page type ' + + 'cannot invalidate an already-produced search response.' + ), +})); + /** * Global Cross-Object Search Response (#11924) * @@ -2191,9 +2238,11 @@ export const SearchAllHitSchema = lazySchema(() => z.object({ * Declared AS PRODUCED (maintainer ruling 2026-08-25 on #11924: the shape is * stable and server-produced). One query sweeps every searchable, * API-enabled object the caller can read (ADR-0061 Tier 1: the server - * resolves which fields to search from object metadata); an empty/blank `q` - * short-circuits to `{ query: '', hits: [], totalObjects: 0, totalHits: 0, - * truncated: false }` without scanning. + * resolves which fields to search from object metadata) — and, on an + * unscoped sweep, the published pages the caller's metadata read door serves + * (#13216, {@link SearchAllPageHitSchema}); an empty/blank `q` + * short-circuits to `{ query: '', hits: [], pages: [], totalObjects: 0, + * totalHits: 0, truncated: false }` without scanning. */ export const SearchAllResponseSchema = lazySchema(() => z.object({ query: z.string().describe( @@ -2204,6 +2253,15 @@ export const SearchAllResponseSchema = lazySchema(() => z.object({ 'Matched records across objects, in scan order, capped at the overall `limit` ' + '(default 20, max 100) with at most `perObject` (default 5, max 25) per object.' ), + pages: z.array(SearchAllPageHitSchema).describe( + 'Published pages whose name, label, or description matches every term (each term may match ' + + 'in a different field; matching folds case), in the served listing\'s order, capped at ' + + '`perObject` — the page store is swept as one more container, and more matching pages may ' + + 'exist beyond the cap. Produced ONLY on an unscoped sweep: a request that names `objects` ' + + 'asks for records of those objects and answers `pages: []`. The swept set is exactly what ' + + 'the caller\'s metadata read door (`GET /api/v1/meta/page`) serves — published state only, ' + + 'never drafts (#13216).' + ), totalObjects: z.number().describe( 'Number of objects the sweep actually SCANNED (searchable, API-enabled, with a ' + 'resolvable search-field set) — not the number of objects with hits. An object ' @@ -3126,6 +3184,8 @@ export type DeleteDataResponse = z.input; * — see {@link SearchAllHitSchema} for the trap. */ export type SearchAllHit = z.input; +/** One published-page hit of the same body (#13216) — see {@link SearchAllPageHitSchema}. */ +export type SearchAllPageHit = z.input; export type SearchAllResponse = z.input; export type BatchDataRequest = z.input; diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 0fc9ac3f8e..e2830d0680 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -269,7 +269,7 @@ import type * as M170 from './ui/component.zod.js'; import type * as M183 from './api/sortability.zod.js'; // --------------------------------------------------------------------------- -// 835 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 836 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -500,6 +500,7 @@ export type Iso146 = Assert, export type Iso147 = Assert, z.infer< typeof M28.DeleteDataResponseSchema > >>; export type Iso859 = Assert, z.infer< typeof M28.CloneDataResponseSchema > >>; export type Iso860 = Assert, z.infer< typeof M28.SearchAllHitSchema > >>; +export type Iso866 = Assert, z.infer< typeof M28.SearchAllPageHitSchema > >>; export type Iso861 = Assert, z.infer< typeof M28.SearchAllResponseSchema > >>; export type Iso148 = Assert, z.infer< typeof M28.CreateManyDataResponseSchema > >>; export type Iso150 = Assert, z.infer< typeof M28.CheckPermissionResponseSchema > >>; @@ -1681,7 +1682,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 835 isomorphic pins', () => { + it('still declares all 836 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2057,9 +2058,19 @@ describe('ADR-0122 type-alias convention', () => { // the platform-checked event vocabulary is the closed `DataEventType` / // `BulkDataEventType` enums. -1 removed; the Iso number stays vacant // (ids are claims about pins, not positions). + // + // 835 -> 836 is #13216's `SearchAllPageHitSchema` — the published-page + // hit of the global-search body (`pages`, the sibling array of + // `SearchAllHitSchema`'s record hits), declared as produced like its + // #11924 siblings one family up. Isomorphism MEASURED, not assumed: one + // `z.literal('page')`, two required `z.string()`s and two optional + // `z.string()`s — no `.default()`, `.transform()`, `.catch()` or + // `.pipe()` anywhere, so the two shapes coincide and ADR-0122 gives it a + // pin rather than an `XParsed`. Its id is `Iso866`, the next free one — + // ids are claims about pins, not positions. const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert Date: Tue, 1 Sep 2026 13:53:01 +0000 Subject: [PATCH 2/5] test(metadata-protocol,rest): page-sweep coverage + fixture surface; regen spec artifacts; changeset Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1 --- .changeset/search-published-pages-hit-kind.md | 50 +++ content/docs/references/api/protocol.mdx | 30 +- content/docs/references/index.mdx | 10 +- ...07-unknown-key-strictness-ledger.counts.md | 2 +- .../src/protocol.orderby-vocabulary.test.ts | 4 +- ...otocol.read-seam-empty-accumulator.test.ts | 16 +- .../src/protocol.search-case-fold.test.ts | 7 +- .../protocol.search-published-pages.test.ts | 307 ++++++++++++++++++ .../protocol.search-title-namefield.test.ts | 3 + .../search-clone-schema-conformance.test.ts | 45 ++- ...covery-search-capability-agreement.test.ts | 7 +- ...rch-clone-route-schema-conformance.test.ts | 11 + packages/spec/api-surface/api.json | 2 + packages/spec/authorable-surface/api.json | 6 + packages/spec/declaration-map/api.json | 2 + packages/spec/export-origins/api.json | 2 + packages/spec/json-schema.manifest/api.json | 1 + 17 files changed, 486 insertions(+), 19 deletions(-) create mode 100644 .changeset/search-published-pages-hit-kind.md create mode 100644 packages/metadata-protocol/src/protocol.search-published-pages.test.ts diff --git a/.changeset/search-published-pages-hit-kind.md b/.changeset/search-published-pages-hit-kind.md new file mode 100644 index 0000000000..1bb1d91bcd --- /dev/null +++ b/.changeset/search-published-pages-hit-kind.md @@ -0,0 +1,50 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": minor +--- + +feat(spec,metadata-protocol): a page hit kind on `GET /api/v1/search` — the command palette indexes published pages (#13216) + +Direction 3 of #13216, the complement the 2026-08-29 maintainer ruling adopted +beside the landed direction 1 (#13372): a custom page created and published at +runtime rendered perfectly and was absent from the ⌘K palette door alone +(#13100 measured it) — `searchAll` swept object RECORDS and nothing else, so +the artifact an agent grew into a running app was reachable by direct URL only. + +**The widened body.** `SearchAllResponseSchema` gains a required `pages` +member — an array of the new `SearchAllPageHitSchema` +(`{ kind: 'page', name, title, snippet?, pageType? }`), declared as produced +like its #11924 siblings. Page hits are deliberately a SIBLING array, never +members of `hits`: every `hits` element is a record with an `object`/`id` +address, and an existing consumer iterating it must not receive an element +whose address vocabulary it predates. `kind: 'page'` is the self-describing +discriminant for clients that flatten both arrays into one palette list. The +blank-query short-circuit carries `pages: []`. + +**Zero new authorization surface — the swept set IS the served set.** Pages +reach the sweep through `getMetaItems({ type: 'page' })`, the same verb the +REST `GET /meta/page` list door serves, org-scoped through the same +registry-derived predicate every REST meta read door uses +(`organizationIdForMetaRead`, #9454). Published (`state: 'active'`) items +only — drafts surface exclusively under `previewDrafts`, which the sweep +never passes — and whatever the read door withholds (a disabled package's +pages included) the sweep never saw. A page hit therefore surfaces to a +caller exactly what that caller's own meta read door already answers — name, +label, description — never more; opening the hit goes through the existing +page routes/renderer, where the page's own audience gate +(`assignedProfiles`) applies unchanged. Search is not a second read door, +and no `allowOrgOverride` / `allowRuntimeCreate` flag moves. + +**Matching and caps.** AND of terms, OR of fields (name, every locale value +of label/description), case-folded — the record sweep's term semantics, +restated for metadata because there is no engine expansion to delegate to. +Page hits cap at `perObject` (the page store is one more scanned container, +not a competitor for `limit`), titles resolve through the shared +`resolveI18nLabel` chain, and the snippet is cut from the description with +the same excerpt geometry as record hits. A SCOPED sweep (`?objects=…`) +answers `pages: []` — it asks for records of those objects. A failed page +read propagates (#8896's rule): a partial scan must not wear a whole one's +answer. + +No REST handler change — `GET /api/v1/search` relays the producer's return +bare, exactly as before, and no request parameter is added. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index bddc560233..8764418677 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -12,8 +12,8 @@ description: Protocol protocol schemas ## TypeScript Usage ```typescript -import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; -import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CloneDataResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; +import { AiAgentCapabilitiesSchema, AiAgentChatRequestSchema, AiAgentSummarySchema, AiAgentsResponseSchema, AiChatRequestSchema, AiChatResponseSchema, AiCompleteRequestSchema, AiConversationSchema, AiMessageSchema, AiModelsResponseSchema, AiPendingActionSchema, AiPendingActionStatusSchema, AiStreamChunkSchema, ApproveAiPendingActionResponseSchema, AuditMetaItemRequestSchema, AuditMetaItemResponseSchema, AutomationActionsResponseSchema, AutomationTriggerRequestSchema, AutomationTriggerResponseSchema, BatchDataRequestSchema, BatchDataResponseSchema, CheckPermissionRequestSchema, CheckPermissionResponseSchema, CloneDataResponseSchema, CreateAiConversationRequestSchema, CreateDataRequestSchema, CreateDataResponseSchema, CreateManyDataRequestSchema, CreateManyDataResponseSchema, DeleteDataRequestSchema, DeleteDataResponseSchema, DeleteManyDataRequestSchema, DeleteManyDataResponseSchema, DeleteMetaItemRequestSchema, DeleteMetaItemResponseSchema, DiffMetaItemResponseSchema, DisablePackageRequestSchema, DisablePackageResponseSchema, EnablePackageRequestSchema, EnablePackageResponseSchema, FindDataRequestSchema, FindDataResponseSchema, FindReferencesToMetaResponseSchema, GetDataRequestSchema, GetDataResponseSchema, GetDiscoveryRequestSchema, GetDiscoveryResponseSchema, GetEffectivePermissionsRequestSchema, GetEffectivePermissionsResponseSchema, GetFieldLabelsRequestSchema, GetFieldLabelsResponseSchema, GetLocalesRequestSchema, GetLocalesResponseSchema, GetMetaDiagnosticsResponseSchema, GetMetaItemCachedRequestSchema, GetMetaItemCachedResponseSchema, GetMetaItemLayeredRequestSchema, GetMetaItemLayeredResponseSchema, GetMetaItemRequestSchema, GetMetaItemResponseSchema, GetMetaItemsRequestSchema, GetMetaItemsResponseSchema, GetMetaTypesRequestSchema, GetMetaTypesResponseSchema, GetNotificationPreferencesRequestSchema, GetNotificationPreferencesResponseSchema, GetObjectPermissionsRequestSchema, GetObjectPermissionsResponseSchema, GetPackageRequestSchema, GetPackageResponseSchema, GetPresenceRequestSchema, GetPresenceResponseSchema, GetPublishedMetaItemResponseSchema, GetTranslationsRequestSchema, GetTranslationsResponseSchema, GetUiViewRequestSchema, GetUiViewResponseSchema, HistoryMetaItemRequestSchema, HistoryMetaItemResponseSchema, HttpFindQueryParamsSchema, InstallPackageRequestSchema, InstallPackageResponseSchema, ListAiConversationsRequestSchema, ListAiConversationsResponseSchema, ListAiPendingActionsRequestSchema, ListAiPendingActionsResponseSchema, ListDraftsResponseSchema, ListNotificationsRequestSchema, ListNotificationsResponseSchema, ListPackagesRequestSchema, ListPackagesResponseSchema, MarkAllNotificationsReadRequestSchema, MarkAllNotificationsReadResponseSchema, MarkNotificationsReadRequestSchema, MarkNotificationsReadResponseSchema, NotificationSchema, NotificationPreferencesSchema, PublishMetaItemRequestSchema, PublishMetaItemResponseSchema, PublishPackageDraftsResponseSchema, RealtimeConnectRequestSchema, RealtimeConnectResponseSchema, RealtimeDisconnectRequestSchema, RealtimeDisconnectResponseSchema, RealtimeSubscribeRequestSchema, RealtimeSubscribeResponseSchema, RealtimeUnsubscribeRequestSchema, RealtimeUnsubscribeResponseSchema, RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, RejectAiPendingActionResponseSchema, RollbackMetaItemResponseSchema, RuntimeAuthoringIssueSchema, SaveMetaItemRequestSchema, SaveMetaItemResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema, SetPresenceRequestSchema, SetPresenceResponseSchema, UninstallPackageRequestSchema, UninstallPackageResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UpdateAiConversationRequestSchema, UpdateDataRequestSchema, UpdateDataResponseSchema, UpdateManyDataRequestSchema, UpdateManyDataResponseSchema, UpdateNotificationPreferencesRequestSchema, UpdateNotificationPreferencesResponseSchema, ValidateDataIssueSchema, ValidateDataRequestSchema, ValidateDataResponseSchema } from '@objectstack/spec/api'; +import type { AiAgentCapabilities, AiAgentChatRequest, AiAgentSummary, AiAgentsResponse, AiChatRequest, AiChatResponse, AiCompleteRequest, AiConversation, AiMessage, AiModelsResponse, AiPendingAction, AiPendingActionStatus, AiStreamChunk, ApproveAiPendingActionResponse, AuditMetaItemRequest, AuditMetaItemResponse, AutomationActionsResponse, AutomationTriggerRequest, AutomationTriggerResponse, BatchDataRequest, BatchDataResponse, CheckPermissionRequest, CheckPermissionResponse, CloneDataResponse, CreateAiConversationRequest, CreateDataRequest, CreateDataResponse, CreateManyDataRequest, CreateManyDataResponse, DeleteDataRequest, DeleteDataResponse, DeleteManyDataRequest, DeleteManyDataResponse, DeleteMetaItemRequest, DeleteMetaItemResponse, DiffMetaItemResponse, DisablePackageRequest, DisablePackageResponse, EnablePackageRequest, EnablePackageResponse, FindDataRequest, FindDataResponse, FindReferencesToMetaResponse, GetDataRequest, GetDataResponse, GetDiscoveryRequest, GetDiscoveryResponse, GetEffectivePermissionsRequest, GetEffectivePermissionsResponse, GetFieldLabelsRequest, GetFieldLabelsResponse, GetLocalesRequest, GetLocalesResponse, GetMetaDiagnosticsResponse, GetMetaItemCachedRequest, GetMetaItemCachedResponse, GetMetaItemLayeredRequest, GetMetaItemLayeredResponse, GetMetaItemRequest, GetMetaItemResponse, GetMetaItemsRequest, GetMetaItemsResponse, GetMetaTypesRequest, GetMetaTypesResponse, GetNotificationPreferencesRequest, GetNotificationPreferencesResponse, GetObjectPermissionsRequest, GetObjectPermissionsResponse, GetPackageRequest, GetPackageResponse, GetPresenceRequest, GetPresenceResponse, GetPublishedMetaItemResponse, GetTranslationsRequest, GetTranslationsResponse, GetUiViewRequest, GetUiViewResponse, HistoryMetaItemRequest, HistoryMetaItemResponse, InstallPackageRequest, InstallPackageResponse, ListAiConversationsRequest, ListAiConversationsResponse, ListAiPendingActionsRequest, ListAiPendingActionsResponse, ListDraftsResponse, ListNotificationsRequest, ListNotificationsResponse, ListPackagesRequest, ListPackagesResponse, MarkAllNotificationsReadRequest, MarkAllNotificationsReadResponse, MarkNotificationsReadRequest, MarkNotificationsReadResponse, Notification, NotificationPreferences, PublishMetaItemRequest, PublishMetaItemResponse, PublishPackageDraftsResponse, RealtimeConnectRequest, RealtimeConnectResponse, RealtimeDisconnectRequest, RealtimeDisconnectResponse, RealtimeSubscribeRequest, RealtimeSubscribeResponse, RealtimeUnsubscribeRequest, RealtimeUnsubscribeResponse, RegisterDeviceRequest, RegisterDeviceResponse, RejectAiPendingActionResponse, RollbackMetaItemResponse, RuntimeAuthoringIssue, SaveMetaItemRequest, SaveMetaItemResponse, SearchAllHit, SearchAllPageHit, SearchAllResponse, SetPresenceRequest, SetPresenceResponse, UninstallPackageRequest, UninstallPackageResponse, UnregisterDeviceRequest, UnregisterDeviceResponse, UpdateAiConversationRequest, UpdateDataRequest, UpdateDataResponse, UpdateManyDataRequest, UpdateManyDataResponse, UpdateNotificationPreferencesRequest, UpdateNotificationPreferencesResponse, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; // Validate data const result = AiAgentCapabilitiesSchema.parse(data); @@ -2582,6 +2582,21 @@ Installed package with runtime lifecycle state | **record** | `Record` | ✅ | The matched record as the engine's find path returns it (row-level security applied, internal fields already stripped). Object-specific — no cross-object field shape is promised beyond "a record of the named object". | +--- + +## SearchAllPageHit + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **kind** | `'page'` | ✅ | Discriminant, always the literal `page` — kept on the hit (despite the dedicated array) so a client merging record and page hits into one palette list still holds a self-describing element. | +| **name** | `string` | ✅ | Machine name of the page (`page.name`) — the address a client routes to; the same name the metadata read door serves the page under. | +| **title** | `string` | ✅ | Display title: the page `label` resolved through the shared i18n label resolution (exact tag → base language → regional sibling → `default` → `en` → any), falling back to the machine name when no label resolves. | +| **snippet** | `string` | optional | Excerpt cut around the first matched term in the page `description`, ellipsized at both ends when truncated — the same geometry as a record hit's snippet. ABSENT when the match is on the name or label only (the title already shows it) — absence is a correct answer, not a miss. | +| **pageType** | `string` | optional | The page's own declared `type` (`record` \| `app` \| `home` \| …) when the served document carries one — a routing hint, deliberately typed as a plain string so a future page type cannot invalidate an already-produced search response. | + + --- ## SearchAllResponse @@ -2592,6 +2607,7 @@ Installed package with runtime lifecycle state | :--- | :--- | :--- | :--- | | **query** | `string` | ✅ | The TRIMMED query text the sweep ran with — empty string when the request carried none (the no-scan short-circuit). | | **hits** | `{ object: string; id: string; title: string; snippet?: string; … }[]` | ✅ | Matched records across objects, in scan order, capped at the overall `limit` (default 20, max 100) with at most `perObject` (default 5, max 25) per object. | +| **pages** | `{ kind: 'page'; name: string; title: string; snippet?: string; … }[]` | ✅ | Published pages whose name, label, or description matches every term (each term may match in a different field; matching folds case), in the served listing's order, capped at `perObject` — the page store is swept as one more container, and more matching pages may exist beyond the cap. Produced ONLY on an unscoped sweep: a request that names `objects` asks for records of those objects and answers `pages: []`. The swept set is exactly what the caller's metadata read door (`GET /api/v1/meta/page`) serves — published state only, never drafts (#13216). | | **totalObjects** | `number` | ✅ | Number of objects the sweep actually SCANNED (searchable, API-enabled, with a resolvable search-field set) — not the number of objects with hits. An object whose table was never provisioned is skipped and not counted. | | **totalHits** | `number` | ✅ | Number of hits returned — equals `hits.length`. NOT a deployment-wide total-match count: matches beyond `limit` / `perObject` are not counted. | | **truncated** | `boolean` | ✅ | True when the sweep stopped at the overall `limit` — more matches may exist beyond the returned set. | @@ -2606,6 +2622,16 @@ Installed package with runtime lifecycle state | **snippet** | `string` | optional | Excerpt cut around the first matched term in a searchable text column, ellipsized at both ends when truncated. ABSENT when no source column literally contains a term (e.g. a pinyin companion match) — absence is a correct answer, not a miss. | | **record** | `Record` | ✅ | The matched record as the engine's find path returns it (row-level security applied, internal fields already stripped). Object-specific — no cross-object field shape is promised beyond "a record of the named object". | +### Nested Shape: `SearchAllResponse.pages[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **kind** | `'page'` | ✅ | Discriminant, always the literal `page` — kept on the hit (despite the dedicated array) so a client merging record and page hits into one palette list still holds a self-describing element. | +| **name** | `string` | ✅ | Machine name of the page (`page.name`) — the address a client routes to; the same name the metadata read door serves the page under. | +| **title** | `string` | ✅ | Display title: the page `label` resolved through the shared i18n label resolution (exact tag → base language → regional sibling → `default` → `en` → any), falling back to the machine name when no label resolves. | +| **snippet** | `string` | optional | Excerpt cut around the first matched term in the page `description`, ellipsized at both ends when truncated — the same geometry as a record hit's snippet. ABSENT when the match is on the name or label only (the title already shows it) — absence is a correct answer, not a miss. | +| **pageType** | `string` | optional | The page's own declared `type` (`record` \| `app` \| `home` \| …) when the served document carries one — a routing hint, deliberately typed as a plain string so a future page type cannot invalidate an already-produced search response. | + --- diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 0182cb8fd3..abba2d6f2c 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1591 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1592 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -20,7 +20,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 11 | 66 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 31 | 439 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 31 | 440 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 68 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. | | [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 291 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 153 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **199** | **1591** | 14 protocol modules | +| **Total** | **199** | **1592** | 14 protocol modules | --- @@ -61,7 +61,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **31 pages, 439 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **31 pages, 440 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -88,7 +88,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`package-api.zod.ts`](/docs/references/api/package-api) | `GetInstalledPackageRequest`, `GetInstalledPackageResponse`, `ListInstalledPackagesRequest`, `ListInstalledPackagesResponse`, `PackageApiErrorCode`, `PackageInstallRequest`, `PackageInstallResponse`, `PackagePathParams`, `PackageRollbackRequest`, `PackageUpgradeRequest`, `PackageUpgradeResponse`, `ResolveDependenciesRequest`, `ResolveDependenciesResponse`, `UninstallPackageApiRequest`, `UninstallPackageApiResponse`, `UploadArtifactRequest`, `UploadArtifactResponse` | | [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `ListPackageCommitsResponse`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` | | [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `HandlerStatus`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `RouteCoverageEntry`, `RouteCoverageReport`, `ValidationMode` | -| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | +| [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `Notification`, `NotificationPreferences`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | | [`query-adapter.zod.ts`](/docs/references/api/query-adapter) | `ODataQueryAdapter`, `OperatorMapping`, `QueryAdapterConfig`, `QueryAdapterTarget`, `RestQueryAdapter` | | [`realtime.zod.ts`](/docs/references/api/realtime) | `RealtimeConfig`, `RealtimeEvent`, `RealtimeEventType`, `RealtimePresence`, `Subscription`, `SubscriptionEvent`, `TransportProtocol` | | [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index cef0c1ffb6..0fe672c84f 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -257,7 +257,7 @@ directory rather than per file. | Dir | Sites | |---|---| | `ai/` | 77 | -| `api/` | 454 | +| `api/` | 455 | | `cloud/` | 83 | | `identity/` | 32 | | `integration/` | 10 | diff --git a/packages/metadata-protocol/src/protocol.orderby-vocabulary.test.ts b/packages/metadata-protocol/src/protocol.orderby-vocabulary.test.ts index 668ddd30dd..dd442d5899 100644 --- a/packages/metadata-protocol/src/protocol.orderby-vocabulary.test.ts +++ b/packages/metadata-protocol/src/protocol.orderby-vocabulary.test.ts @@ -120,7 +120,9 @@ describe('searchAll sorts newest-first (#4674)', () => { function makeProtocol() { const find = makeFind({ contact: SEARCH_ROWS }); const engine = { - registry: { getObject: (n: string) => (n === 'contact' ? CONTACT : undefined), getAllObjects: () => [CONTACT] }, + // [#13216] `listItems: () => []` — the page sweep's registry read; + // no pages here, sort vocabulary is the only thing under test. + registry: { getObject: (n: string) => (n === 'contact' ? CONTACT : undefined), getAllObjects: () => [CONTACT], listItems: () => [] }, find, }; return { p: new ObjectStackProtocolImplementation(engine as any), find }; diff --git a/packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts b/packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts index 76a1d800e9..2ffe326c06 100644 --- a/packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts +++ b/packages/metadata-protocol/src/protocol.read-seam-empty-accumulator.test.ts @@ -104,13 +104,16 @@ describe('[#8896] searchAll — an object that could not be READ is not an objec const acct = objectFixture('acct'); const lead = objectFixture('lead'); - /** `acct` always answers with one matching row; `lead`'s read is the variable. */ + /** `acct` always answers with one matching row; `lead`'s read is the variable. + * ([#13216] `sys_metadata` — the page sweep's overlay read — answers + * empty, so the record seam stays the only variable.) */ function engineWhereLeadFails(error: unknown) { const readCalls: string[] = []; const engine = { registry: fixtureRegistry([acct, lead]), find: vi.fn(async (object: string) => { readCalls.push(object); + if (object === 'sys_metadata') return []; if (object === 'lead') throw error; return [{ id: 'a1', name: 'Acme' }]; }), @@ -123,7 +126,8 @@ describe('[#8896] searchAll — an object that could not be READ is not an objec const engine = { registry: fixtureRegistry([acct, lead]), find: vi.fn(async (object: string) => ( - object === 'acct' ? [{ id: 'a1', name: 'Acme' }] : [{ id: 'l1', name: 'Acme Lead' }] + object === 'sys_metadata' ? [] + : object === 'acct' ? [{ id: 'a1', name: 'Acme' }] : [{ id: 'l1', name: 'Acme Lead' }] )), findOne: vi.fn(async (object: string, query?: EngineFindOneQueryInput) => { assertEngineFindOnePredicate(object, query); return null; }), }; @@ -278,7 +282,9 @@ describe('[#11754] searchAll — a registry that cannot ENUMERATE is not a regis const result = await protocol.searchAll({ q: 'Acme' }); - expect(result).toEqual({ query: 'Acme', hits: [], totalObjects: 0, totalHits: 0, truncated: false }); + // [#13216] `pages: []` joined the body when the published-page sweep + // landed — updated in place, the emptiness claims are unchanged. + expect(result).toEqual({ query: 'Acme', hits: [], pages: [], totalObjects: 0, totalHits: 0, truncated: false }); // Proof the emptiness was SAID by the registry, not invented past it. expect(engine.registry.getAllObjects).toHaveBeenCalledTimes(1); }); @@ -300,7 +306,9 @@ describe('[#11754] searchAll — a registry that cannot ENUMERATE is not a regis const result = await protocol.searchAll({ q: ' ' }); - expect(result).toEqual({ query: '', hits: [], totalObjects: 0, totalHits: 0, truncated: false }); + // [#13216] `pages: []` joined the short-circuit body too — same + // no-scan claim, one more empty member. + expect(result).toEqual({ query: '', hits: [], pages: [], totalObjects: 0, totalHits: 0, truncated: false }); }); }); diff --git a/packages/metadata-protocol/src/protocol.search-case-fold.test.ts b/packages/metadata-protocol/src/protocol.search-case-fold.test.ts index c2f4030a4d..a6d3f4f818 100644 --- a/packages/metadata-protocol/src/protocol.search-case-fold.test.ts +++ b/packages/metadata-protocol/src/protocol.search-case-fold.test.ts @@ -66,12 +66,15 @@ function makeProtocol(): { } { // No filtering and no `$search` expansion: this double stands in for the // engine BOUNDARY, not for the engine. What is under test is what the - // protocol hands across it. - const find = vi.fn(async (_object: string, _opts: FindOptions = {}) => ROWS); + // protocol hands across it. ([#13216] `sys_metadata` answers empty — the + // page sweep's overlay read — so the record delegation stays the only + // thing measured; the registry lists no pages either.) + const find = vi.fn(async (object: string, _opts: FindOptions = {}) => (object === 'sys_metadata' ? [] : ROWS)); const engine = { registry: { getObject: (n: string) => (n === 'contact' ? CONTACT : undefined), getAllObjects: () => [CONTACT], + listItems: () => [], }, find, }; diff --git a/packages/metadata-protocol/src/protocol.search-published-pages.test.ts b/packages/metadata-protocol/src/protocol.search-published-pages.test.ts new file mode 100644 index 0000000000..3f4cc826a1 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.search-published-pages.test.ts @@ -0,0 +1,307 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#13216] `searchAll` sweeps PUBLISHED PAGES into the sibling `pages` array — +// direction 3 of the card, per the 2026-08-29 maintainer ruling (directions +// 1 + 3 adopted): a custom page created and published at runtime rendered +// perfectly and was absent from the ⌘K palette door alone (#13100 measured +// it), so the artifact an agent grew into a running app was reachable by +// direct URL only. +// +// ## What this file pins, and why each pin is here +// +// 1. THE SWEPT SET IS THE SERVED SET. Pages reach the sweep through +// `getMetaItems({ type: 'page' })` — the same verb the REST +// `GET /meta/page` list door serves — so whatever that door withholds +// (draft rows, disabled-package items) the sweep never saw. That single +// decision is the zero-new-authorization-surface basis the ruling +// requires: search surfaces to a caller exactly what the caller's own +// meta read door already answers, never more, and is not a second read +// door. Pinned from both directions: a published (state `'active'`) +// stored row surfaces, a draft row of the same shape does not, and a +// disabled package's code-registered page does not. +// 2. RECORD HITS ARE UNTOUCHED. Page hits are a SIBLING array, never +// members of `hits` — an existing consumer iterating `hits` (each one a +// record with an `object`/`id` address) must not meet an element whose +// address vocabulary it predates. +// 3. SCOPED SWEEPS SKIP PAGES. `?objects=lead` asks for records of `lead`; +// answering pages there would widen a request the caller narrowed. +// 4. TERM SEMANTICS match the record sweep's: AND of terms, OR of fields +// (name, every locale value of label/description), case-folded. +// 5. A FAILED PAGE READ PROPAGATES (#8896 one seam over): `getMetaItems` +// raises a real store outage as a 503, and the sweep adds no `catch` — a +// partial scan must not wear a whole one's answer. + +import { describe, it, expect, vi } from 'vitest'; +import { SearchAllPageHitSchema, SearchAllResponseSchema } from '@objectstack/spec/api'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const text = (name: string) => ({ name, type: 'text' }); + +interface StoredRow { + id: string; + type: string; + name: string; + state: 'active' | 'draft'; + organization_id: string | null; + package_id: string | null; + metadata: string; +} + +interface EngineOptions { + /** Code-registered pages, served by `registry.listItems('page')`. */ + codePages?: Array>; + /** `sys_metadata` rows, filtered honestly on `type`/`state`/`organization_id`. */ + storedRows?: StoredRow[]; + /** Package ids the registry reports disabled. */ + disabledPackages?: string[]; + /** Rows served per data object (the record sweep's half). */ + rowsByObject?: Record; + /** Data objects the registry enumerates. */ + objects?: Array & { name: string }>; +} + +/** + * A protocol over an engine double whose `sys_metadata` face is HONEST: rows + * come back only when they match the queried `type` / `state` / + * `organization_id` — because the draft-exclusion pin below is a claim about + * the read the sweep performs, and a double that ignored `where.state` would + * measure the double. + */ +function makeEngine(opts: EngineOptions = {}) { + const objects = opts.objects ?? []; + const disabled = new Set(opts.disabledPackages ?? []); + const registry = { + getObject: (n: string) => objects.find((o) => o.name === n), + getAllObjects: () => objects, + listItems: (type: string) => (type === 'page' ? (opts.codePages ?? []) : []), + getItem: () => undefined, + applyNavContributions: (x: unknown) => x, + isPackageDisabled: (p: unknown) => typeof p === 'string' && disabled.has(p), + }; + const find = vi.fn(async (object: string, q?: { where?: Record }) => { + if (object === 'sys_metadata') { + const where = q?.where ?? {}; + return (opts.storedRows ?? []).filter((r) => + r.type === where.type + && r.state === where.state + && (r.organization_id ?? null) === ((where.organization_id ?? null) as string | null)); + } + return opts.rowsByObject?.[object] ?? []; + }); + return { engine: { registry, find }, find }; +} + +function makeProtocol(opts: EngineOptions = {}) { + const { engine, find } = makeEngine(opts); + return { p: new ObjectStackProtocolImplementation(engine as never), find }; +} + +const storedPage = (over: Partial & { body: Record }): StoredRow => ({ + id: `row_${String(over.body.name ?? 'page')}`, + type: 'page', + name: String(over.body.name ?? 'page'), + state: 'active', + organization_id: null, + package_id: null, + metadata: JSON.stringify(over.body), + ...over, +}); + +describe('[#13216] searchAll sweeps published pages into `pages`', () => { + it('a runtime-published (state active) page surfaces, with the declared hit shape', async () => { + const { p } = makeProtocol({ + storedRows: [storedPage({ + body: { + name: 'sales_dashboard', + label: 'Sales Dashboard', + description: 'Quarterly pipeline overview for the sales team', + type: 'app', + kind: 'react', + }, + })], + }); + + const result = await p.searchAll({ q: 'sales' }); + + expect(result.pages).toHaveLength(1); + const hit = result.pages[0]; + expect(hit).toMatchObject({ kind: 'page', name: 'sales_dashboard', title: 'Sales Dashboard', pageType: 'app' }); + const parsed = SearchAllPageHitSchema.safeParse(hit); + expect(parsed.error?.issues ?? []).toEqual([]); + // The whole body still parses against the widened response schema. + const body = SearchAllResponseSchema.safeParse(JSON.parse(JSON.stringify(result))); + expect(body.error?.issues ?? []).toEqual([]); + }); + + it('a DRAFT row of the same shape does not surface — the sweep reads the published state only', async () => { + const body = { + name: 'pending_dashboard', + label: 'Pending Dashboard', + type: 'app', + }; + const { p, find } = makeProtocol({ + storedRows: [storedPage({ body, state: 'draft', name: 'pending_dashboard' })], + }); + + const result = await p.searchAll({ q: 'pending' }); + + expect(result.pages).toEqual([]); + // Discriminating control: the read RAN (the door was asked), and the + // same row published IS a hit — so the emptiness above is the state + // filter, not a sweep that never looked. + expect(find.mock.calls.some(([o]) => o === 'sys_metadata')).toBe(true); + const { p: published } = makeProtocol({ storedRows: [storedPage({ body })] }); + expect((await published.searchAll({ q: 'pending' })).pages).toHaveLength(1); + }); + + it("a DISABLED package's page does not surface — whatever the meta read door withholds, the sweep never saw", async () => { + const page = { name: 'pkg_home', label: 'Package Home', type: 'app', _packageId: 'pkg_off' }; + const { p } = makeProtocol({ codePages: [page], disabledPackages: ['pkg_off'] }); + expect((await p.searchAll({ q: 'package' })).pages).toEqual([]); + + // Positive control — the same page with its package enabled surfaces, + // so the emptiness above is the door's withholding, not a dead sweep. + const { p: enabled } = makeProtocol({ codePages: [page] }); + expect((await enabled.searchAll({ q: 'package' })).pages).toHaveLength(1); + }); + + it('record hits are untouched beside page hits — and carry no `kind`', async () => { + const lead = { name: 'lead', fields: { name: text('name') } }; + const { p } = makeProtocol({ + objects: [lead], + rowsByObject: { lead: [{ id: 'l1', name: 'Acme Industrial' }] }, + storedRows: [storedPage({ body: { name: 'acme_home', label: 'Acme Home', type: 'app' } })], + }); + + const result = await p.searchAll({ q: 'acme' }); + + expect(result.hits.map((h) => h.object)).toEqual(['lead']); + expect(Object.keys(result.hits[0])).not.toContain('kind'); + expect(result.pages.map((h) => h.name)).toEqual(['acme_home']); + // The record-side counters keep their record-only meaning. + expect(result.totalHits).toBe(1); + expect(result.totalObjects).toBe(1); + }); + + it('a SCOPED sweep (`objects`) answers `pages: []` even when a page matches', async () => { + const lead = { name: 'lead', fields: { name: text('name') } }; + const { p, find } = makeProtocol({ + objects: [lead], + rowsByObject: { lead: [{ id: 'l1', name: 'Acme Industrial' }] }, + storedRows: [storedPage({ body: { name: 'acme_home', label: 'Acme Home' } })], + }); + + const result = await p.searchAll({ q: 'acme', objects: ['lead'] }); + + expect(result.hits).toHaveLength(1); + expect(result.pages).toEqual([]); + // Skipped, not filtered-after-reading: the page read never ran. + expect(find.mock.calls.some(([o]) => o === 'sys_metadata')).toBe(false); + }); + + it('the blank-query short-circuit carries `pages: []` and still scans nothing', async () => { + const { p, find } = makeProtocol({ + storedRows: [storedPage({ body: { name: 'anything', label: 'Anything' } })], + }); + const result = await p.searchAll({ q: ' ' }); + expect(result).toEqual({ query: '', hits: [], pages: [], totalObjects: 0, totalHits: 0, truncated: false }); + expect(find).not.toHaveBeenCalled(); + }); + + it('terms AND across fields, case-folded: each term may match a different field', async () => { + const { p } = makeProtocol({ + storedRows: [storedPage({ + body: { + name: 'ops_board', + label: 'Operations Board', + description: 'Realtime fulfilment metrics', + }, + })], + }); + + // 'operations' hits the label, 'metrics' the description — AND holds. + expect((await p.searchAll({ q: 'OPERATIONS metrics' })).pages).toHaveLength(1); + // One term matching nowhere fails the AND. + expect((await p.searchAll({ q: 'operations nonexistent' })).pages).toEqual([]); + }); + + it('an i18n label map matches on EVERY locale value and titles through the shared resolution', async () => { + const { p } = makeProtocol({ + storedRows: [storedPage({ + body: { + name: 'hr_portal', + label: { en: 'HR Portal', 'zh-CN': '人事门户' }, + }, + })], + }); + + // A caller searching in Chinese hits the zh-CN label value… + const zh = await p.searchAll({ q: '人事' }); + expect(zh.pages.map((h) => h.name)).toEqual(['hr_portal']); + // …and the title resolves through resolveI18nLabel's default chain + // (this route carries no locale, so `en` leads it). + expect(zh.pages[0].title).toBe('HR Portal'); + }); + + it('a name match with no description carries no snippet; a description match carries the excerpt', async () => { + const longDesc = 'x'.repeat(50) + ' the quarterly revenue figures live here ' + 'y'.repeat(120); + const { p } = makeProtocol({ + storedRows: [ + storedPage({ body: { name: 'bare_page', label: 'Bare Page' } }), + storedPage({ body: { name: 'rev_page', label: 'Revenue', description: longDesc } }), + ], + }); + + const bare = (await p.searchAll({ q: 'bare' })).pages[0]; + expect(bare).toBeDefined(); + expect(Object.keys(bare)).not.toContain('snippet'); + + const rev = (await p.searchAll({ q: 'quarterly' })).pages[0]; + expect(rev?.snippet).toContain('quarterly revenue'); + // Same excerpt geometry as a record hit: ellipsized at both truncated ends. + expect(rev?.snippet?.startsWith('…')).toBe(true); + expect(rev?.snippet?.endsWith('…')).toBe(true); + }); + + it('page hits cap at `perObject` — one more container, not a competitor for `limit`', async () => { + const rows = Array.from({ length: 5 }, (_, i) => + storedPage({ body: { name: `report_page_${i}`, label: `Report Page ${i}` } })); + const { p } = makeProtocol({ storedRows: rows }); + + const result = await p.searchAll({ q: 'report', perObject: 2 }); + + expect(result.pages).toHaveLength(2); + // The cap is `perObject`, not `limit`: limit 1 still admits 2 pages. + const wide = await p.searchAll({ q: 'report', limit: 1, perObject: 2 }); + expect(wide.pages).toHaveLength(2); + }); + + it('a failed page read PROPAGATES — a partial scan must not wear a whole one\'s answer (#8896)', async () => { + const injected = Object.assign(new Error('connection terminated unexpectedly'), { code: 'ECONNRESET' }); + const { engine } = makeEngine({}); + (engine as { find: unknown }).find = vi.fn(async (object: string) => { + if (object === 'sys_metadata') throw injected; + return []; + }); + const p = new ObjectStackProtocolImplementation(engine as never); + + // `getMetaItems` classifies a real outage as a 503 metadata-store + // failure (#5532) — what must NOT happen is a resolve with invented + // `pages: []`. + await expect(p.searchAll({ q: 'anything' })).rejects.toMatchObject({ status: 503 }); + }); + + it('the swept set equals the served set — every page hit is a name `getMetaItems` serves', async () => { + const { p } = makeProtocol({ + codePages: [{ name: 'code_home', label: 'Code Home' }], + storedRows: [storedPage({ body: { name: 'stored_home', label: 'Stored Home' } })], + }); + + const served = await p.getMetaItems({ type: 'page' }); + const servedNames = new Set((served.items as Array<{ name?: string }>).map((i) => i.name)); + const { pages } = await p.searchAll({ q: 'home' }); + + expect(pages.length).toBeGreaterThan(0); + for (const hit of pages) expect(servedNames.has(hit.name)).toBe(true); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.search-title-namefield.test.ts b/packages/metadata-protocol/src/protocol.search-title-namefield.test.ts index 6f53f2cfac..c67ed60a1a 100644 --- a/packages/metadata-protocol/src/protocol.search-title-namefield.test.ts +++ b/packages/metadata-protocol/src/protocol.search-title-namefield.test.ts @@ -50,6 +50,9 @@ function makeProtocol(objects: ObjectMeta[], rowsByObject: Record objects.find(o => o.name === n), getAllObjects: () => objects, + // [#13216] The page sweep's registry read — no pages here; titles + // of RECORD hits are the only thing under test. + listItems: () => [], }, find, }; diff --git a/packages/metadata-protocol/src/search-clone-schema-conformance.test.ts b/packages/metadata-protocol/src/search-clone-schema-conformance.test.ts index 0833c66ac0..6059a927a0 100644 --- a/packages/metadata-protocol/src/search-clone-schema-conformance.test.ts +++ b/packages/metadata-protocol/src/search-clone-schema-conformance.test.ts @@ -28,7 +28,7 @@ // drops the undefined-valued key on the wire. import { describe, it, expect, vi } from 'vitest'; -import { CloneDataResponseSchema, SearchAllHitSchema, SearchAllResponseSchema } from '@objectstack/spec/api'; +import { CloneDataResponseSchema, SearchAllHitSchema, SearchAllPageHitSchema, SearchAllResponseSchema } from '@objectstack/spec/api'; import { ObjectStackProtocolImplementation } from './protocol.js'; import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core'; @@ -51,6 +51,13 @@ const text = (name: string) => ({ name, type: 'text' }); * A protocol over a fixed object set, rows served verbatim by `find` (the * engine double filters nothing — recall is the engine's contract, pinned in * `protocol.search-case-fold.test.ts`; this file is about the emitted SHAPE). + * + * [#13216] The registry also serves one published PAGE matching the query + * (and one that does not), so the body's `pages` member is measured as + * produced — both `snippet` branches of the page hit included: the matching + * page's description contains the term (excerpt present), and a page hit + * whose match is name/label-only serializes the key away (covered in the + * page-hit key test below via `acme_home`). */ function makeSearchProtocol() { const lead = { @@ -63,12 +70,24 @@ function makeSearchProtocol() { // No searchable column contains the term → `snippet` key serializes away. { id: 'lead_2', name: 'Beta Corp', notes: 'no matching text here' }, ]; + const pages = [ + // Description contains the term → page `snippet` present. + { name: 'acme_portal', label: 'Acme Portal', description: 'acme rollout portal', type: 'app' }, + // Match on name/label only → page `snippet` serializes away. + { name: 'acme_home', label: 'Acme Home', type: 'app' }, + // No match at all → not a hit. + { name: 'ops_board', label: 'Operations Board', type: 'app' }, + ]; const engine = { registry: { getObject: (n: string) => (n === 'lead' ? lead : undefined), getAllObjects: () => [lead], + listItems: (type: string) => (type === 'page' ? pages : []), + getItem: () => undefined, + applyNavContributions: (x: unknown) => x, + isPackageDisabled: () => false, }, - find: vi.fn(async () => rows), + find: vi.fn(async (object: string) => (object === 'sys_metadata' ? [] : rows)), }; return new ObjectStackProtocolImplementation(engine as never); } @@ -91,6 +110,8 @@ describe('[#11924] searchAll conforms to SearchAllResponseSchema', () => { expect(body.totalObjects).toBe(1); expect(body.totalHits).toBe(body.hits.length); expect(body.truncated).toBe(false); + // [#13216] …including the published-page sibling array, as produced. + expect(body.pages.map((h) => h.name)).toEqual(['acme_portal', 'acme_home']); }); it('emits no top-level key the spec does not declare', async () => { @@ -120,12 +141,30 @@ describe('[#11924] searchAll conforms to SearchAllResponseSchema', () => { expect(Object.keys(withoutSnippet!)).not.toContain('snippet'); }); + it('emits no PAGE-hit key the spec does not declare — and the page `snippet` is genuinely conditional (#13216)', async () => { + const body = overTheWire(await makeSearchProtocol().searchAll({ q: 'acme' })); + const declared = declaredKeys(SearchAllPageHitSchema); + for (const hit of body.pages) { + const undeclared = Object.keys(hit).filter((k) => !declared.has(k)); + expect(undeclared, `keys on page hit ${hit.name} that SearchAllPageHitSchema never declares`).toEqual([]); + } + // Both branches of the optional member, measured on one body: the page + // whose DESCRIPTION contains the term carries the excerpt; the page + // matched on name/label alone serializes the key away (the title + // already shows the match). + const withSnippet = body.pages.find((h: { name: string }) => h.name === 'acme_portal'); + const withoutSnippet = body.pages.find((h: { name: string }) => h.name === 'acme_home'); + expect(withSnippet?.snippet?.toLowerCase()).toContain('acme rollout'); + expect(withoutSnippet).toBeDefined(); + expect(Object.keys(withoutSnippet!)).not.toContain('snippet'); + }); + it('the blank-query short-circuit parses too — the one body built by a different return', async () => { // `searchAll` has exactly two return statements; this is the other one. const body = await makeSearchProtocol().searchAll({ q: ' ' }); const parsed = SearchAllResponseSchema.safeParse(body); expect(parsed.error?.issues ?? []).toEqual([]); - expect(body).toEqual({ query: '', hits: [], totalObjects: 0, totalHits: 0, truncated: false }); + expect(body).toEqual({ query: '', hits: [], pages: [], totalObjects: 0, totalHits: 0, truncated: false }); }); }); diff --git a/packages/rest/src/discovery-search-capability-agreement.test.ts b/packages/rest/src/discovery-search-capability-agreement.test.ts index 4ef82f3e1b..23c7d76064 100644 --- a/packages/rest/src/discovery-search-capability-agreement.test.ts +++ b/packages/rest/src/discovery-search-capability-agreement.test.ts @@ -62,8 +62,13 @@ function createEngine() { getObject: (n: string) => (n === 'widget' ? widget : undefined), getAllObjects: () => [widget], getRegisteredTypes: () => [], + // [#13216] The published-page sweep's registry read — no pages here; + // capability/route agreement is the only thing under test. + listItems: () => [], }, - find: async () => [{ id: 'w1', title: 'audit trail' }], + // [#13216] `sys_metadata` (the page sweep's overlay read) answers empty + // so the record hit stays the only content on this host. + find: async (object: string) => (object === 'sys_metadata' ? [] : [{ id: 'w1', title: 'audit trail' }]), }; } diff --git a/packages/rest/src/search-clone-route-schema-conformance.test.ts b/packages/rest/src/search-clone-route-schema-conformance.test.ts index 9b0132b3fd..6f2443d5e4 100644 --- a/packages/rest/src/search-clone-route-schema-conformance.test.ts +++ b/packages/rest/src/search-clone-route-schema-conformance.test.ts @@ -59,6 +59,17 @@ const SEARCH_BODY = { record: { id: 'lead_2', name: 'Beta Corp' }, }, ], + // [#13216] The published-page sibling array — produced by the same + // `searchAll` and relayed by the same bare `res.json(result)`. + pages: [ + { + kind: 'page' as const, + name: 'acme_portal', + title: 'Acme Portal', + snippet: 'acme rollout portal', + pageType: 'app', + }, + ], totalObjects: 1, totalHits: 2, truncated: false, diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 7b4d07d400..80daddcdc4 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -914,6 +914,8 @@ "ScheduledExportSchema (const)", "SearchAllHit (type)", "SearchAllHitSchema (const)", + "SearchAllPageHit (type)", + "SearchAllPageHitSchema (const)", "SearchAllResponse (type)", "SearchAllResponseSchema (const)", "ServiceInfo (type)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index fe97732e63..93cf71ee0a 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1607,7 +1607,13 @@ "api/SearchAllHit:record", "api/SearchAllHit:snippet", "api/SearchAllHit:title", + "api/SearchAllPageHit:kind", + "api/SearchAllPageHit:name", + "api/SearchAllPageHit:pageType", + "api/SearchAllPageHit:snippet", + "api/SearchAllPageHit:title", "api/SearchAllResponse:hits", + "api/SearchAllResponse:pages", "api/SearchAllResponse:query", "api/SearchAllResponse:totalHits", "api/SearchAllResponse:totalObjects", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index e849b01800..d8e1da6fd1 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -684,6 +684,8 @@ "ScheduledExportSchema": "api/ScheduledExport", "SearchAllHit": "api/SearchAllHit", "SearchAllHitSchema": "api/SearchAllHit", + "SearchAllPageHit": "api/SearchAllPageHit", + "SearchAllPageHitSchema": "api/SearchAllPageHit", "SearchAllResponse": "api/SearchAllResponse", "SearchAllResponseSchema": "api/SearchAllResponse", "ServiceInfo": "api/ServiceInfo", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index c8f4b4232f..36e01f542d 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -914,6 +914,8 @@ "ScheduledExportSchema": "src/api/export.zod.ts#ScheduledExportSchema (const)", "SearchAllHit": "src/api/protocol.zod.ts#SearchAllHit (type)", "SearchAllHitSchema": "src/api/protocol.zod.ts#SearchAllHitSchema (const)", + "SearchAllPageHit": "src/api/protocol.zod.ts#SearchAllPageHit (type)", + "SearchAllPageHitSchema": "src/api/protocol.zod.ts#SearchAllPageHitSchema (const)", "SearchAllResponse": "src/api/protocol.zod.ts#SearchAllResponse (type)", "SearchAllResponseSchema": "src/api/protocol.zod.ts#SearchAllResponseSchema (const)", "ServiceInfo": "src/api/discovery.zod.ts#ServiceInfo (type)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index c886e11f74..2d56c5e21e 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -376,6 +376,7 @@ "api/ScheduleExportResponse", "api/ScheduledExport", "api/SearchAllHit", + "api/SearchAllPageHit", "api/SearchAllResponse", "api/ServiceInfo", "api/ServiceSelfInfo", From 52588165436fd23b31549e9030e5e3752cfeb728 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 13:56:00 +0000 Subject: [PATCH 3/5] chore: repair system-context census line anchors after protocol.ts insertion Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1 --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index be72c21552..0cb53a8eab 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -112,7 +112,7 @@ that silently does not happen. | 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10914` | | 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11076` | | 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9772` | -| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1737` | +| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1742` | | 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9809`, `readonly-strict-errors.ts:66` | | 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5730` | | 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3599`, `:3609`, `:3636` | From 0ba04d7653927855b1882343d8a55ca4e8d3fbb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 14:06:49 +0000 Subject: [PATCH 4/5] chore: keep tracker ids out of runtime/spec prose (doc-authoring gate); regen docs Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1 --- content/docs/references/api/protocol.mdx | 2 +- packages/rest/src/rest-route-ledger.ts | 5 ++++- packages/spec/src/api/protocol.zod.ts | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 8764418677..f74675a191 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2607,7 +2607,7 @@ Installed package with runtime lifecycle state | :--- | :--- | :--- | :--- | | **query** | `string` | ✅ | The TRIMMED query text the sweep ran with — empty string when the request carried none (the no-scan short-circuit). | | **hits** | `{ object: string; id: string; title: string; snippet?: string; … }[]` | ✅ | Matched records across objects, in scan order, capped at the overall `limit` (default 20, max 100) with at most `perObject` (default 5, max 25) per object. | -| **pages** | `{ kind: 'page'; name: string; title: string; snippet?: string; … }[]` | ✅ | Published pages whose name, label, or description matches every term (each term may match in a different field; matching folds case), in the served listing's order, capped at `perObject` — the page store is swept as one more container, and more matching pages may exist beyond the cap. Produced ONLY on an unscoped sweep: a request that names `objects` asks for records of those objects and answers `pages: []`. The swept set is exactly what the caller's metadata read door (`GET /api/v1/meta/page`) serves — published state only, never drafts (#13216). | +| **pages** | `{ kind: 'page'; name: string; title: string; snippet?: string; … }[]` | ✅ | Published pages whose name, label, or description matches every term (each term may match in a different field; matching folds case), in the served listing's order, capped at `perObject` — the page store is swept as one more container, and more matching pages may exist beyond the cap. Produced ONLY on an unscoped sweep: a request that names `objects` asks for records of those objects and answers `pages: []`. The swept set is exactly what the caller's metadata read door (`GET /api/v1/meta/page`) serves — published state only, never drafts. | | **totalObjects** | `number` | ✅ | Number of objects the sweep actually SCANNED (searchable, API-enabled, with a resolvable search-field set) — not the number of objects with hits. An object whose table was never provisioned is skipped and not counted. | | **totalHits** | `number` | ✅ | Number of hits returned — equals `hits.length`. NOT a deployment-wide total-match count: matches beyond `limit` / `perObject` are not counted. | | **truncated** | `boolean` | ✅ | True when the sweep stopped at the overall `limit` — more matches may exist beyond the returned set. | diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 9e079dead9..4aeda17711 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -347,9 +347,12 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ note: 'file-stream response; the SDK returns the raw Response rather than a JSON envelope' }, // ── search ──────────────────────────────────────────────────────────────── + // #13216 — the body's `pages` member (published-page hits) landed with the + // command-palette page indexing; the note below describes it without the id + // (doc-authoring rule: tracker ids stay out of runtime string prose). { route: 'GET /api/v1/search', family: 'search', source: 'route-manager', disposition: 'sdk', client: 'search', responseSchema: 'SearchAllResponseSchema', - note: '[#11924] answers BARE (res.json(result), no envelope), so the named schema is the whole body. ⚠️ NOT `SearchResult` — that exported contract types the per-object ISearchService.search (hits of score/document), the #8140 near-miss trap. Filled with its conformance coverage: search-clone-route-schema-conformance.test.ts drives this mount, and metadata-protocol\'s search-clone-schema-conformance.test.ts parses the real searchAll producer. [#13216] the body additionally carries `pages` — published-page hits swept by the same producer through the caller\'s own meta read verb; still this one named schema, no handler change (bare relay)' }, + note: '[#11924] answers BARE (res.json(result), no envelope), so the named schema is the whole body. ⚠️ NOT `SearchResult` — that exported contract types the per-object ISearchService.search (hits of score/document), the #8140 near-miss trap. Filled with its conformance coverage: search-clone-route-schema-conformance.test.ts drives this mount, and metadata-protocol\'s search-clone-schema-conformance.test.ts parses the real searchAll producer. The body additionally carries `pages` — published-page hits swept by the same producer through the caller\'s own meta read verb; still this one named schema, no handler change (bare relay)' }, // ── email ───────────────────────────────────────────────────────────────── { route: 'POST /api/v1/email/send', family: 'email', source: 'route-manager', disposition: 'sdk', client: 'email.send' }, diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 96f83ad374..de14269c93 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -2260,7 +2260,7 @@ export const SearchAllResponseSchema = lazySchema(() => z.object({ + 'exist beyond the cap. Produced ONLY on an unscoped sweep: a request that names `objects` ' + 'asks for records of those objects and answers `pages: []`. The swept set is exactly what ' + 'the caller\'s metadata read door (`GET /api/v1/meta/page`) serves — published state only, ' - + 'never drafts (#13216).' + + 'never drafts.' ), totalObjects: z.number().describe( 'Number of objects the sweep actually SCANNED (searchable, API-enabled, with a ' From 88eee836dd3f4be415e45401cd1116345e0e0e66 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 15:02:02 +0000 Subject: [PATCH 5/5] =?UTF-8?q?chore:=20discharge=20os-regen=20deferral=20?= =?UTF-8?q?=E2=80=94=20system-context=20census=20doc=20regenerated=20on=20?= =?UTF-8?q?the=20merge=20result?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Mciyv38maJ6HYVMiaM26T1 --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 0cb53a8eab..7fe8c239f8 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -112,7 +112,7 @@ that silently does not happen. | 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:10914` | | 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11076` | | 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9772` | -| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1742` | +| 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1746` | | 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9809`, `readonly-strict-errors.ts:66` | | 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5730` | | 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3599`, `:3609`, `:3636` |