diff --git a/.changeset/adr-0046-doc-description.md b/.changeset/adr-0046-doc-description.md new file mode 100644 index 0000000000..2bba6846ed --- /dev/null +++ b/.changeset/adr-0046-doc-description.md @@ -0,0 +1,17 @@ +--- +"@objectstack/spec": minor +"@objectstack/cli": minor +--- + +feat(ADR-0046): add optional `description` to package docs + +A doc can now carry a one-line `description` (frontmatter `description:`), +giving the natural minimal model: title / summary / body. `DocSchema` gains an +optional `description`; `os build` reads it from frontmatter. It travels in the +`GET /meta/doc` list response (unlike `content`, which the list omits), so a +docs portal can show summaries without fetching each body. Example docs +(app-showcase, app-todo) updated. + +Also records the deferred-to-P3 design for doc **tags** in ADR-0046: tags are +keys (i18n-resolved, never display strings), with a small protocol core +vocabulary plus namespace-prefixed package tags — not a field to bolt on early. diff --git a/.objectui-sha b/.objectui-sha index cd85c308f3..a317a866e2 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -6164366619a136ba301580322e72a99b8a19c738 \ No newline at end of file +893e5302174c7cbf75a7bed9e8e6dcb935273363 diff --git a/content/docs/guides/metadata/doc.mdx b/content/docs/guides/metadata/doc.mdx index 2ca5844ead..344bf74689 100644 --- a/content/docs/guides/metadata/doc.mdx +++ b/content/docs/guides/metadata/doc.mdx @@ -31,11 +31,13 @@ stable (a link resolves by basename, never by path). ### Frontmatter -A leading YAML frontmatter block is optional. Only `title` is read: +A leading YAML frontmatter block is optional. `title` and `description` +are read: ```md --- title: User Guide +description: How to create, organize, and complete records in CRM. --- # Getting started with CRM @@ -43,7 +45,9 @@ title: User Guide ``` The display title resolves in order: frontmatter `title:` → the first -`#` heading → the doc `name`. +`#` heading → the doc `name`. The optional `description` is a one-line +summary the docs portal renders under the title (it travels in the doc +list, so the portal shows summaries without fetching each body). ## Doc Properties @@ -53,6 +57,7 @@ When a `*.md` file is collected, it becomes a `doc` item with this shape: | :--- | :--- | :--- | :--- | | `name` | `string` | ✅ | Machine name (`snake_case`), from the filename stem. Must match `^[a-z][a-z0-9_]*$` and be **namespace-prefixed** | | `label` | `string` | — | Display title (from frontmatter `title`) | +| `description` | `string` | — | One-line summary for listings (from frontmatter `description`) | | `content` | `string` | ✅ | The Markdown body | You can also declare a doc programmatically with `DocSchema` in a stack diff --git a/docs/adr/0046-package-docs-as-metadata.md b/docs/adr/0046-package-docs-as-metadata.md index 1a3852af97..a12ba5986b 100644 --- a/docs/adr/0046-package-docs-as-metadata.md +++ b/docs/adr/0046-package-docs-as-metadata.md @@ -107,6 +107,8 @@ export const DocSchema = z.object({ .describe('Unique doc name; MUST carry the package namespace prefix (enforced by build/publish lint)'), label: z.string().optional() .describe('Display title; defaults to the first `#` heading, then the name'), + description: z.string().optional() + .describe('One-line summary for listings (frontmatter `description:`); travels in the list response'), content: z.string().describe('Raw Markdown (CommonMark + GFM)'), }); ``` @@ -144,6 +146,8 @@ unbounded, and manifest-size pressure lands here first. legacy, so day-one lint costs nothing — the same reasoning as the image ban. - `label` = frontmatter `title:` if present, else first `#` heading. +- `description` = frontmatter `description:` if present (optional one-line + summary; the docs portal renders it under the title). - `content` = the file body (frontmatter stripped). - **Subdirectories under `src/docs/` are a build error**, not silently flattened — flatness is the contract that keeps references stable. @@ -240,3 +244,30 @@ maintained, so it cannot rot. P0 conventions are isomorphic to the P1 schema by construction: when the compiler lands, pilot content migrates with zero edits. + +### P3 design note — tags / categorization (deferred, not bolted on early) + +Tags, categories, and ordering are **navigation-model** concerns and stay +in P3 by design — adding them before there is enough doc volume to need +filtering buys an i18n-bearing protocol field with no payoff. When they +land, design the discovery surface (tags + category + search + cross-package +aggregation) as one thing, not field-by-field. The agreed shape for tags: + +- **A tag is a stable key, never a display string.** Display always resolves + through the platform's existing label-key → i18n mechanism, exactly like + every other label. Free-form display strings fail twice: cross-package + fragmentation (`setup` vs `getting-started` vs `quickstart`) and no i18n + owner. Keying fixes both. +- **Layered vocabulary, not closed-vs-open.** The protocol blesses a *small + core* of cross-cutting "purpose" tags (`getting-started`, `guide`, + `reference`, `tutorial`, `api`, `migration`, `troubleshooting`) — central + i18n, eligible for dedicated UI. Packages extend with **namespace-prefixed** + tags (`crm_*`, same rule as doc names) for domain topics, shipping their + translations in the package i18n bundle (same path as object/field labels). +- Pure closed enum is too rigid (packages can't express domain topics); pure + open free-form is too fragmented and has no i18n home. The layered + key-based model is the resolution. + +The current addition — `description` — deliberately stops short of this: it is +a per-doc summary, not a taxonomy, so it carries no i18n-keying or +cross-package-coherence burden. diff --git a/examples/app-showcase/src/docs/showcase_docs_guide.md b/examples/app-showcase/src/docs/showcase_docs_guide.md index eef20539ca..edadaa7f24 100644 --- a/examples/app-showcase/src/docs/showcase_docs_guide.md +++ b/examples/app-showcase/src/docs/showcase_docs_guide.md @@ -1,5 +1,6 @@ --- title: Documentation Guide +description: The authoring rules every package doc must follow — flat directory, namespace prefix, links, and the Markdown subset. --- # Writing package docs diff --git a/examples/app-showcase/src/docs/showcase_index.md b/examples/app-showcase/src/docs/showcase_index.md index fcde2d9fef..91fde6fae5 100644 --- a/examples/app-showcase/src/docs/showcase_index.md +++ b/examples/app-showcase/src/docs/showcase_index.md @@ -1,3 +1,8 @@ +--- +title: Showcase +description: Overview of the showcase package and the docs-as-metadata feature it demonstrates. +--- + # Showcase The living conformance fixture for the ObjectStack protocol: every field diff --git a/examples/app-todo/src/docs/todo_index.md b/examples/app-todo/src/docs/todo_index.md index 26de9290f3..ae279e7889 100644 --- a/examples/app-todo/src/docs/todo_index.md +++ b/examples/app-todo/src/docs/todo_index.md @@ -1,3 +1,8 @@ +--- +title: Todo App +description: A minimal task-management app that demonstrates the ObjectStack metadata protocol end to end. +--- + # Todo App A minimal task-management app demonstrating the ObjectStack metadata diff --git a/examples/app-todo/src/docs/todo_user_guide.md b/examples/app-todo/src/docs/todo_user_guide.md index 38692b0bc0..8473b14364 100644 --- a/examples/app-todo/src/docs/todo_user_guide.md +++ b/examples/app-todo/src/docs/todo_user_guide.md @@ -1,5 +1,6 @@ --- title: User Guide +description: How to create, organize, and complete tasks in the Todo app. --- # Working with Tasks diff --git a/packages/cli/src/utils/collect-docs.test.ts b/packages/cli/src/utils/collect-docs.test.ts index cf0ea6c998..c9c11c9742 100644 --- a/packages/cli/src/utils/collect-docs.test.ts +++ b/packages/cli/src/utils/collect-docs.test.ts @@ -38,6 +38,18 @@ describe('collectDocsFromSrc (ADR-0046 §3.2)', () => { expect(guide.content).not.toContain('title:'); // frontmatter stripped }); + it('reads optional frontmatter `description:` (and omits it when absent)', () => { + write('crm_index.md', '---\ntitle: CRM\ndescription: Start here.\n---\n\n# CRM\n\nBody.'); + write('crm_plain.md', '# Plain\n\nNo frontmatter.'); + const { docs, issues } = collectDocsFromSrc(configPath); + expect(issues).toHaveLength(0); + const index = docs.find((d) => d.name === 'crm_index')!; + expect(index.description).toBe('Start here.'); + expect(index.content).not.toContain('description:'); // frontmatter stripped + const plain = docs.find((d) => d.name === 'crm_plain')!; + expect(plain.description).toBeUndefined(); // absent → omitted, not '' + }); + it('errors on subdirectories — flatness is the contract', () => { fs.mkdirSync(path.join(docsDir, 'user')); write('crm_index.md', '# x'); diff --git a/packages/cli/src/utils/collect-docs.ts b/packages/cli/src/utils/collect-docs.ts index 503ba06eb1..c8ca17b811 100644 --- a/packages/cli/src/utils/collect-docs.ts +++ b/packages/cli/src/utils/collect-docs.ts @@ -29,6 +29,7 @@ import path from 'path'; export interface DocItem { name: string; label?: string; + description?: string; content: string; } @@ -41,19 +42,31 @@ export interface DocIssue { const DOC_NAME_RE = /^[a-z][a-z0-9_]*$/; -/** Strip a leading `---` frontmatter block; extract a `title:` if present. */ -function parseFrontmatter(raw: string): { title?: string; body: string } { +/** Extract a single-line scalar `key: value` from a frontmatter block. */ +function frontmatterScalar(block: string, key: string): string | undefined { + const re = new RegExp(`^${key}\\s*:`, 'i'); + const line = block.split(/\r?\n/).find((l) => re.test(l)); + if (!line) return undefined; + const value = line.replace(re, '').trim().replace(/^['"]|['"]$/g, ''); + return value || undefined; +} + +/** + * Strip a leading `---` frontmatter block; extract `title:` and + * `description:` if present (both optional, single-line scalars). + */ +function parseFrontmatter(raw: string): { title?: string; description?: string; body: string } { if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) return { body: raw }; const end = raw.indexOf('\n---', 3); if (end === -1) return { body: raw }; const block = raw.slice(raw.indexOf('\n') + 1, end); const bodyStart = raw.indexOf('\n', end + 1); const body = bodyStart === -1 ? '' : raw.slice(bodyStart + 1); - const titleLine = block.split(/\r?\n/).find((l) => /^title\s*:/.test(l)); - const title = titleLine - ? titleLine.replace(/^title\s*:\s*/, '').trim().replace(/^['"]|['"]$/g, '') - : undefined; - return { title: title || undefined, body }; + return { + title: frontmatterScalar(block, 'title'), + description: frontmatterScalar(block, 'description'), + body, + }; } /** Remove fenced code blocks and inline code spans before content scans. */ @@ -105,8 +118,13 @@ export function collectDocsFromSrc(configPath: string): { docs: DocItem[]; issue } const raw = fs.readFileSync(path.join(docsDir, entry.name), 'utf-8'); - const { title, body } = parseFrontmatter(raw); - docs.push({ name: stem, label: title ?? firstHeading(body), content: body }); + const { title, description, body } = parseFrontmatter(raw); + docs.push({ + name: stem, + label: title ?? firstHeading(body), + ...(description ? { description } : {}), + content: body, + }); } return { docs, issues }; } diff --git a/packages/objectql/src/registry-namespace-install-gate.test.ts b/packages/objectql/src/registry-namespace-install-gate.test.ts new file mode 100644 index 0000000000..59b7a4693f --- /dev/null +++ b/packages/objectql/src/registry-namespace-install-gate.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0048 Phase 1 — install-time namespace gate. + * + * A package's `manifest.namespace` is the mandatory object-name prefix and the + * container that scopes its UI metadata, so it must be unique per installation. + * `installPackage` refuses a package whose namespace is already owned by a + * *different* installed package. Same-package reinstall and shareable platform + * namespaces (`base`/`system`/`sys`) pass through; `OS_METADATA_COLLISION=warn` + * downgrades the refusal to a warning. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { SchemaRegistry, NamespaceConflictError } from './registry'; + +const manifest = (id: string, namespace: string) => ({ + id, + name: id, + namespace, + version: '1.0.0', +}); + +describe('SchemaRegistry — namespace install gate (ADR-0048 Phase 1)', () => { + let registry: SchemaRegistry; + + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + registry.logLevel = 'silent'; + }); + + it('refuses a package whose namespace is already owned by a different package', () => { + registry.installPackage(manifest('com.acme.crm', 'crm') as any); + expect(() => + registry.installPackage(manifest('com.beta.crm', 'crm') as any), + ).toThrowError(NamespaceConflictError); + }); + + it('error names both packages and the namespace', () => { + registry.installPackage(manifest('com.acme.crm', 'crm') as any); + try { + registry.installPackage(manifest('com.beta.crm', 'crm') as any); + throw new Error('expected a namespace conflict error'); + } catch (e) { + expect(e).toBeInstanceOf(NamespaceConflictError); + const err = e as NamespaceConflictError; + expect(err.namespace).toBe('crm'); + expect(err.existingPackageId).toBe('com.acme.crm'); + expect(err.incomingPackageId).toBe('com.beta.crm'); + expect(err.message).toContain('com.acme.crm'); + expect(err.message).toContain('com.beta.crm'); + expect(err.message).toContain('crm'); + } + // The conflicting package must NOT have been recorded. + expect(registry.getPackage('com.beta.crm')).toBeUndefined(); + expect(registry.getNamespaceOwners('crm')).toEqual(['com.acme.crm']); + }); + + it('allows the same package to reinstall/reload its own namespace', () => { + registry.installPackage(manifest('com.acme.crm', 'crm') as any); + expect(() => + registry.installPackage(manifest('com.acme.crm', 'crm') as any), + ).not.toThrow(); + }); + + it('allows two packages with distinct namespaces', () => { + registry.installPackage(manifest('com.acme.crm', 'crm') as any); + expect(() => + registry.installPackage(manifest('com.acme.hr', 'hr') as any), + ).not.toThrow(); + }); + + it('exempts shareable platform namespaces (base/system/sys)', () => { + for (const ns of ['base', 'system', 'sys']) { + registry.installPackage(manifest(`com.a.${ns}`, ns) as any); + expect(() => + registry.installPackage(manifest(`com.b.${ns}`, ns) as any), + ).not.toThrow(); + } + }); + + it('downgrades to a warning under collisionPolicy "warn"', () => { + const warnReg = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'warn' }); + warnReg.logLevel = 'silent'; + warnReg.installPackage(manifest('com.acme.crm', 'crm') as any); + expect(() => + warnReg.installPackage(manifest('com.beta.crm', 'crm') as any), + ).not.toThrow(); + // Both packages are recorded; the namespace now has two owners. + expect(warnReg.getNamespaceOwners('crm').sort()).toEqual(['com.acme.crm', 'com.beta.crm']); + }); + + it('releases the namespace on uninstall, allowing a different package to claim it', () => { + registry.installPackage(manifest('com.acme.crm', 'crm') as any); + registry.uninstallPackage('com.acme.crm'); + expect(() => + registry.installPackage(manifest('com.beta.crm', 'crm') as any), + ).not.toThrow(); + expect(registry.getNamespaceOwners('crm')).toEqual(['com.beta.crm']); + }); +}); diff --git a/packages/objectql/src/registry-prefer-local-resolution.test.ts b/packages/objectql/src/registry-prefer-local-resolution.test.ts new file mode 100644 index 0000000000..bcf964f668 --- /dev/null +++ b/packages/objectql/src/registry-prefer-local-resolution.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0048 Phase 2 — prefer-local (container-scoped) resolution. + * + * With the install-time namespace gate (Phase 1) keeping namespaces distinct, + * two packages may legitimately ship the same bare name (e.g. `page/home`). + * They no longer collide at registration; instead `getItem(type, name, ns)` + * routes each caller to the item owned by its own namespace's package. The + * per-item collision guard now fires only where prefer-local CANNOT + * disambiguate (shared / missing namespace). + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { SchemaRegistry } from './registry'; + +const install = (reg: SchemaRegistry, id: string, namespace: string) => + reg.installPackage({ id, name: id, namespace, version: '1.0.0' } as any); + +describe('SchemaRegistry — prefer-local resolution (ADR-0048 Phase 2)', () => { + let registry: SchemaRegistry; + + beforeEach(() => { + registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + registry.logLevel = 'silent'; + install(registry, 'com.acme.crm', 'crm'); + install(registry, 'com.acme.hr', 'hr'); + }); + + it('lets two distinct-namespace packages coexist on the same bare name', () => { + registry.registerItem('page', { name: 'home', title: 'CRM Home' }, 'name', 'com.acme.crm'); + expect(() => + registry.registerItem('page', { name: 'home', title: 'HR Home' }, 'name', 'com.acme.hr'), + ).not.toThrow(); + }); + + it('resolves prefer-local to the namespace owner', () => { + registry.registerItem('page', { name: 'home', title: 'CRM Home' }, 'name', 'com.acme.crm'); + registry.registerItem('page', { name: 'home', title: 'HR Home' }, 'name', 'com.acme.hr'); + + expect(registry.getItem('page', 'home', 'crm')?.title).toBe('CRM Home'); + expect(registry.getItem('page', 'home', 'hr')?.title).toBe('HR Home'); + }); + + it('context-free getItem still returns one of the entries (legacy first-match fallback)', () => { + registry.registerItem('page', { name: 'home', title: 'CRM Home' }, 'name', 'com.acme.crm'); + registry.registerItem('page', { name: 'home', title: 'HR Home' }, 'name', 'com.acme.hr'); + + const got = registry.getItem('page', 'home'); + expect(['CRM Home', 'HR Home']).toContain(got?.title); + }); + + it('keeps runtime/DB overlay (bare key) precedence over prefer-local (ADR-0005)', () => { + registry.registerItem('page', { name: 'home', title: 'CRM Home' }, 'name', 'com.acme.crm'); + // Runtime-authored overlay under the bare key (no package provenance). + registry.registerItem('page', { name: 'home', title: 'overlay' }, 'name'); + + expect(registry.getItem('page', 'home', 'crm')?.title).toBe('overlay'); + }); + + it('falls back to first-match when the namespace owns no such item', () => { + registry.registerItem('page', { name: 'home', title: 'CRM Home' }, 'name', 'com.acme.crm'); + // hr has no `home`; asking within hr's container falls back to the only entry. + expect(registry.getItem('page', 'home', 'hr')?.title).toBe('CRM Home'); + }); + + it('still fails loudly when two packages SHARE a namespace (unresolvable)', () => { + const reg = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + reg.logLevel = 'silent'; + // `sys` is a shareable platform namespace, exempt from the install gate, so + // two packages CAN both own it — but then a same-named item is ambiguous. + install(reg, 'com.a.sys', 'sys'); + install(reg, 'com.b.sys', 'sys'); + reg.registerItem('flow', { name: 'cleanup' }, 'name', 'com.a.sys'); + expect(() => + reg.registerItem('flow', { name: 'cleanup' }, 'name', 'com.b.sys'), + ).toThrow(); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 928b0805ac..acb2c81d0d 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -317,6 +317,17 @@ function isRealPackage(pkg: unknown): pkg is string { return typeof pkg === 'string' && pkg.length > 0 && pkg !== SYS_METADATA_OWNER; } +/** + * Platform namespaces that multiple packages may legitimately share, so the + * install-time namespace-uniqueness gate (ADR-0048 Phase 1) must never fire on + * them: the FQN-exempt reserved namespaces (`base`, `system`) plus `sys` + * (system objects such as `sys_metadata` are contributed by many packages — see + * {@link SchemaRegistry.registerNamespace}, which is intentionally many-to-one). + */ +function isShareableNamespace(ns: string): boolean { + return RESERVED_NAMESPACES.has(ns) || ns === 'sys'; +} + /** * Raised when two **different** code packages register a generic (non-object) * metadata item under the same `(type, name)` in the code-defined base layer @@ -357,6 +368,43 @@ export class MetadataCollisionError extends Error { } } +/** + * Raised when a package is installed whose `manifest.namespace` is already owned + * by a **different** installed package in this installation (ADR-0048 Phase 1). + * + * The namespace is the mandatory object-name prefix (`${namespace}_${shortName}`) + * and — once installed — the container that scopes a package's UI/automation + * metadata. Two packages sharing a namespace would collide at the object/table + * layer (a duplicate `CREATE TABLE crm_account` already fails loudly at the DB) + * and would make container-scoped resolution ambiguous. This gate refuses the + * install up front with an actionable error, instead of letting a half-applied + * install blow up later at table creation. Shareable platform namespaces + * (`base`/`system`/`sys`) are exempt. + */ +export class NamespaceConflictError extends Error { + readonly namespace: string; + readonly existingPackageId: string; + readonly incomingPackageId: string; + + constructor(namespace: string, existingPackageId: string, incomingPackageId: string) { + super( + `Namespace conflict: namespace "${namespace}" is already owned by ` + + `package "${existingPackageId}", so package "${incomingPackageId}" ` + + `cannot be installed alongside it. A namespace is the mandatory prefix ` + + `of every object name (e.g. "${namespace}_account") and the container ` + + `that scopes a package's UI metadata, so it must be unique per ` + + `installation. Choose a different namespace for "${incomingPackageId}", ` + + `or uninstall "${existingPackageId}" first. If this is a deliberate ` + + `migration, set OS_METADATA_COLLISION=warn to downgrade to a warning. ` + + `See ADR-0048.`, + ); + this.name = 'NamespaceConflictError'; + this.namespace = namespace; + this.existingPackageId = existingPackageId; + this.incomingPackageId = incomingPackageId; + } +} + export class SchemaRegistry { // ========================================== // Logging control @@ -837,7 +885,15 @@ export class SchemaRegistry { // artifact-vs-DB warning below. if (isRealPackage(packageId)) { const conflictOwner = this.findOtherPackageOwner(collection, baseName, packageId); - if (conflictOwner) { + if (conflictOwner && !this.isPreferLocalDisambiguable(packageId, conflictOwner)) { + // ADR-0048 Phase 2 — the guard now only fires when prefer-local + // resolution (see getItem) CANNOT disambiguate the two owners: they + // share a namespace (possible only for shareable platform namespaces + // like `sys`, which the install gate exempts) or one lacks a namespace + // (legacy package with no container to scope by). Two packages in + // DIFFERENT namespaces legitimately coexist on the same bare name — + // the install-time namespace gate keeps their namespaces distinct and + // prefer-local routes each caller to its own container. const err = new MetadataCollisionError(type, baseName, conflictOwner, packageId); if (this.collisionPolicy === 'warn') { console.warn(`[Registry] ${err.message}`); @@ -894,6 +950,21 @@ export class SchemaRegistry { return undefined; } + /** + * True when prefer-local resolution ({@link getItem}) can route callers in + * each owner's container to the right item — i.e. the two packages declare + * **distinct, non-empty namespaces** (ADR-0048 Phase 2). When it returns + * false (shared namespace, or a missing namespace on either side) a same + * `(type, name)` clash is genuinely unresolvable and the collision guard + * fires. Namespaces are read from the installed-package records, which the + * install path registers before a package's metadata is loaded. + */ + private isPreferLocalDisambiguable(incoming: string, owner: string): boolean { + const incomingNs = this.getPackage(incoming)?.manifest?.namespace; + const ownerNs = this.getPackage(owner)?.manifest?.namespace; + return !!incomingNs && !!ownerNs && incomingNs !== ownerNs; + } + /** * Validate Metadata against Spec Zod Schemas */ @@ -939,19 +1010,40 @@ export class SchemaRegistry { } /** - * Universal Get Method + * Universal Get Method. + * + * ADR-0048 Phase 2 — *prefer-local* resolution. When `currentNamespace` is + * given (the container the caller is resolving within), a bare name resolves + * to the item owned by *that namespace's* package before any cross-package + * fallback, so two packages shipping e.g. `page/home` no longer resolve by + * registration order (first-match-wins). Omitting `currentNamespace` + * preserves the legacy resolution exactly, so this is backward compatible. + * + * Precedence (highest first): + * 1. bare-key runtime/DB overlay (ADR-0005 sanctioned override) — unchanged + * 2. the `currentNamespace` owner's composite entry (prefer-local) + * 3. first composite match (legacy first-registered-wins fallback) */ - getItem(type: string, name: string): T | undefined { + getItem(type: string, name: string, currentNamespace?: string): T | undefined { // Special handling for 'object' and 'objects' types - use objectContributors if (type === 'object' || type === 'objects') { return this.getObject(name) as unknown as T | undefined; } - + const collection = this.metadata.get(type); if (!collection) return undefined; const direct = collection.get(name); if (direct) return direct as T; - // Scan for composite keys + + // Prefer-local: resolve within the caller's container (namespace) first. + if (currentNamespace) { + for (const owner of this.getNamespaceOwners(currentNamespace)) { + const local = collection.get(`${owner}:${name}`); + if (local) return local as T; + } + } + + // Fallback: first composite key matching the bare name (legacy behaviour). for (const [key, item] of collection) { if (key.endsWith(`:${name}`)) { return item as T; @@ -1080,6 +1172,30 @@ export class SchemaRegistry { // ========================================== installPackage(manifest: ObjectStackManifest, settings?: Record): InstalledPackage { + // ADR-0048 Phase 1 — install-time namespace gate. Refuse a package whose + // namespace is already owned by a *different* installed package; this is + // the constraint the object/table layer enforces implicitly (a duplicate + // `CREATE TABLE _` fails at the DB), made explicit and early. + // Same-package reinstall/reload is excluded (owner === manifest.id), and + // shareable platform namespaces (base/system/sys) are exempt. + if (manifest.namespace && !isShareableNamespace(manifest.namespace)) { + const conflictOwner = this.getNamespaceOwners(manifest.namespace).find( + (owner) => owner !== manifest.id, + ); + if (conflictOwner) { + if (this.collisionPolicy === 'warn') { + console.warn( + `[Registry] Namespace conflict (downgraded to warning via ` + + `OS_METADATA_COLLISION=warn): namespace "${manifest.namespace}" is ` + + `already owned by "${conflictOwner}"; installing "${manifest.id}" ` + + `anyway. See ADR-0048.`, + ); + } else { + throw new NamespaceConflictError(manifest.namespace, conflictOwner, manifest.id); + } + } + } + const now = new Date().toISOString(); const disabled = this.initialDisabledPackageIds.has(manifest.id); const pkg: InstalledPackage = { @@ -1175,7 +1291,10 @@ export class SchemaRegistry { } getApp(name: string): any { - const app = this.getItem('app', name); + // ADR-0048 (v1) — one app per package, with `app.name ≡ manifest.namespace`, + // so the app name *is* its own container: resolve prefer-local against it so + // two packages' apps never resolve by registration order. + const app = this.getItem('app', name, name); if (!app) return app; return this.applyNavContributions(app); } diff --git a/packages/spec/src/system/doc.zod.ts b/packages/spec/src/system/doc.zod.ts index 37dd3a9c52..2a29caf5f0 100644 --- a/packages/spec/src/system/doc.zod.ts +++ b/packages/spec/src/system/doc.zod.ts @@ -41,6 +41,16 @@ export const DocSchema = lazySchema(() => z.object({ label: z.string().optional() .describe('Display title; defaults to the first `#` heading, then the name'), + /** + * One-line summary for listings (the docs portal renders it under the + * title). The CLI reads it from frontmatter `description:`. Optional and + * short by convention — it travels in the list response (unlike + * `content`, which the REST list omits by default), so a portal can show + * summaries without fetching each doc's body. + */ + description: z.string().optional() + .describe('One-line summary for listings; from frontmatter `description:`'), + /** * Raw Markdown body (CommonMark + GFM), frontmatter stripped. * MDX and image references are banned in v1 (ADR-0046 §3.4) — diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index ce17d95067..1b50b4db22 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -645,6 +645,8 @@ src/docs/ a bare `user_guide` would collide across packages and fail at install (ADR-0048). 3. **Title** resolves: frontmatter `title:` → first `#` heading → `name`. + Optional frontmatter `description:` is a one-line summary the docs portal + shows under the title — add it on index/overview docs. 4. **Pure Markdown.** CommonMark + GFM only, plus heading anchors, fenced code highlighting, and GitHub alerts (`> [!NOTE]`, `> [!WARNING]`, …). **MDX and image references are rejected at build time** — docs are @@ -685,6 +687,7 @@ package) is a later, additive concern — author-side, nothing to model now. ```md --- title: CRM Overview +description: Accounts, contacts, and opportunities — start here. --- # CRM