diff --git a/docs/adr/0028-metadata-naming-and-namespace-isolation.md b/docs/adr/0028-metadata-naming-and-namespace-isolation.md index 4bd577ba8f..93dc1817fd 100644 --- a/docs/adr/0028-metadata-naming-and-namespace-isolation.md +++ b/docs/adr/0028-metadata-naming-and-namespace-isolation.md @@ -1,6 +1,6 @@ # ADR-0028: Metadata Naming & Namespace Isolation — Derived Physical Names, Namespace-Scoped Identity, and a Single Kernel Contract -**Status**: Proposed (2026-06-01) — NOT STARTED (2026-07-16 audit): the target model (namespace as identity dimension, short authored names, derived physical names, `namingMode` dual-read, `sys` reservation, namespaced transport segments) is entirely unbuilt; what exists is the superseded literal-prefix current-state contract (`spec/kernel/namespace-prefix.ts`, self-labelled) plus unrelated ADR-0048 conflict scaffolding. +**Status**: Deferred (2026-07-16; proposed 2026-06-01) — the target model (namespace as identity dimension, short authored names, derived physical names, `namingMode` dual-read, `sys` reservation, namespaced transport segments) is entirely unbuilt, and adopting it now would be a platform-wide migration (physical table derivation, transport routes, dual-read compatibility) with no present pain to justify it: the literal-prefix current-state contract (`spec/kernel/namespace-prefix.ts`) is enforced, collision-proof (ADR-0028/0048), and working. **Re-open trigger**: real multi-package ecosystem naming pain — e.g. a marketplace package whose natural short names are unusable under literal prefixing, or cross-environment portability requiring namespace remapping. Until then this stays a recorded design, not planned work. **Deciders**: ObjectStack Protocol Architects **Supersedes**: the hand-written object-namespace-prefix authoring rule documented in `packages/spec/src/kernel/manifest.zod.ts` (the `namespace` field) and enforced by `validateNamespacePrefix()` in `packages/spec/src/stack.zod.ts` — there is no standalone ADR for that rule today. **Builds on**: [ADR-0005](./0005-metadata-customization-overlay.md) (one Zod source per type, org overlay), [ADR-0008](./0008-metadata-repository-and-change-log.md) (Repository · ChangeLog · Cache · Registry; `MetaRef = org/type/name`), [ADR-0010](./0010-metadata-protection-model.md) (protection model), [ADR-0019](./0019-app-as-consumer-unit.md) (app as the consumer-installable unit), [ADR-0025](./0025-plugin-package-distribution.md) (package distribution), [ADR-0029](./0029-kernel-object-ownership-and-platform-objects-decomposition.md) (**prerequisite** — kernel object ownership; D5/D6 below assume the kernel is properly owned per ADR-0029) diff --git a/docs/adr/0061-record-search-architecture.md b/docs/adr/0061-record-search-architecture.md index 76ad5e236d..5ec40ba56f 100644 --- a/docs/adr/0061-record-search-architecture.md +++ b/docs/adr/0061-record-search-architecture.md @@ -1,6 +1,6 @@ # ADR-0061: Record Search Architecture — metadata-driven `$search` resolution -**Status**: Proposed — recommended for acceptance (2026-06-21). **Tier 1 (`$search` + `object.searchableFields`) has since been implemented & is live; Tier 2 (FTS/relevance) deferred.** Verified 2026-07-16: Tier 1 claim TRUE (`objectql/src/search-filter.ts` + engine wiring + unit tests, incl. `$searchFields` narrowing and select label-mapping) — but this ADR's own acceptance bar (a `search-conformance` ledger entry + dogfood multi-field HTTP proof, per ADR-0060 closure) is still unmet, so the header stays Proposed until that lands. +**Status**: Accepted (2026-06-21; Tier 1) — implemented and conformance-closed per this ADR's own bar: server-resolved `$search` executor (`objectql/src/search-filter.ts` + engine wiring), `object.searchableFields` + `$searchFields` narrowing, select label→value mapping; **`search-conformance` ledger** (`dogfood/test/search-conformance.ledger.ts`, ADR-0060 `checkLedger`, proof-required) + **dogfood multi-field HTTP proof** (`showcase-search.dogfood.test.ts`: "retail"→Northwind via `industry`, label mapping, narrowing, terms-AND). Tier 2 (FTS / relevance / external engines) remains deferred — add its ledger row with a proof when a driver ships `fullTextSearch: true`. **Deciders**: ObjectStack Protocol Architects **Builds on**: ADR-0045 (additive materialization & visibility gate — search must be visibility-aware), ADR-0049 (enforce-or-remove / no unenforced declaration), ADR-0054 (runtime proof per enforced surface), ADR-0060 (conformance ledger as a platform pattern) **Consumers**: spec, objectql, driver-sql, driver-memory, driver-mongodb, rest, objectui (`fields`, `plugin-list`, `app-shell`), verify, dogfood diff --git a/packages/dogfood/test/search-conformance.ledger.ts b/packages/dogfood/test/search-conformance.ledger.ts new file mode 100644 index 0000000000..c1684c1837 --- /dev/null +++ b/packages/dogfood/test/search-conformance.ledger.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { ConformanceRow } from '@objectstack/verify'; +// +// ADR-0061 §Conformance — Record-Search Conformance ledger (ADR-0060 pattern). +// +// The ADR's own landing bar: `$search` is "landed" iff a driver-reachable +// executor runs it AND a dogfood proof asserts a MULTI-FIELD match over the +// real HTTP API (e.g. searching "retail" returns an account matched by +// `industry`, not `name`). This ledger is the durable encoding of that bar: +// every search-related declarable surface sits in exactly one honest state +// (ADR-0049), names its executor site, and the enforced rows reference the +// HTTP-level proof (`showcase-search.dogfood.test.ts`). +// +// Tier 2 (FTS / relevance / external engines) is deliberately ABSENT from this +// ledger rather than carried as an aspirational row — per ADR-0049 a surface +// that does not exist in the spec cannot be "declared but unenforced". When a +// driver grows `fullTextSearch: true`, add its row here with a proof. + +export const SEARCH_SURFACE: ConformanceRow[] = [ + { + id: 'search-executor', + summary: '`$search` server-resolved cross-field executor (terms AND-ed, fields OR-ed, case-insensitive `$contains`)', + surface: 'spec/api/query.zod.ts:$search (QueryParams `search`)', + state: 'enforced', + enforcement: 'objectql/src/engine.ts (find AST expansion) → objectql/src/search-filter.ts expandSearchToFilter', + proof: 'showcase-search.dogfood.test.ts', + }, + { + id: 'searchable-fields-object', + summary: '`object.searchableFields` — canonical allowed-search-field set; auto-default (name/title + short text, secret/PII/json excluded) when unset', + surface: 'spec/data/object.zod.ts:searchableFields', + state: 'enforced', + enforcement: 'objectql/src/search-filter.ts resolveSearchFields / autoDefaultFields', + proof: 'showcase-search.dogfood.test.ts', + }, + { + id: 'search-fields-override', + summary: '`$searchFields` per-query narrowing — validated against the allowed set, can never widen it', + surface: 'spec/api/query.zod.ts:$searchFields', + state: 'enforced', + enforcement: 'objectql/src/search-filter.ts resolveSearchFields (intersection)', + proof: 'showcase-search.dogfood.test.ts', + }, + { + id: 'search-select-label-mapping', + summary: 'select/status label→value mapping — a human label in the query matches option values', + surface: 'spec/data/field.zod.ts:options (select) × $search', + state: 'enforced', + enforcement: 'objectql/src/search-filter.ts optionValuesMatching / fieldClausesForTerm', + proof: 'showcase-search.dogfood.test.ts', + }, +]; diff --git a/packages/dogfood/test/search-conformance.test.ts b/packages/dogfood/test/search-conformance.test.ts new file mode 100644 index 0000000000..8007649033 --- /dev/null +++ b/packages/dogfood/test/search-conformance.test.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0061 §Conformance — the Record-Search Conformance ledger is a CHECKED +// artifact (ADR-0060 `checkLedger`): shared invariants + every enforced row +// must carry an existing proof file. The proof itself +// (`showcase-search.dogfood.test.ts`) asserts the ADR's landing bar over the +// real HTTP API; this test asserts the ledger cannot silently rot (a renamed +// or deleted proof file fails here, closing the declared-but-unenforced loop). + +import { describe, expect, it } from 'vitest'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { checkLedger } from '@objectstack/verify'; +import { SEARCH_SURFACE } from './search-conformance.ledger.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +describe('ADR-0061 record-search conformance ledger', () => { + it('is sound and every enforced row carries an existing proof', () => { + const problems = checkLedger(SEARCH_SURFACE, { + proofRoot: HERE, + proofRequiredForEnforced: true, + }); + expect(problems).toEqual([]); + }); + + it('covers the executor surface exactly once', () => { + const ids = SEARCH_SURFACE.map((r) => r.id); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).toContain('search-executor'); + }); +}); diff --git a/packages/dogfood/test/showcase-search.dogfood.test.ts b/packages/dogfood/test/showcase-search.dogfood.test.ts new file mode 100644 index 0000000000..94376bc560 --- /dev/null +++ b/packages/dogfood/test/showcase-search.dogfood.test.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0061 §Conformance — the dogfood proof behind the search-conformance +// ledger: a MULTI-FIELD `$search` match over the REAL HTTP API. The ADR's own +// example, verbatim: searching "retail" must return an account matched by +// `industry`, not `name` (the showcase seed's Northwind row carries +// `industry: 'retail'` and no account name contains "retail"). Also proves +// the `$searchFields` narrowing (can only narrow, never widen) so the +// executor's security posture is pinned at the HTTP level, not just in +// `search-filter.test.ts` unit tests. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +describe('showcase: $search over the HTTP API (ADR-0061 conformance proof)', () => { + let stack: VerifyStack; + let token: string; + + const query = async (body: Record) => { + const res = await stack.apiAs(token, 'POST', '/data/showcase_account/query', body); + expect(res.status).toBe(200); + const data = await res.json(); + return (data?.data?.records ?? data?.records ?? []) as Array>; + }; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + token = await stack.signIn(); + }, 60_000); + afterAll(async () => { await stack?.stop(); }); + + it('multi-field match: "retail" returns Northwind via industry, not name', async () => { + const records = await query({ search: 'retail' }); + const names = records.map((r) => String(r.name)); + expect(names).toContain('Northwind'); + // Guard the premise: Northwind's NAME does not contain "retail", so its + // presence in the result can only come from a non-name field… + expect(names.find((n) => n === 'Northwind')!.toLowerCase()).not.toContain('retail'); + // …and prove it positively: restricting the same search to `industry` + // alone still returns Northwind — the hit IS the industry field. + const viaIndustry = await query({ search: 'retail', searchFields: ['industry'] }); + expect(viaIndustry.map((r) => r.name)).toContain('Northwind'); + // (The seed also has an account literally NAMED "acme retail" — a useful + // control: the unrestricted search returns it via `name`, proving the + // cross-field OR spans both fields in one query.) + expect(names.some((n) => n.toLowerCase().includes('retail'))).toBe(true); + }); + + it('select label→value mapping: the capitalized label "Retail" also matches', async () => { + const records = await query({ search: 'Retail' }); + expect(records.map((r) => r.name)).toContain('Northwind'); + }); + + it('$searchFields narrows: "retail" restricted to name matches nothing', async () => { + const records = await query({ search: 'retail', searchFields: ['name'] }); + expect(records.map((r) => r.name)).not.toContain('Northwind'); + }); + + it('terms AND: "retail northwind" still matches; "retail contoso" does not', async () => { + const both = await query({ search: 'retail northwind' }); + expect(both.map((r) => r.name)).toContain('Northwind'); + const cross = await query({ search: 'retail contoso' }); + expect(cross.map((r) => r.name)).not.toContain('Northwind'); + expect(cross.map((r) => r.name)).not.toContain('Contoso'); + }); +});