Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/adr/0028-metadata-naming-and-namespace-isolation.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0061-record-search-architecture.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
53 changes: 53 additions & 0 deletions packages/dogfood/test/search-conformance.ledger.ts
Original file line number Diff line number Diff line change
@@ -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',
},
];
32 changes: 32 additions & 0 deletions packages/dogfood/test/search-conformance.test.ts
Original file line number Diff line number Diff line change
@@ -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, join } from 'node:path';

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note test

Unused import join.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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');
});
});
67 changes: 67 additions & 0 deletions packages/dogfood/test/showcase-search.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
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<Record<string, unknown>>;
};

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');
});
});
Loading