From f1993f8aa8566cf5964695cad146c4206fc09a70 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 14:22:55 +0000 Subject: [PATCH 01/19] wip(spec): declare the reverse-domain manifest id rule once Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- packages/spec/src/kernel/manifest.zod.ts | 90 +++++++++++++++++++- packages/spec/src/marketplace/package.zod.ts | 7 +- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/kernel/manifest.zod.ts b/packages/spec/src/kernel/manifest.zod.ts index 68648337324..4cd127033f6 100644 --- a/packages/spec/src/kernel/manifest.zod.ts +++ b/packages/spec/src/kernel/manifest.zod.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { CORE_PLUGIN_TYPES } from './plugin.zod'; import { retiredKey } from '../shared/retired-key'; import { strictObject } from '../shared/strict-object'; +import { formatSuggestion } from '../shared/suggestions.zod'; import { SeedSchema } from '../data/seed.zod'; import { NavigationContributionSchema } from '../ui/app.zod'; @@ -241,6 +242,77 @@ export type PluginIntegrity = z.input; * - "./src/objects/*.object.yml" * ``` */ +/** + * The reverse-domain identifier rule, declared ONCE. + * + * Two surfaces name the same identity — `ManifestSchema.id` here (what an + * author writes) and `PackageSchema.manifestId` in + * `../marketplace/package.zod.ts` (what the registry stores and addresses the + * package by, `manifest_id`). They were two independent declarations and drifted: + * the registry enforced the shape and the authoring surface accepted any string, + * so a package that scaffolded, validated and booted was refused at publish. + * Both sites now reference this constant, which is what makes a future drift a + * one-line edit rather than a silent divergence. + * + * Reads as: a lowercase segment, then one or more dot-separated lowercase + * segments. Each segment starts with a letter and may carry digits and hyphens. + * ⛔ Underscores are NOT admitted — `manifest.namespace` allows them and this + * key does not, so a namespace is never a legal id by itself. + */ +export const MANIFEST_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/; + +/** + * The example ids the refusal shows an author, and the same two the TSDoc on + * {@link ManifestSchema}'s `id` carries as `@example`. + * + * They are one list so the refusal cannot show an example the schema would + * reject: `manifest.test.ts` asserts every entry here matches + * {@link MANIFEST_ID_PATTERN}. An example that fails its own rule teaches the + * exact wrong thing to the author who is already stuck. + */ +export const MANIFEST_ID_EXAMPLES = ['com.steedos.crm', 'org.apache.superset'] as const; + +/** + * The remedy a rejected package id carries (#4001: a refusal names the key, + * echoes the value, and prescribes the fix). + * + * The suggestion arm is deliberately conditional. A bare word — the shape the + * scaffolder and `os init` used to produce, and what an author reaches for + * first — has one obvious repair, `com.example.`, and offering it is the + * whole difference between a rule restated and a fix. But a bare word may itself + * be unusable: `manifest.namespace` admits underscores, so `my_app` prefixed + * still fails this pattern. So the candidate is VERIFIED against the pattern + * before it is offered, hyphenating underscores when that is what rescues it, + * and no suggestion is made at all when nothing mechanical does. + * + * @param key - The authoring path to name, e.g. `manifest.id` or `manifestId`. + * @param input - Whatever the author actually wrote. + */ +export function manifestIdRefusal(key: string, input: unknown): string { + const received = typeof input === 'string' ? input : String(input ?? ''); + const examples = MANIFEST_ID_EXAMPLES.map((e) => `'${e}'`).join(', '); + const base = + `Invalid package id '${received}' on \`${key}\`. Expected reverse-domain notation ` + + `(${examples}) — lowercase dot-separated segments; hyphens allowed inside a segment, ` + + 'underscores are not.'; + + // Two mechanical repairs, tried in order, and only ever OFFERED once the + // candidate has been checked against the pattern itself: + // • a value already carrying a dot is trying to be reverse-domain — the + // usual break is an underscore, so hyphenate and re-check; + // • a bare word has no prefix at all, so prefix it with the documentation + // namespace (and hyphenate, for a namespace-shaped `my_app`). + const candidates = received.includes('.') + ? [received.replace(/_/g, '-')] + : [`com.example.${received}`, `com.example.${received.replace(/_/g, '-')}`]; + if (received.length > 0) { + for (const candidate of candidates) { + if (MANIFEST_ID_PATTERN.test(candidate)) return `${base} ${formatSuggestion([candidate])}`; + } + } + return base; +} + export const ManifestSchema = strictObject({ surface: 'this package manifest', history: @@ -260,12 +332,24 @@ export const ManifestSchema = strictObject({ }, { /** * Unique package identifier using reverse domain notation. - * Must be unique across the entire ecosystem. - * + * Must be unique across the entire ecosystem — so a package that is never + * published still carries a name for the ecosystem it may one day join, which + * is why the rule holds for a private app exactly as for a listed one. + * + * Enforced by {@link MANIFEST_ID_PATTERN}, the single declaration this key + * shares with `PackageSchema.manifestId` (`../marketplace/package.zod.ts`). + * ⛔ `manifest.namespace` is NOT an id: it admits underscores and carries no + * dot, so it fails this rule by construction. + * + * Both examples below are held against the pattern by `manifest.test.ts` via + * {@link MANIFEST_ID_EXAMPLES}. + * * @example "com.steedos.crm" * @example "org.apache.superset" */ - id: z.string().describe('Unique package identifier (reverse domain style)'), + id: z.string() + .regex(MANIFEST_ID_PATTERN, { error: (iss) => manifestIdRefusal('manifest.id', iss.input) }) + .describe('Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm)'), /** * Short namespace identifier for metadata scoping AND the mandatory diff --git a/packages/spec/src/marketplace/package.zod.ts b/packages/spec/src/marketplace/package.zod.ts index da445ccd424..a775afdd30c 100644 --- a/packages/spec/src/marketplace/package.zod.ts +++ b/packages/spec/src/marketplace/package.zod.ts @@ -28,6 +28,11 @@ import { z } from 'zod'; * Package visibility — controls who can discover and install the package. */ import { lazySchema } from '../shared/lazy-schema'; +// The reverse-domain rule is declared ONCE, on the authoring surface that owns +// it (`ManifestSchema.id`), and referenced here. This registry field and that +// authoring key name the same identity; two independent copies of the pattern +// are what let them drift apart in the first place. +import { MANIFEST_ID_PATTERN, manifestIdRefusal } from '../kernel/manifest.zod'; export const PackageVisibilitySchema = lazySchema(() => z .enum(['private', 'org', 'marketplace']) .describe( @@ -166,7 +171,7 @@ export const PackageSchema = lazySchema(() => z.object({ */ manifestId: z .string() - .regex(/^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/) + .regex(MANIFEST_ID_PATTERN, { error: (iss) => manifestIdRefusal('manifestId', iss.input) }) .describe('Globally unique reverse-domain package identifier (e.g. com.acme.crm)'), /** From a41581e88032eb2a9c94fe3601ed741ba77da1a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 14:37:40 +0000 Subject: [PATCH 02/19] feat(spec)!: manifest.id enforces the reverse-domain rule, declared once Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../17534-manifest-id-reverse-domain.md | 55 ++++++++ packages/cli/src/commands/init.ts | 33 ++++- packages/cli/test/init-manifest-id.test.ts | 69 +++++++++ packages/create-objectstack/package.json | 1 + packages/create-objectstack/src/index.ts | 5 +- .../src/rewrite-identity.test.ts | 61 ++++++++ .../src/rewrite-identity.ts | 38 +++++ .../src/templates/blank/objectstack.config.ts | 2 +- packages/spec/src/kernel/manifest.test.ts | 132 +++++++++++++++++- .../18.manifest-id-reverse-domain-required.ts | 48 +++++++ packages/spec/src/migrations/registry.ts | 44 ++++++ pnpm-lock.yaml | 3 + 12 files changed, 485 insertions(+), 6 deletions(-) create mode 100644 .changeset/17534-manifest-id-reverse-domain.md create mode 100644 packages/cli/test/init-manifest-id.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.manifest-id-reverse-domain-required.ts diff --git a/.changeset/17534-manifest-id-reverse-domain.md b/.changeset/17534-manifest-id-reverse-domain.md new file mode 100644 index 00000000000..af77d7bf96a --- /dev/null +++ b/.changeset/17534-manifest-id-reverse-domain.md @@ -0,0 +1,55 @@ +--- +"@objectstack/spec": minor +"@objectstack/cli": minor +"create-objectstack": minor +--- + +feat(spec)!: `manifest.id` enforces the reverse-domain rule its registry face already had (#17534) + + + +**BREAKING** in the accept-set sense, landing in the launch window as `minor` +(the repo's convention: `major` is refused by `check-changeset-no-major`, and +breaking-ness is carried by this banner plus the ADR-0087 disposition): +`ManifestSchema.id` was `z.string()` and accepted any string. It now enforces +reverse-domain notation — the same rule `PackageSchema.manifestId` has always +carried, now declared once and referenced from both sites so the two cannot +drift again. + +Two declarations named one identity and disagreed. The registry enforced the +shape; the key an author actually writes did not. So a package scaffolded, +validated, built and booted with an id the publish path would refuse, and the +author met the rule for the first time at the most expensive possible moment. + +FROM → TO, for metadata that used to parse and now fails: + +```ts +// FROM — accepted by defineStack, refused at publish +defineStack({ manifest: { id: 'my_app', /* … */ } }); +defineStack({ manifest: { id: 'com.acme.my_app', /* … */ } }); + +// TO — dot-separated lowercase segments; hyphens inside a segment, never underscores +defineStack({ manifest: { id: 'com.example.my-app', /* … */ } }); +defineStack({ manifest: { id: 'com.acme.my-app', /* … */ } }); +``` + +The refusal carries the repair rather than restating the rule: it names the key, +echoes the value, shows both documented examples, and — having first checked the +candidate against the pattern itself — suggests `com.example.blank` for a bare +word and `com.dogfood.flow-fixture` for a value whose only fault is an +underscore. A suggestion it cannot verify it does not make. + +⚠️ **Changing an id is a republish, not an edit.** An id is an identity: the +registry addresses a package by `manifest_id`, an installed row is keyed on it +and a dependent declares it. Before renaming, confirm nothing still addresses +the old value. That is why this ships as an ADR-0087 **semantic** entry +(`manifest-id-reverse-domain-required`) with a structured TODO and no automatic +rewrite — `objectstack migrate meta` will not rename an id for you. + +`manifest.namespace` is unchanged and still admits underscores, so the two are +derived from a project name under different rules and neither is the other. Both +scaffolders were producing ids the new rule refuses and both now derive a +conforming one: the bundled `create-objectstack` template ships +`com.example.blank` and interpolates `com.example.` in kebab form, +and `os init` derives its id from the project name instead of interpolating the +snake_case namespace (`os init my-app` produced `com.example.my_app`). diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index b396475b5f0..901251669f9 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -75,6 +75,33 @@ export function sanitizeNamespace(name: string): string { return s; } +/** + * Convert an npm package name into the last segment of a reverse-domain + * package id. + * + * `manifest.id` and `manifest.namespace` are derived from the same project name + * under CONTRADICTORY rules, which is why this cannot call + * {@link sanitizeNamespace}: a namespace is snake_case by rule + * (`^[a-z][a-z0-9_]{1,19}$`), an id segment admits hyphens and refuses + * underscores (`MANIFEST_ID_PATTERN`, `@objectstack/spec/kernel`). `os init + * my-app` sanitizes to the namespace `my_app`, and the id this scaffold used to + * interpolate it into — `com.example.my_app` — is refused by the schema the + * scaffold must satisfy on its very first `os validate`. + * + * No length cap: unlike a namespace, an id segment has none. + * + * Held against the real pattern by `init-manifest-id.test.ts`. + */ +export function manifestIdSlug(name: string): string { + let s = name.replace(/^@[^/]+\//, ''); // drop npm scope + s = s.toLowerCase().replace(/[^a-z0-9]+/g, '-'); // separators → - + s = s.replace(/^-+|-+$/g, ''); // trim hyphens + // A segment must OPEN with a letter, so an empty or digit-leading name takes + // a literal prefix instead of producing a silently invalid id. + if (!/^[a-z]/.test(s)) s = `app-${s}`.replace(/-+$/, ''); + return s; +} + /** * Native dependencies the scaffold pulls in (transitively) that need their * build scripts to run at install time. pnpm 10+ blocks dependency build @@ -591,7 +618,7 @@ import * as objects from './src/objects'; export default defineStack({ manifest: { - id: 'com.example.${namespace}', + id: 'com.example.${manifestIdSlug(name)}', namespace: '${namespace}', version: '0.1.0', type: 'app', @@ -679,7 +706,7 @@ import * as objects from './src/objects'; export default defineStack({ manifest: { - id: 'com.objectstack.plugin-${name}', + id: 'com.objectstack.plugin-${manifestIdSlug(name)}', namespace: '${namespace}', version: '0.1.0', type: 'plugin', @@ -751,7 +778,7 @@ export default ${toCamelCase(namespace)}Item; export default defineStack({ manifest: { - id: 'com.example.${namespace}', + id: 'com.example.${manifestIdSlug(name)}', namespace: '${namespace}', version: '0.1.0', type: 'app', diff --git a/packages/cli/test/init-manifest-id.test.ts b/packages/cli/test/init-manifest-id.test.ts new file mode 100644 index 00000000000..904783bb1c5 --- /dev/null +++ b/packages/cli/test/init-manifest-id.test.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `os init` must scaffold a project whose `manifest.id` the spec accepts — +// pinned against the spec's own exported rule, never a restatement of it. +// +// The defect this holds shut (#17534): every template interpolated the +// NAMESPACE into the id (`com.example.${namespace}`). A namespace is snake_case +// by rule and an id segment refuses underscores, so `os init my-app` — the very +// name the neighbouring scaffold tests use — wrote `com.example.my_app`, which +// `ManifestSchema` refuses. The two identifiers are derived from one project +// name under contradictory rules; deriving either from the other is the bug. + +import { describe, it, expect } from 'vitest'; +import { MANIFEST_ID_PATTERN, ManifestSchema } from '@objectstack/spec/kernel'; +import { TEMPLATES, sanitizeNamespace, manifestIdSlug } from '../src/commands/init.js'; + +/** The `id:` literal a template's rendered config declares. */ +function renderedId(templateKey: string, projectName: string): string | undefined { + const cfg = TEMPLATES[templateKey].configContent(projectName, sanitizeNamespace(projectName)); + return /\bid:\s*'([^']+)'/.exec(cfg)?.[1]; +} + +// `my-app` is the name the other init scaffold tests use, and `my_app` is a +// name a user can type: npm accepts it and the namespace sanitizer leaves it +// alone, so it is the shortest path to the underscore this rule refuses. +const PROJECT_NAMES = ['my-app', 'my_app', 'MyApp', 'support desk', '@acme/crm', '2fa']; + +describe('os init scaffolds a conforming manifest.id', () => { + it.each(Object.keys(TEMPLATES))('template "%s"', (templateKey) => { + for (const name of PROJECT_NAMES) { + const id = renderedId(templateKey, name); + expect(id, `template ${templateKey} must declare a manifest id`).toBeTruthy(); + expect(MANIFEST_ID_PATTERN.test(id as string), `${templateKey} + ${name} → ${id}`).toBe(true); + // The pattern is necessary but the schema is the authority, so ask it too. + const parsed = ManifestSchema.safeParse({ id, version: '1.0.0', type: 'app', name: 'X' }); + expect(parsed.success, `${templateKey} + ${name} → ${id} must parse`).toBe(true); + } + }); + + it('never interpolates the namespace into the id — the two rules contradict', () => { + // The regression in one line: the namespace for `my-app` is `my_app`, and + // no template may carry that value inside its id. + expect(sanitizeNamespace('my-app')).toBe('my_app'); + for (const templateKey of Object.keys(TEMPLATES)) { + expect(renderedId(templateKey, 'my-app')).not.toContain('my_app'); + } + }); +}); + +describe('manifestIdSlug', () => { + it.each([ + ['my-app', 'my-app'], + ['my_app', 'my-app'], + ['MyApp', 'myapp'], + ['support desk', 'support-desk'], + ['@acme/crm', 'crm'], + ['2fa', 'app-2fa'], + ['', 'app'], + ['---', 'app'], + ])('%s → %s', (input, expected) => { + expect(manifestIdSlug(input)).toBe(expected); + }); + + it('is prefixable into an id the spec accepts, for every name tried', () => { + for (const name of [...PROJECT_NAMES, '', '---', 'ünïcödé', 'a']) { + expect(MANIFEST_ID_PATTERN.test(`com.example.${manifestIdSlug(name)}`), name).toBe(true); + } + }); +}); diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index 80f367d0269..49e071ff2a9 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -31,6 +31,7 @@ "commander": "^15.0.0" }, "devDependencies": { + "@objectstack/spec": "workspace:*", "@types/node": "^26.2.0", "tsup": "^8.5.1", "typescript": "^6.0.3", diff --git a/packages/create-objectstack/src/index.ts b/packages/create-objectstack/src/index.ts index 1f4ac6cb9c4..3cb3c1b6f9c 100644 --- a/packages/create-objectstack/src/index.ts +++ b/packages/create-objectstack/src/index.ts @@ -66,6 +66,7 @@ import { fileURLToPath } from 'node:url'; import { syncObjectStackDeps } from './pkg-utils.js'; import { copyDir } from './template-copy.js'; import { + deriveManifestId, readTemplateNamespace, rewriteObjectNamePrefix, findStaleNamespacePrefixes, @@ -220,7 +221,9 @@ function rewriteProjectIdentity( const configPath = path.join(targetDir, 'objectstack.config.ts'); if (fs.existsSync(configPath)) { let cfg = fs.readFileSync(configPath, 'utf8'); - cfg = cfg.replace(/(\bid:\s*)(['"`])[^'"`]*\2/, `$1$2${projectName}$2`); + // `manifest.id` is a reverse-domain identifier, not a bare word — see + // deriveManifestId() for why the namespace cannot be reused for it. + cfg = cfg.replace(/(\bid:\s*)(['"`])[^'"`]*\2/, `$1$2${deriveManifestId(projectName)}$2`); cfg = cfg.replace(/(\bnamespace:\s*)(['"`])[^'"`]*\2/, `$1$2${namespace}$2`); cfg = cfg.replace(/(\bname:\s*)(['"`])[^'"`]*\2/, `$1$2${title}$2`); cfg = cfg.replace(/^[ \t]*description:\s*(['"`])[^'"`]*\1,?\r?\n/m, ''); diff --git a/packages/create-objectstack/src/rewrite-identity.test.ts b/packages/create-objectstack/src/rewrite-identity.test.ts index 1b41ce72d0b..ffa41bb1e53 100644 --- a/packages/create-objectstack/src/rewrite-identity.test.ts +++ b/packages/create-objectstack/src/rewrite-identity.test.ts @@ -8,7 +8,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { MANIFEST_ID_PATTERN } from '@objectstack/spec/kernel'; import { + deriveManifestId, readTemplateNamespace, rewriteObjectNamePrefix, findStaleNamespacePrefixes, @@ -130,3 +133,61 @@ describe('findStaleNamespacePrefixes', () => { expect(stale[0].line).toBeGreaterThan(0); }); }); + +// ── deriveManifestId — the pin the scaffold's id is held against ───────────── +// +// The rule is imported, never restated. A local copy of the regex is the very +// defect #17534 fixed one level up: `ManifestSchema.id` and +// `PackageSchema.manifestId` were two copies of one rule and drifted, so the +// scaffold satisfied the one that was not enforced. This asserts against the +// exported `MANIFEST_ID_PATTERN` so a future change to the rule fails HERE, in +// the scaffolder, rather than in the user's first `os validate`. +describe('deriveManifestId', () => { + it.each([ + ['my-app', 'com.example.my-app'], + ['myapp', 'com.example.myapp'], + // The case prerequisite 4 names: a project name the namespace sanitizer + // turns into `my_app`, which is NOT a legal id segment. + ['my_app', 'com.example.my-app'], + ['My App', 'com.example.my-app'], + ['@acme/support-desk', 'com.example.support-desk'], + ['support.desk', 'com.example.support-desk'], + ['--leading-and-trailing--', 'com.example.leading-and-trailing'], + // A segment must OPEN with a letter. + ['123', 'com.example.app-123'], + ['', 'com.example.app'], + ])('%s → %s', (input, expected) => { + expect(deriveManifestId(input)).toBe(expected); + }); + + it('every derived id satisfies the spec rule itself', () => { + const names = [ + 'my-app', 'my_app', 'My App', '@acme/support-desk', 'support.desk', + '123', '', '---', 'a', 'UPPER_CASE_NAME', 'name with spaces', 'ünïcödé-app', + ]; + for (const name of names) { + const id = deriveManifestId(name); + expect(MANIFEST_ID_PATTERN.test(id), `${JSON.stringify(name)} → ${id}`).toBe(true); + } + }); + + it('is not the namespace rule — the two disagree on the underscore', () => { + // sanitizeNamespace('my-app') is 'my_app'; reusing it as the id produced + // `com.example.my_app`, which the schema refuses. + expect(deriveManifestId('my-app')).not.toContain('_'); + }); +}); + +// The bundled template is the one scaffold output that ships as checked-in +// source, so it is pinned directly rather than through the derivation. +describe('the bundled blank template', () => { + it('declares a manifest id the spec accepts', () => { + const config = fs.readFileSync( + path.join(path.dirname(fileURLToPath(import.meta.url)), 'templates', 'blank', 'objectstack.config.ts'), + 'utf8', + ); + const id = /\bid:\s*'([^']+)'/.exec(config)?.[1]; + expect(id, 'the template must declare a manifest id').toBeTruthy(); + expect(MANIFEST_ID_PATTERN.test(id as string), `template id ${id}`).toBe(true); + }); +}); diff --git a/packages/create-objectstack/src/rewrite-identity.ts b/packages/create-objectstack/src/rewrite-identity.ts index 41e7bb1b70c..0b0a7185e92 100644 --- a/packages/create-objectstack/src/rewrite-identity.ts +++ b/packages/create-objectstack/src/rewrite-identity.ts @@ -78,6 +78,44 @@ export function readTemplateNamespace(targetDir: string): string | undefined { return undefined; } +/** + * The reverse-domain prefix a scaffolded project is named under. + * + * `example.com` is the IETF-reserved documentation domain (RFC 2606), so a + * scaffold can carry it without colliding with anyone's real namespace, and an + * author who publishes is told to change it rather than discovering a clash. + */ +const SCAFFOLD_ID_PREFIX = 'com.example.'; + +/** + * The package id a scaffolded project gets, derived from its project name. + * + * `manifest.id` is a reverse-domain identifier (`MANIFEST_ID_PATTERN`, + * `@objectstack/spec/kernel`): dot-separated lowercase segments, hyphens + * allowed inside a segment, **underscores not**. That last clause is why this + * cannot reuse `sanitizeNamespace`: a namespace is snake_case by rule, so + * `my-app` sanitizes to the namespace `my_app`, and `com.example.my_app` is + * refused by the very schema the scaffold has to satisfy. The two identifiers + * are derived from the same project name under DIFFERENT rules, and deriving + * one from the other is the bug. + * + * Nor can the raw project name be used: `id: ''` is what shipped, + * and a bare word carries no dot at all, so every scaffolded project failed + * `manifest.id` the moment the rule was enforced. + * + * Held against the real pattern by `rewrite-identity.test.ts`. + */ +export function deriveManifestId(projectName: string): string { + // Drop an npm scope: `@acme/my-app` is the project `my-app`. + let s = projectName.replace(/^@[^/]+\//, ''); + s = s.toLowerCase().replace(/[^a-z0-9]+/g, '-'); + s = s.replace(/^-+|-+$/g, ''); + // A segment must OPEN with a letter, so a name that is empty or starts with a + // digit gets a literal prefix rather than a silently invalid id. + if (!/^[a-z]/.test(s)) s = `app-${s}`.replace(/-+$/, ''); + return `${SCAFFOLD_ID_PREFIX}${s}`; +} + /** Every `*.ts` file under `dir`, recursively. Missing dir → empty. */ function tsFiles(dir: string, out: string[] = []): string[] { if (!fs.existsSync(dir)) return out; diff --git a/packages/create-objectstack/src/templates/blank/objectstack.config.ts b/packages/create-objectstack/src/templates/blank/objectstack.config.ts index fb07e6e17fd..84a65033903 100644 --- a/packages/create-objectstack/src/templates/blank/objectstack.config.ts +++ b/packages/create-objectstack/src/templates/blank/objectstack.config.ts @@ -6,7 +6,7 @@ import * as objects from './src/objects/index.js'; export default defineStack({ manifest: { - id: 'blank', + id: 'com.example.blank', namespace: 'blank', version: '0.1.0', type: 'app', diff --git a/packages/spec/src/kernel/manifest.test.ts b/packages/spec/src/kernel/manifest.test.ts index beee1f7e033..2be88368b4b 100644 --- a/packages/spec/src/kernel/manifest.test.ts +++ b/packages/spec/src/kernel/manifest.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { ManifestSchema, type ObjectStackManifest } from './manifest.zod'; +import { + ManifestSchema, + MANIFEST_ID_PATTERN, + MANIFEST_ID_EXAMPLES, + type ObjectStackManifest, +} from './manifest.zod'; +import { PackageSchema } from '../marketplace/package.zod'; describe('ManifestSchema', () => { describe('Basic Properties', () => { @@ -545,3 +551,127 @@ describe('dead-container retirement (#11332, ADR-0049 — tombstoned, not delete expect(parsed.navigationContributions).toHaveLength(1); }); }); + +// ── `manifest.id` — the reverse-domain rule, declared once ─────────────────── +// +// `ManifestSchema.id` and `PackageSchema.manifestId` name the same identity: +// what an author writes, and what the registry stores and addresses the package +// by. They were two independent declarations — one enforcing the shape, one +// accepting any string — so a package could scaffold, validate and boot and +// still be refused at publish. These pins hold the two together and hold the +// refusal to the shape #4001 asks of it. +describe('manifest.id — reverse-domain identifier', () => { + const legal = (id: string) => ({ id, version: '1.0.0', type: 'app' as const, name: 'X' }); + + it('the examples the TSDoc and the refusal show are themselves legal', () => { + // The refusal shows these two ids to an author who is already stuck. An + // example that fails its own rule teaches exactly the wrong thing, so the + // list is held against the pattern rather than trusted. + for (const example of MANIFEST_ID_EXAMPLES) { + expect(MANIFEST_ID_PATTERN.test(example), `${example} must match the pattern`).toBe(true); + expect(ManifestSchema.safeParse(legal(example)).success).toBe(true); + } + }); + + it.each([ + 'com.acme.crm', + 'com.example.my-app', + 'org.apache.superset', + 'app.example.hr', + 'a.b', + 'com.example.app2', + ])('accepts %s', (id) => { + expect(ManifestSchema.safeParse(legal(id)).success).toBe(true); + }); + + it.each([ + ['blank', 'a bare word carries no dot'], + ['com', 'one segment is not reverse domain'], + ['com.', 'a trailing dot leaves an empty segment'], + ['.com.app', 'a leading dot leaves an empty segment'], + ['com.example.my_app', 'underscores are not admitted'], + ['Com.Example.App', 'uppercase is not admitted'], + ['com.example.-app', 'a segment must open with a letter'], + ['com.example.2app', 'a segment must open with a letter, not a digit'], + ['com example.app', 'spaces are not admitted'], + ])('refuses %s (%s)', (id) => { + expect(ManifestSchema.safeParse(legal(id)).success).toBe(false); + }); + + it('a namespace is never an id — the two rules contradict on the underscore', () => { + // `manifest.namespace` documents "lowercase letters, digits, and + // underscores only", so reusing it as the id is wrong by construction. + // This is the scaffolder bug that produced `com.example.my_app` (#4902's + // neighbour) and it must stay refused. + expect(ManifestSchema.safeParse(legal('my_app')).success).toBe(false); + expect(ManifestSchema.safeParse({ ...legal('com.example.app'), namespace: 'my_app' }).success).toBe(true); + }); + + describe('the refusal carries a remedy (#4001)', () => { + const refusalFor = (id: string) => { + const r = ManifestSchema.safeParse(legal(id)); + expect(r.success).toBe(false); + const issue = r.success ? undefined : r.error.issues.find((i) => i.path[0] === 'id'); + return issue?.message ?? ''; + }; + + it('names the key and echoes the value', () => { + const msg = refusalFor('blank'); + expect(msg).toContain('manifest.id'); + expect(msg).toContain("'blank'"); + }); + + it('shows both examples', () => { + const msg = refusalFor('blank'); + for (const example of MANIFEST_ID_EXAMPLES) expect(msg).toContain(example); + }); + + it('suggests com.example. for a bare word', () => { + expect(refusalFor('blank')).toContain("Did you mean 'com.example.blank'?"); + }); + + it('hyphenates a bare word that is namespace-shaped, rather than suggesting an id it would refuse', () => { + // `com.example.my_app` is the naive prefix and the schema rejects it. + // A suggestion is only offered once it has been checked against the + // pattern, so what comes back is the form that actually parses. + const msg = refusalFor('my_app'); + expect(msg).toContain("Did you mean 'com.example.my-app'?"); + expect(msg).not.toContain('com.example.my_app'); + }); + + it('repairs a dotted value in place instead of prefixing it', () => { + expect(refusalFor('com.dogfood.flow_fixture')).toContain("Did you mean 'com.dogfood.flow-fixture'?"); + }); + + it('offers no suggestion when nothing mechanical rescues the value', () => { + const msg = refusalFor('Com.Example.App'); + expect(msg).toContain('manifest.id'); + expect(msg).not.toContain('Did you mean'); + }); + + it('every suggestion it makes is itself accepted by the schema', () => { + for (const input of ['blank', 'my_app', 'com.dogfood.flow_fixture', 'support_desk']) { + const suggested = /Did you mean '([^']+)'\?/.exec(refusalFor(input))?.[1]; + expect(suggested, `${input} should get a suggestion`).toBeTruthy(); + expect(ManifestSchema.safeParse(legal(suggested as string)).success).toBe(true); + } + }); + }); + + it('PackageSchema.manifestId enforces the SAME declaration — the two cannot drift', () => { + // The point of the shared constant: one verdict, two surfaces. A future + // edit to either regex literal would have to break this table to pass. + const cases = ['com.acme.crm', 'org.apache.superset', 'blank', 'com.example.my_app', 'Com.App', 'a.b']; + for (const id of cases) { + const authoring = ManifestSchema.safeParse({ id, version: '1.0.0', type: 'app', name: 'X' }).success; + const registry = PackageSchema.safeParse({ + id: '00000000-0000-4000-8000-000000000000', + manifestId: id, + name: 'X', + type: 'app', + }).success; + expect(registry, `registry verdict for ${id}`).toBe(authoring); + expect(MANIFEST_ID_PATTERN.test(id), `pattern verdict for ${id}`).toBe(authoring); + } + }); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.manifest-id-reverse-domain-required.ts b/packages/spec/src/migrations/entries/semantic/18.manifest-id-reverse-domain-required.ts new file mode 100644 index 00000000000..fcacf972ba2 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.manifest-id-reverse-domain-required.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// A rename is the one thing this entry deliberately does NOT prescribe +// mechanically. An id is an IDENTITY: it is what the registry addresses the +// package by (`manifest_id`), what an installed row is keyed on, and what a +// dependent declares. Rewriting `com.acme.my_app` to `com.acme.my-app` on the +// author's behalf would silently make the artifact a DIFFERENT package from the +// one already installed somewhere — so this is a structured TODO the human +// answers, not a D2 conversion. +export const entry: SemanticMigration = { + id: 'manifest-id-reverse-domain-required', + surface: 'manifest.id — `ObjectStackManifest.id`, i.e. `defineStack({ manifest: { id } })` ' + + 'and the `id:` key of a package manifest — and its registry face ' + + '`PackageSchema.manifestId` (`marketplace/package.zod.ts`)', + replacement: 'a reverse-domain identifier matching `MANIFEST_ID_PATTERN` ' + + '(`kernel/manifest.zod.ts`): dot-separated lowercase segments, each opening with a ' + + 'letter, digits and hyphens allowed inside a segment — `com.acme.crm`, ' + + '`org.apache.superset`. ⛔ Underscores are not admitted, so `manifest.namespace` is ' + + 'never a legal id and never a legal last segment of one: `com.acme.my_app` becomes ' + + '`com.acme.my-app`. A bare word gains a prefix: `blank` becomes `com.example.blank`. ' + + 'The refusal carries the repaired value it has already checked against the pattern, so ' + + 'the prescription is in the error text, not only here.', + reason: + 'Two declarations named one identity and drifted. `PackageSchema.manifestId` — what the ' + + 'registry stores and addresses a package by — has always carried the reverse-domain ' + + 'regex; `ManifestSchema.id`, the key an author actually writes, was `z.string()` and ' + + 'accepted anything. So a package scaffolded, validated, built and booted with an id the ' + + 'publish path would refuse, and the author met the rule for the first time at the one ' + + 'moment it was most expensive to meet. The two sites now reference ONE exported ' + + 'constant, which is what makes a future divergence a visible edit rather than a silent ' + + 'one. Why the rule holds for a package nobody publishes: the TSDoc\'s own words are ' + + '"unique across the entire ecosystem" — an id names the artifact for the ecosystem it ' + + 'may one day join, so a private app is named under the same rule as a listed one. ' + + 'Why it is a D3 semantic TODO and not a D2 conversion: the value IS the identity. A ' + + 'mechanical rewrite would re-point every install, dependency declaration and stored ' + + '`manifest_id` row at a package that, to the registry, is a different one — and the ' + + 'safe choice between "rename the package" and "keep the id and change nothing that ' + + 'depends on it" is not derivable from the metadata.', + acceptanceCriteria: + 'Every `manifest.id` you author matches the pattern, and `defineStack` / ' + + '`objectstack validate` report no `manifest.id` finding. Prove the rename side ' + + 'separately, because the schema cannot: for each id you changed, confirm nothing still ' + + 'addresses the old value — no installed row, no `dependencies` entry in another ' + + 'package\'s manifest, and no registry listing. If any does, the correct answer is a ' + + 'deliberate republish under the new id, not an in-place edit.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 17989df36da..ea8a914f846 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -9118,6 +9118,50 @@ const step18: MigrationStep = { + 'naming the suffixed key and its def; the parsed defaults are 5000 / 1000 / 30000 / 1000 as ' + 'before; and each published describe names milliseconds.', }, + // A rename is the one thing this entry deliberately does NOT prescribe + // mechanically. An id is an IDENTITY: it is what the registry addresses the + // package by (`manifest_id`), what an installed row is keyed on, and what a + // dependent declares. Rewriting `com.acme.my_app` to `com.acme.my-app` on the + // author's behalf would silently make the artifact a DIFFERENT package from the + // one already installed somewhere — so this is a structured TODO the human + // answers, not a D2 conversion. + { + id: 'manifest-id-reverse-domain-required', + surface: 'manifest.id — `ObjectStackManifest.id`, i.e. `defineStack({ manifest: { id } })` ' + + 'and the `id:` key of a package manifest — and its registry face ' + + '`PackageSchema.manifestId` (`marketplace/package.zod.ts`)', + replacement: 'a reverse-domain identifier matching `MANIFEST_ID_PATTERN` ' + + '(`kernel/manifest.zod.ts`): dot-separated lowercase segments, each opening with a ' + + 'letter, digits and hyphens allowed inside a segment — `com.acme.crm`, ' + + '`org.apache.superset`. ⛔ Underscores are not admitted, so `manifest.namespace` is ' + + 'never a legal id and never a legal last segment of one: `com.acme.my_app` becomes ' + + '`com.acme.my-app`. A bare word gains a prefix: `blank` becomes `com.example.blank`. ' + + 'The refusal carries the repaired value it has already checked against the pattern, so ' + + 'the prescription is in the error text, not only here.', + reason: + 'Two declarations named one identity and drifted. `PackageSchema.manifestId` — what the ' + + 'registry stores and addresses a package by — has always carried the reverse-domain ' + + 'regex; `ManifestSchema.id`, the key an author actually writes, was `z.string()` and ' + + 'accepted anything. So a package scaffolded, validated, built and booted with an id the ' + + 'publish path would refuse, and the author met the rule for the first time at the one ' + + 'moment it was most expensive to meet. The two sites now reference ONE exported ' + + 'constant, which is what makes a future divergence a visible edit rather than a silent ' + + 'one. Why the rule holds for a package nobody publishes: the TSDoc\'s own words are ' + + '"unique across the entire ecosystem" — an id names the artifact for the ecosystem it ' + + 'may one day join, so a private app is named under the same rule as a listed one. ' + + 'Why it is a D3 semantic TODO and not a D2 conversion: the value IS the identity. A ' + + 'mechanical rewrite would re-point every install, dependency declaration and stored ' + + '`manifest_id` row at a package that, to the registry, is a different one — and the ' + + 'safe choice between "rename the package" and "keep the id and change nothing that ' + + 'depends on it" is not derivable from the metadata.', + acceptanceCriteria: + 'Every `manifest.id` you author matches the pattern, and `defineStack` / ' + + '`objectstack validate` report no `manifest.id` finding. Prove the rename side ' + + 'separately, because the schema cannot: for each id you changed, confirm nothing still ' + + 'addresses the old value — no installed row, no `dependencies` entry in another ' + + 'package\'s manifest, and no registry listing. If any does, the correct answer is a ' + + 'deliberate republish under the new id, not an in-place edit.', + }, { id: 'memory-persistence-placeholder-refused', surface: 'memory driver config `persistence.path` (file persistence and the `auto` ' + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 981a61066d0..86e1bfcb2d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -903,6 +903,9 @@ importers: specifier: ^15.0.0 version: 15.0.0 devDependencies: + '@objectstack/spec': + specifier: workspace:* + version: link:../spec '@types/node': specifier: ^26.2.0 version: 26.2.0 From 46af72040b92570c082a6bff50ef0ae762c22188 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 15:00:34 +0000 Subject: [PATCH 03/19] chore(spec): regenerate api-surface, export-origins and reference docs Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- content/docs/references/api/package-api.mdx | 12 ++++++------ content/docs/references/api/protocol.mdx | 2 +- content/docs/references/kernel/manifest.mdx | 2 +- .../references/kernel/package-registry.mdx | 4 ++-- .../docs/references/kernel/package-upgrade.mdx | 4 ++-- packages/spec/api-surface/kernel.json | 3 +++ packages/spec/export-origins/kernel.json | 3 +++ packages/spec/src/kernel/manifest.test.ts | 18 +++++++++--------- 8 files changed, 27 insertions(+), 21 deletions(-) diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index 8fe0531d73f..b2fa4b9c92d 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -64,7 +64,7 @@ Installed package row whose manifest is the assembled package body | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique package identifier (reverse domain style) | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | | **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | | **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | | **version** | `string` | ✅ | Package version (semantic versioning) | @@ -245,7 +245,7 @@ Installed package with runtime lifecycle state | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique package identifier (reverse domain style) | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | | **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | | **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | | **version** | `string` | ✅ | Package version (semantic versioning) | @@ -308,7 +308,7 @@ Installed package row whose manifest is the assembled package body | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique package identifier (reverse domain style) | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | | **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | | **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | | **version** | `string` | ✅ | Package version (semantic versioning) | @@ -473,7 +473,7 @@ Install package request | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique package identifier (reverse domain style) | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | | **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | | **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | | **version** | `string` | ✅ | Package version (semantic versioning) | @@ -597,7 +597,7 @@ Upgrade package request | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique package identifier (reverse domain style) | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | | **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | | **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | | **version** | `string` | ✅ | Package version (semantic versioning) | @@ -683,7 +683,7 @@ Resolve dependencies request | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique package identifier (reverse domain style) | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | | **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | | **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | | **version** | `string` | ✅ | Package version (semantic versioning) | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index b6fd069ff8b..2ab3b2932d1 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1883,7 +1883,7 @@ Install package request | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique package identifier (reverse domain style) | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | | **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | | **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | | **version** | `string` | ✅ | Package version (semantic versioning) | diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index 5884e3215e7..46ac1cd2971 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -27,7 +27,7 @@ const result = ManifestSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique package identifier (reverse domain style) | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | | **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | | **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | | **version** | `string` | ✅ | Package version (semantic versioning) | diff --git a/content/docs/references/kernel/package-registry.mdx b/content/docs/references/kernel/package-registry.mdx index d364c6b3181..d88a7dc809c 100644 --- a/content/docs/references/kernel/package-registry.mdx +++ b/content/docs/references/kernel/package-registry.mdx @@ -191,7 +191,7 @@ Install package request | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique package identifier (reverse domain style) | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | | **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | | **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | | **version** | `string` | ✅ | Package version (semantic versioning) | @@ -287,7 +287,7 @@ Installed package with runtime lifecycle state | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique package identifier (reverse domain style) | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | | **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | | **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | | **version** | `string` | ✅ | Package version (semantic versioning) | diff --git a/content/docs/references/kernel/package-upgrade.mdx b/content/docs/references/kernel/package-upgrade.mdx index 84c5bd4143a..20381052d53 100644 --- a/content/docs/references/kernel/package-upgrade.mdx +++ b/content/docs/references/kernel/package-upgrade.mdx @@ -140,7 +140,7 @@ Upgrade package request | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique package identifier (reverse domain style) | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | | **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | | **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | | **version** | `string` | ✅ | Package version (semantic versioning) | @@ -285,7 +285,7 @@ Pre-upgrade state snapshot for rollback capability | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique package identifier (reverse domain style) | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | | **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | | **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | | **version** | `string` | ✅ | Package version (semantic versioning) | diff --git a/packages/spec/api-surface/kernel.json b/packages/spec/api-surface/kernel.json index c583a64ed30..7f561c1d346 100644 --- a/packages/spec/api-surface/kernel.json +++ b/packages/spec/api-surface/kernel.json @@ -156,6 +156,8 @@ "ListPackagesResponse (type)", "ListPackagesResponseParsed (type)", "ListPackagesResponseSchema (const)", + "MANIFEST_ID_EXAMPLES (const)", + "MANIFEST_ID_PATTERN (const)", "METADATA_READ_DECORATIONS (const)", "ManifestPermissions (type)", "ManifestPermissionsSchema (const)", @@ -480,6 +482,7 @@ "listMetadataTypeSchemaTypes (function)", "listUnregisteredKindSchemaTypes (function)", "lowerRequiresFeature (function)", + "manifestIdRefusal (function)", "registerMetadataTypeActions (function)", "registerMetadataTypeRedactor (function)", "registerMetadataTypeSchema (function)", diff --git a/packages/spec/export-origins/kernel.json b/packages/spec/export-origins/kernel.json index 46f59185377..9dad040f768 100644 --- a/packages/spec/export-origins/kernel.json +++ b/packages/spec/export-origins/kernel.json @@ -155,6 +155,8 @@ "ListPackagesResponse": "src/kernel/package-registry.zod.ts#ListPackagesResponse (type)", "ListPackagesResponseParsed": "src/kernel/package-registry.zod.ts#ListPackagesResponseParsed (type)", "ListPackagesResponseSchema": "src/kernel/package-registry.zod.ts#ListPackagesResponseSchema (const)", + "MANIFEST_ID_EXAMPLES": "src/kernel/manifest.zod.ts#MANIFEST_ID_EXAMPLES (const)", + "MANIFEST_ID_PATTERN": "src/kernel/manifest.zod.ts#MANIFEST_ID_PATTERN (const)", "METADATA_READ_DECORATIONS": "src/kernel/metadata-read-decorations.ts#METADATA_READ_DECORATIONS (const)", "ManifestPermissions": "src/kernel/manifest.zod.ts#ManifestPermissions (type)", "ManifestPermissionsSchema": "src/kernel/manifest.zod.ts#ManifestPermissionsSchema (const)", @@ -476,6 +478,7 @@ "listMetadataTypeSchemaTypes": "src/kernel/metadata-type-schemas.ts#listMetadataTypeSchemaTypes (function)", "listUnregisteredKindSchemaTypes": "src/kernel/metadata-type-schemas.ts#listUnregisteredKindSchemaTypes (function)", "lowerRequiresFeature": "src/kernel/public-auth-features.ts#lowerRequiresFeature (function)", + "manifestIdRefusal": "src/kernel/manifest.zod.ts#manifestIdRefusal (function)", "registerMetadataTypeActions": "src/kernel/metadata-type-schemas.ts#registerMetadataTypeActions (function)", "registerMetadataTypeRedactor": "src/kernel/metadata-type-redaction.ts#registerMetadataTypeRedactor (function)", "registerMetadataTypeSchema": "src/kernel/metadata-type-schemas.ts#registerMetadataTypeSchema (function)", diff --git a/packages/spec/src/kernel/manifest.test.ts b/packages/spec/src/kernel/manifest.test.ts index 2be88368b4b..e0e91ef5d0b 100644 --- a/packages/spec/src/kernel/manifest.test.ts +++ b/packages/spec/src/kernel/manifest.test.ts @@ -662,16 +662,16 @@ describe('manifest.id — reverse-domain identifier', () => { // The point of the shared constant: one verdict, two surfaces. A future // edit to either regex literal would have to break this table to pass. const cases = ['com.acme.crm', 'org.apache.superset', 'blank', 'com.example.my_app', 'Com.App', 'a.b']; + // Judged per FIELD, not on whole-object success: the two schemas require + // different neighbours, so an overall verdict would be measuring those. + const fieldRefused = (schema: typeof ManifestSchema | typeof PackageSchema, key: string, value: unknown) => { + const r = schema.safeParse({ [key]: value } as never); + return r.success ? false : r.error.issues.some((i) => i.path[0] === key); + }; for (const id of cases) { - const authoring = ManifestSchema.safeParse({ id, version: '1.0.0', type: 'app', name: 'X' }).success; - const registry = PackageSchema.safeParse({ - id: '00000000-0000-4000-8000-000000000000', - manifestId: id, - name: 'X', - type: 'app', - }).success; - expect(registry, `registry verdict for ${id}`).toBe(authoring); - expect(MANIFEST_ID_PATTERN.test(id), `pattern verdict for ${id}`).toBe(authoring); + const refused = !MANIFEST_ID_PATTERN.test(id); + expect(fieldRefused(ManifestSchema, 'id', id), `manifest.id verdict for ${id}`).toBe(refused); + expect(fieldRefused(PackageSchema, 'manifestId', id), `manifestId verdict for ${id}`).toBe(refused); } }); }); From 6464e7f69a612a7a940eb4b533d35929623f5762 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 15:23:23 +0000 Subject: [PATCH 04/19] test(create-objectstack): anchor the spec kernel subpath to source for vitest Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- packages/create-objectstack/vitest.config.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/create-objectstack/vitest.config.ts b/packages/create-objectstack/vitest.config.ts index 68e5d5330b8..93fc82cc1c0 100644 --- a/packages/create-objectstack/vitest.config.ts +++ b/packages/create-objectstack/vitest.config.ts @@ -1,6 +1,25 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; +const HERE = path.dirname(fileURLToPath(import.meta.url)); + export default defineConfig({ + // The scaffolder's tests pin their output against the spec's own exported + // rule (`MANIFEST_ID_PATTERN`). Resolved through `dist/`, that verdict would + // be a function of build state rather than of the source in this checkout — + // and the dangerous direction is silent, because a stale `dist` runs GREEN. + // Anchored per subpath: the object form matches by PREFIX and would resolve + // `@objectstack/spec/kernel` to `…/src/index.ts/kernel` + // (`pnpm check:test-source-alias`). + resolve: { + alias: [ + { + find: /^@objectstack\/spec\/kernel$/, + replacement: path.resolve(HERE, '../spec/src/kernel/index.ts'), + }, + ], + }, test: { // A late console.* must not redden a green suite (#10374): vitest's worker // forwards console output over RPC and discards the promise, and a write From c864c6b1b46b29d2cb7ab6db8b420080a3539b58 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 15:42:35 +0000 Subject: [PATCH 05/19] test(spec): give the stack fixtures reverse-domain manifest ids Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../spec/src/stack-email-template-locale-floor.test.ts | 2 +- packages/spec/src/stack.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/stack-email-template-locale-floor.test.ts b/packages/spec/src/stack-email-template-locale-floor.test.ts index 0fb2a1b9ecb..64cad033504 100644 --- a/packages/spec/src/stack-email-template-locale-floor.test.ts +++ b/packages/spec/src/stack-email-template-locale-floor.test.ts @@ -23,7 +23,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { defineStack } from './stack.zod'; import { EmailTemplateDefinitionSchema, EMAIL_TEMPLATE_FLOOR_LOCALE } from './system/email-template.zod'; -const MANIFEST = { id: 'acme', name: 'acme', version: '1.0.0', namespace: 'acme', type: 'app' as const }; +const MANIFEST = { id: 'com.example.acme', name: 'acme', version: '1.0.0', namespace: 'acme', type: 'app' as const }; const tpl = (locale: string | undefined, name = 'acme.welcome') => ({ name, diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts index bb4b733b514..9a2258cade6 100644 --- a/packages/spec/src/stack.test.ts +++ b/packages/spec/src/stack.test.ts @@ -1370,7 +1370,7 @@ describe('defineStack - Namespace Prefix Validation', () => { it('aggregates errors across multiple offending objects', () => { const config = { - manifest: { id: 'p', version: '1.0.0', type: 'app' as const, name: 'P', namespace: 'todo' }, + manifest: { id: 'com.example.p', version: '1.0.0', type: 'app' as const, name: 'P', namespace: 'todo' }, objects: [ { name: 'task', label: 'T', fields: { t: { type: 'text' as const } } }, { name: 'project', label: 'P', fields: { t: { type: 'text' as const } } }, @@ -1399,7 +1399,7 @@ describe('defineStack — at most one App per package (ADR-0019 D1/D3)', () => { label, navigation: [{ id: 'nav_tasks', type: 'object' as const, label: 'Tasks', objectName: 'demo_task' }], }); - const manifest = { id: 'p', version: '1.0.0', type: 'app' as const, name: 'P', namespace: 'demo' }; + const manifest = { id: 'com.example.p', version: '1.0.0', type: 'app' as const, name: 'P', namespace: 'demo' }; it('accepts an app package with exactly one app', () => { expect(() => @@ -1424,7 +1424,7 @@ describe('defineStack — at most one App per package (ADR-0019 D1/D3)', () => { it('does not constrain non-app package types', () => { expect(() => defineStack({ - manifest: { id: 'p', version: '1.0.0', type: 'driver' as const, name: 'Driver', namespace: 'demo' }, + manifest: { id: 'com.example.p', version: '1.0.0', type: 'driver' as const, name: 'Driver', namespace: 'demo' }, objects: [obj], apps: [appNav('app_a', 'A'), appNav('app_b', 'B')], }), @@ -1441,7 +1441,7 @@ describe('defineStack — at most one App per package (ADR-0019 D1/D3)', () => { // shape heard nothing unless they happened to run `os validate` (`os build` // was deaf too). Five conversions are live today, so this was a real gap. describe('defineStack — ADR-0087 D2 conversion notices', () => { - const manifest = { id: 'p', version: '1.0.0', type: 'app' as const, name: 'P', namespace: 'demo' }; + const manifest = { id: 'com.example.p', version: '1.0.0', type: 'app' as const, name: 'P', namespace: 'demo' }; const obj = { name: 'demo_task', label: 'Task', fields: { title: { type: 'text' as const } } }; // A protocol-11 flow callout node type — `webhook` converts to `http`. From d0d859edbcfcf85240665f13207670f304ed5311 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 16:02:28 +0000 Subject: [PATCH 06/19] fix(cli): golden corpus and fixtures carry reverse-domain manifest ids Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- packages/cli/src/lint/corpus.ts | 15 ++++++++++----- .../cli/test/lint-label-case-localized.test.ts | 2 +- packages/cli/test/metadata-eval.test.ts | 8 ++++---- packages/cli/test/score.test.ts | 4 ++-- .../src/scaffold-description.test.ts | 8 +++++++- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/lint/corpus.ts b/packages/cli/src/lint/corpus.ts index 993ff1586f7..64c8907306d 100644 --- a/packages/cli/src/lint/corpus.ts +++ b/packages/cli/src/lint/corpus.ts @@ -18,6 +18,11 @@ import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import type { MetadataEvalCase } from './metadata-eval.js'; +// `id` is a reverse-domain package identifier and `namespace` is a snake_case +// metadata prefix — one project, two identifiers, contradictory rules +// (`MANIFEST_ID_PATTERN` refuses the underscore a namespace may carry). The +// corpus is what the generator imitates, so each entry writes both correctly +// rather than reusing one for the other. const manifest = (id: string, namespace: string, name: string) => ({ id, namespace, @@ -36,7 +41,7 @@ export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [ 'Model an invoicing app: an invoice with multiple line items (product, quantity, unit price, amount). The invoice total should sum its line amounts, and line items are entered together with the invoice.', note: 'master_detail + inlineEdit + roll-up summary', fixture: { - manifest: manifest('invoicing', 'invoicing', 'Invoicing'), + manifest: manifest('com.example.invoicing', 'invoicing', 'Invoicing'), objects: [ { name: 'invoice', @@ -97,7 +102,7 @@ export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [ 'A project management app: a project owns many tasks (title, status, estimate in hours). Tasks are edited inline within the project, and the project shows a task count and total estimate.', note: 'master_detail + inlineEdit + count/sum roll-ups', fixture: { - manifest: manifest('pm', 'pm_app', 'Project Management'), + manifest: manifest('com.example.pm', 'pm_app', 'Project Management'), objects: [ { name: 'project', @@ -162,7 +167,7 @@ export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [ 'A blog: posts have a title and body. Readers leave comments on a post (author, body). Comments belong to the post but are an activity stream, not something you fill in when writing the post.', note: 'association child: master_detail WITHOUT inlineEdit (related list on detail page)', fixture: { - manifest: manifest('blog', 'blog_app', 'Blog'), + manifest: manifest('com.example.blog', 'blog_app', 'Blog'), objects: [ { name: 'post', @@ -201,7 +206,7 @@ export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [ 'An expense report app: a report has a title and a submitter. It contains expense lines (category, description, amount, date). The report total sums the line amounts and lines are entered inline.', note: 'master_detail + inlineEdit + sum roll-up + select options', fixture: { - manifest: manifest('expenses', 'expenses', 'Expenses'), + manifest: manifest('com.example.expenses', 'expenses', 'Expenses'), objects: [ { name: 'expense_report', @@ -254,7 +259,7 @@ export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [ 'A simple CRM: accounts and their contacts. A contact belongs to an account but can exist independently and is not entered inline with the account.', note: 'lookup (independent child) — should NOT be master_detail/inlineEdit', fixture: { - manifest: manifest('crm', 'crm_app', 'CRM'), + manifest: manifest('com.example.crm', 'crm_app', 'CRM'), objects: [ { name: 'account', diff --git a/packages/cli/test/lint-label-case-localized.test.ts b/packages/cli/test/lint-label-case-localized.test.ts index 08b547b126d..924ac2abd1f 100644 --- a/packages/cli/test/lint-label-case-localized.test.ts +++ b/packages/cli/test/lint-label-case-localized.test.ts @@ -58,7 +58,7 @@ import { lintConfig } from '../src/commands/lint'; import { scoreMetadata } from '../src/lint/score'; const MANIFEST = { - id: 'todo', + id: 'com.example.todo', namespace: 'todo', version: '1.0.0', name: 'Todo', diff --git a/packages/cli/test/metadata-eval.test.ts b/packages/cli/test/metadata-eval.test.ts index c044c15c54e..d0f34c0f1aa 100644 --- a/packages/cli/test/metadata-eval.test.ts +++ b/packages/cli/test/metadata-eval.test.ts @@ -33,7 +33,7 @@ describe('runMetadataEval — offline (golden corpus)', () => { describe('runMetadataEval — live seam', () => { const oneCase: MetadataEvalCase[] = [ - { id: 'c1', prompt: 'invoice with lines', fixture: { manifest: { id: 'a', namespace: 'aa', version: '1.0.0', name: 'A', type: 'app' } } }, + { id: 'c1', prompt: 'invoice with lines', fixture: { manifest: { id: 'com.example.a', namespace: 'aa', version: '1.0.0', name: 'A', type: 'app' } } }, ]; it('scores the generated stack (not the fixture) when a generator is injected', async () => { @@ -117,7 +117,7 @@ describe('runMetadataEval — live seam', () => { */ describe('runMetadataEval — a stack that cannot be scored is a FAILED case, not a crash', () => { const oneCase: MetadataEvalCase[] = [ - { id: 'c1', prompt: 'invoice with lines', fixture: { manifest: { id: 'a', namespace: 'aa', version: '1.0.0', name: 'A', type: 'app' } } }, + { id: 'c1', prompt: 'invoice with lines', fixture: { manifest: { id: 'com.example.a', namespace: 'aa', version: '1.0.0', name: 'A', type: 'app' } } }, ]; /** Poison on a TOP-LEVEL key: throws inside `normalizeStackInput`'s spread. */ @@ -200,7 +200,7 @@ describe('runMetadataEval — a stack that cannot be scored is a FAILED case, no { ...oneCase[0], id: 'fine' }, ]; const generate = (_prompt: string, id: string) => - id === 'poisoned' ? topLevelPoison() : { manifest: { id: 'b', namespace: 'bb', version: '1.0.0', name: 'B', type: 'app' } }; + id === 'poisoned' ? topLevelPoison() : { manifest: { id: 'com.example.b', namespace: 'bb', version: '1.0.0', name: 'B', type: 'app' } }; const report = await runMetadataEval(twoCases, { generate }); @@ -252,7 +252,7 @@ describe('runMetadataEval — a stack that cannot be scored is a FAILED case, no */ describe('runMetadataEval — a generator that THREW scores 0, not 100', () => { const oneCase: MetadataEvalCase[] = [ - { id: 'c1', prompt: 'invoice with lines', fixture: { manifest: { id: 'a', namespace: 'aa', version: '1.0.0', name: 'A', type: 'app' } } }, + { id: 'c1', prompt: 'invoice with lines', fixture: { manifest: { id: 'com.example.a', namespace: 'aa', version: '1.0.0', name: 'A', type: 'app' } } }, ]; const throwingGen = () => { throw new Error('model unavailable'); diff --git a/packages/cli/test/score.test.ts b/packages/cli/test/score.test.ts index 0d85858ddd1..89e5ee3154c 100644 --- a/packages/cli/test/score.test.ts +++ b/packages/cli/test/score.test.ts @@ -4,7 +4,7 @@ import { scoreMetadata } from '../src/lint/score'; /** A clean, convention-following invoice + line-item model. */ const GOOD_STACK = { - manifest: { id: 'demo', namespace: 'demo_app', version: '1.0.0', name: 'Demo', type: 'app' as const, engines: { protocol: `^${PROTOCOL_MAJOR}` } }, + manifest: { id: 'com.example.demo', namespace: 'demo_app', version: '1.0.0', name: 'Demo', type: 'app' as const, engines: { protocol: `^${PROTOCOL_MAJOR}` } }, objects: [ { name: 'invoice', @@ -31,7 +31,7 @@ const GOOD_STACK = { /** Schema-invalid (bad namespace) AND riddled with anti-patterns. */ const BAD_STACK = { - manifest: { id: 'bad', namespace: 'X', version: '1.0.0', name: 'Bad', type: 'app' as const }, // namespace fails pattern → schema error + manifest: { id: 'com.example.bad', namespace: 'X', version: '1.0.0', name: 'Bad', type: 'app' as const }, // namespace fails pattern → schema error objects: [ { name: 'BadName', // not snake_case → lint error diff --git a/packages/create-objectstack/src/scaffold-description.test.ts b/packages/create-objectstack/src/scaffold-description.test.ts index 55cbeeee87a..378556fd33d 100644 --- a/packages/create-objectstack/src/scaffold-description.test.ts +++ b/packages/create-objectstack/src/scaffold-description.test.ts @@ -81,7 +81,13 @@ describe('scaffolded project description (#9263)', () => { it('still rewrites id/namespace/name — the description drop does not regress the existing rewrite', () => { const cfg = fs.readFileSync(path.join(projectDir, 'objectstack.config.ts'), 'utf8'); - expect(cfg).toContain("id: 'support-desk'"); + // The id is the reverse-domain form, not the bare project name (#17534): + // `ManifestSchema.id` refuses a value with no dot, so the bare word this + // line used to assert is metadata the scaffold's own `os validate` rejects. + // Note the two identifiers are derived from one project name under + // contradictory rules — hyphen in the id, underscore in the namespace — so + // neither can be read off the other. + expect(cfg).toContain("id: 'com.example.support-desk'"); expect(cfg).toContain("namespace: 'support_desk'"); expect(cfg).toContain("name: 'Support Desk'"); // The line immediately after the (now-removed) description must still be From 61d26d00ca7ed4a3ec9f444bc40603b142b35697 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 23:25:41 +0000 Subject: [PATCH 07/19] test(qa): give the dogfood and downstream-contract fixtures reverse-domain manifest ids `manifest.id` now enforces the reverse-domain rule, and 21 fixture declarations under `packages/qa` carried an underscore in their last segment. 20 of them are renamed here, underscore to hyphen, exactly the repair the refusal message itself suggests. The 21st, `com.dogfood.flow_fixture` in `packages/qa/dogfood/test/fixtures/flow-touch-fixture.ts`, is left alone: it is not a single-occurrence declaration. The same literal appears twice more in `packages/spec/src/kernel/manifest.test.ts`, as the sample input for the "repairs a dotted value in place" refusal-message assertions. The rename mandate covers single-occurrence declarations, so that one is reported rather than swept. `test/flow-node.dogfood.test.ts` therefore still refuses at boot. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../qa/dogfood/test/fixtures/analytics-admission-fixture.ts | 2 +- packages/qa/dogfood/test/fixtures/attachments-fixture.ts | 2 +- packages/qa/dogfood/test/fixtures/cbp-fixture.ts | 2 +- packages/qa/dogfood/test/fixtures/comments-fixture.ts | 2 +- .../test/fixtures/email-template-materialization-fixture.ts | 2 +- packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts | 2 +- .../qa/dogfood/test/fixtures/flow-durable-suspend-fixture.ts | 2 +- .../qa/dogfood/test/fixtures/flow-function-effect-fixture.ts | 2 +- packages/qa/dogfood/test/fixtures/flow-runas-fixture.ts | 2 +- packages/qa/dogfood/test/fixtures/hook-runas-fixture.ts | 2 +- packages/qa/dogfood/test/fixtures/label-scope-fixture.ts | 2 +- packages/qa/dogfood/test/fixtures/override-composite-fixture.ts | 2 +- packages/qa/dogfood/test/fixtures/rls-owner-fixture.ts | 2 +- .../qa/dogfood/test/fixtures/webhook-materialization-fixture.ts | 2 +- packages/qa/dogfood/test/hook-error-format.dogfood.test.ts | 2 +- .../test/hook-refusal-user-facing-marking.dogfood.test.ts | 2 +- packages/qa/dogfood/test/registry-gate-wiring.dogfood.test.ts | 2 +- .../test/sys-file-metadata-write-refusal.dogfood.test.ts | 2 +- .../qa/dogfood/test/validation-message-locale.dogfood.test.ts | 2 +- packages/qa/downstream-contract/src/stack.ts | 2 +- 20 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/qa/dogfood/test/fixtures/analytics-admission-fixture.ts b/packages/qa/dogfood/test/fixtures/analytics-admission-fixture.ts index 9ddac029e52..c96f10ede3c 100644 --- a/packages/qa/dogfood/test/fixtures/analytics-admission-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/analytics-admission-fixture.ts @@ -57,7 +57,7 @@ export const AdmissionWalled = ObjectSchema.create({ export const admissionFixtureStack = defineStack({ manifest: { - id: 'com.dogfood.analytics_admission', + id: 'com.dogfood.analytics-admission', namespace: 'admission', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/fixtures/attachments-fixture.ts b/packages/qa/dogfood/test/fixtures/attachments-fixture.ts index 6eac553d340..6737a29ab8c 100644 --- a/packages/qa/dogfood/test/fixtures/attachments-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/attachments-fixture.ts @@ -131,7 +131,7 @@ export function attachmentsFixtureSecurity(): SecurityPlugin { export const attachmentsFixtureStack = defineStack({ manifest: { - id: 'com.dogfood.attachments_fixture', + id: 'com.dogfood.attachments-fixture', namespace: 'att', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/fixtures/cbp-fixture.ts b/packages/qa/dogfood/test/fixtures/cbp-fixture.ts index 769b88e7939..ad2953d0366 100644 --- a/packages/qa/dogfood/test/fixtures/cbp-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/cbp-fixture.ts @@ -45,7 +45,7 @@ export const CbpNote = ObjectSchema.create({ export const cbpStack = defineStack({ manifest: { - id: 'com.dogfood.cbp_fixture', + id: 'com.dogfood.cbp-fixture', namespace: 'cbp', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/fixtures/comments-fixture.ts b/packages/qa/dogfood/test/fixtures/comments-fixture.ts index f3e0924e9dc..3b7ec326eab 100644 --- a/packages/qa/dogfood/test/fixtures/comments-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/comments-fixture.ts @@ -130,7 +130,7 @@ export function commentsFixtureSecurity(): SecurityPlugin { export const commentsFixtureStack = defineStack({ manifest: { - id: 'com.dogfood.comments_fixture', + id: 'com.dogfood.comments-fixture', namespace: 'cmt', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/fixtures/email-template-materialization-fixture.ts b/packages/qa/dogfood/test/fixtures/email-template-materialization-fixture.ts index 4e263b99a1e..f4c78431f76 100644 --- a/packages/qa/dogfood/test/fixtures/email-template-materialization-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/email-template-materialization-fixture.ts @@ -51,7 +51,7 @@ export const etPasswordReset = defineEmailTemplateDefinition({ export const emailTemplateFixtureStack = defineStack({ manifest: { - id: 'com.dogfood.email_template_fixture', + id: 'com.dogfood.email-template-fixture', namespace: 'et', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts b/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts index 8e7f61fb7d0..9c3b1dbbe4d 100644 --- a/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/endpoint-policy-fixture.ts @@ -85,7 +85,7 @@ export const SessionGatedEndpoint: ApiEndpoint = { export const endpointPolicyFixtureStack = defineStack({ manifest: { - id: 'com.dogfood.endpoint_policy_fixture', + id: 'com.dogfood.endpoint-policy-fixture', namespace: 'e8policy', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/fixtures/flow-durable-suspend-fixture.ts b/packages/qa/dogfood/test/fixtures/flow-durable-suspend-fixture.ts index 0a90767f630..75e4339d89f 100644 --- a/packages/qa/dogfood/test/fixtures/flow-durable-suspend-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/flow-durable-suspend-fixture.ts @@ -93,7 +93,7 @@ export const flowDurableSuspend: Flow = { /** A minimal, self-contained app config the dogfood harness can boot twice. */ export const durableSuspendStack = defineStack({ manifest: { - id: 'com.dogfood.durable_suspend', + id: 'com.dogfood.durable-suspend', namespace: 'suspend', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/fixtures/flow-function-effect-fixture.ts b/packages/qa/dogfood/test/fixtures/flow-function-effect-fixture.ts index e84b708eb3f..a5fb4119c59 100644 --- a/packages/qa/dogfood/test/fixtures/flow-function-effect-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/flow-function-effect-fixture.ts @@ -61,7 +61,7 @@ const sweepFlow = (name: string, fn: string): Flow => ({ export const flowFunctionEffectStack = defineStack({ manifest: { - id: 'com.dogfood.flow_function_effect', + id: 'com.dogfood.flow-function-effect', namespace: 'fxn', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/fixtures/flow-runas-fixture.ts b/packages/qa/dogfood/test/fixtures/flow-runas-fixture.ts index f9f0ac0f106..120089a8266 100644 --- a/packages/qa/dogfood/test/fixtures/flow-runas-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/flow-runas-fixture.ts @@ -117,7 +117,7 @@ export const runasUserRead = readFlow('runas_user_read', 'user'); /** A minimal, self-contained app config the dogfood harness can boot. */ export const runasFixtureStack = defineStack({ manifest: { - id: 'com.dogfood.runas_fixture', + id: 'com.dogfood.runas-fixture', namespace: 'runas', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/fixtures/hook-runas-fixture.ts b/packages/qa/dogfood/test/fixtures/hook-runas-fixture.ts index 47bb7c10417..5189eeab9ad 100644 --- a/packages/qa/dogfood/test/fixtures/hook-runas-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/hook-runas-fixture.ts @@ -84,7 +84,7 @@ const STAMP_SOURCE = ` export const hookRunAsFixtureStack = defineStack({ manifest: { - id: 'com.dogfood.hookrunas_fixture', + id: 'com.dogfood.hookrunas-fixture', namespace: 'hookrunas', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/fixtures/label-scope-fixture.ts b/packages/qa/dogfood/test/fixtures/label-scope-fixture.ts index ed6510468d5..fcde6d90506 100644 --- a/packages/qa/dogfood/test/fixtures/label-scope-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/label-scope-fixture.ts @@ -47,7 +47,7 @@ export const Deal = ObjectSchema.create({ export const labelScopeStack = defineStack({ manifest: { - id: 'com.dogfood.label_scope', + id: 'com.dogfood.label-scope', namespace: 'lbl', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/fixtures/override-composite-fixture.ts b/packages/qa/dogfood/test/fixtures/override-composite-fixture.ts index 676f2fbc568..c93c2902575 100644 --- a/packages/qa/dogfood/test/fixtures/override-composite-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/override-composite-fixture.ts @@ -81,7 +81,7 @@ export const OverrideCompositeFlow = defineFlow({ export const overrideCompositeStack = defineStack({ manifest: { - id: 'com.dogfood.override_composite', + id: 'com.dogfood.override-composite', namespace: 'override_composite', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/fixtures/rls-owner-fixture.ts b/packages/qa/dogfood/test/fixtures/rls-owner-fixture.ts index 1108e82d05d..ba990f3f2b9 100644 --- a/packages/qa/dogfood/test/fixtures/rls-owner-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/rls-owner-fixture.ts @@ -49,7 +49,7 @@ export const RlsNote = ObjectSchema.create({ /** A minimal, self-contained app config the dogfood harness can boot. */ export const rlsFixtureStack = defineStack({ manifest: { - id: 'com.dogfood.rls_fixture', + id: 'com.dogfood.rls-fixture', namespace: 'rls', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts b/packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts index cb8469d044f..41d629d9f0f 100644 --- a/packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts @@ -47,7 +47,7 @@ export const wmTaskChanged = defineWebhook({ export const webhookFixtureStack = defineStack({ manifest: { - id: 'com.dogfood.webhook_fixture', + id: 'com.dogfood.webhook-fixture', namespace: 'wm', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/hook-error-format.dogfood.test.ts b/packages/qa/dogfood/test/hook-error-format.dogfood.test.ts index 45410fafb2e..4ebc804f824 100644 --- a/packages/qa/dogfood/test/hook-error-format.dogfood.test.ts +++ b/packages/qa/dogfood/test/hook-error-format.dogfood.test.ts @@ -47,7 +47,7 @@ const HefBase = ObjectSchema.create({ const hefStack = defineStack({ manifest: { - id: 'com.dogfood.hook_error_format', + id: 'com.dogfood.hook-error-format', namespace: 'hef', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/hook-refusal-user-facing-marking.dogfood.test.ts b/packages/qa/dogfood/test/hook-refusal-user-facing-marking.dogfood.test.ts index 63f1e532ea8..032a59d05cd 100644 --- a/packages/qa/dogfood/test/hook-refusal-user-facing-marking.dogfood.test.ts +++ b/packages/qa/dogfood/test/hook-refusal-user-facing-marking.dogfood.test.ts @@ -50,7 +50,7 @@ const UfmPlain = ObjectSchema.create({ const ufmStack = defineStack({ manifest: { - id: 'com.dogfood.user_facing_marking', + id: 'com.dogfood.user-facing-marking', namespace: 'ufm', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/registry-gate-wiring.dogfood.test.ts b/packages/qa/dogfood/test/registry-gate-wiring.dogfood.test.ts index 6325a5c0b50..f17b014ba72 100644 --- a/packages/qa/dogfood/test/registry-gate-wiring.dogfood.test.ts +++ b/packages/qa/dogfood/test/registry-gate-wiring.dogfood.test.ts @@ -70,7 +70,7 @@ const GateNote = ObjectSchema.create({ const gateStack = defineStack({ manifest: { - id: 'com.dogfood.registry_gate', + id: 'com.dogfood.registry-gate', namespace: 'gate', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/sys-file-metadata-write-refusal.dogfood.test.ts b/packages/qa/dogfood/test/sys-file-metadata-write-refusal.dogfood.test.ts index 17193378d26..106e82778e9 100644 --- a/packages/qa/dogfood/test/sys-file-metadata-write-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/sys-file-metadata-write-refusal.dogfood.test.ts @@ -88,7 +88,7 @@ const SEEDED = { mime_type: 'application/octet-stream', size: 11 } as const; */ const pinStack = defineStack({ manifest: { - id: 'com.dogfood.sys_file_write_refusal', + id: 'com.dogfood.sys-file-write-refusal', namespace: 'sfp', version: '0.0.0', type: 'app', diff --git a/packages/qa/dogfood/test/validation-message-locale.dogfood.test.ts b/packages/qa/dogfood/test/validation-message-locale.dogfood.test.ts index cdd6ad4d012..759f36b690e 100644 --- a/packages/qa/dogfood/test/validation-message-locale.dogfood.test.ts +++ b/packages/qa/dogfood/test/validation-message-locale.dogfood.test.ts @@ -39,7 +39,7 @@ const VmlSettlement = ObjectSchema.create({ const vmlStack = defineStack({ manifest: { - id: 'com.dogfood.validation_message_locale', + id: 'com.dogfood.validation-message-locale', namespace: 'vml', version: '0.0.0', type: 'app', diff --git a/packages/qa/downstream-contract/src/stack.ts b/packages/qa/downstream-contract/src/stack.ts index 0f5a759230e..fe20ebd86d1 100644 --- a/packages/qa/downstream-contract/src/stack.ts +++ b/packages/qa/downstream-contract/src/stack.ts @@ -11,7 +11,7 @@ import { ArchiveAccountAction } from './modern.action.js'; export const ContractStack = defineStack({ manifest: { - id: 'com.objectstack.downstream_contract', + id: 'com.objectstack.downstream-contract', namespace: 'dc', version: '1.0.0', type: 'app', From f983362592d0057c2827288982220244653ea236 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 23:41:45 +0000 Subject: [PATCH 08/19] test(qa): rename the last dogfood fixture id, completing the 21 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `com.dogfood.flow_fixture` was held back in the previous commit because the literal also appears twice in `packages/spec/src/kernel/manifest.test.ts`, so it was not a single-occurrence declaration and the mechanical rename mandate did not obviously cover it. Those two hits are deliberate NEGATIVE TEST INPUT, not references to this fixture: line 643 asserts the refusal for that value suggests `com.dogfood.flow-fixture`, and line 653 feeds it alongside `blank`, `my_app` and `support_desk` as a batch of ids the schema must refuse. They are left exactly as they are — renaming them would delete the test of the rule this card adds. The new id is the one the refusal itself prescribes for this value, the same underscore-to-hyphen repair the other 20 took. `manifest.test.ts` stays green (66 passed) with the fixture renamed, which is what shows the two are independent. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- packages/qa/dogfood/test/fixtures/flow-touch-fixture.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/qa/dogfood/test/fixtures/flow-touch-fixture.ts b/packages/qa/dogfood/test/fixtures/flow-touch-fixture.ts index dec8cfb9522..0c15823c285 100644 --- a/packages/qa/dogfood/test/fixtures/flow-touch-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/flow-touch-fixture.ts @@ -71,7 +71,7 @@ export const flowTouch: Flow = { /** A minimal, self-contained app config the dogfood harness can boot. */ export const flowFixtureStack = defineStack({ manifest: { - id: 'com.dogfood.flow_fixture', + id: 'com.dogfood.flow-fixture', namespace: 'flow', version: '0.0.0', type: 'app', From 1275a132852179ffef81c16059955d88b3b1ffc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 00:32:44 +0000 Subject: [PATCH 09/19] test: give the remaining non-conforming manifest ids reverse-domain spellings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maintainer ruled that the fixtures outside `packages/qa` are renamed in this PR too. Each new id is the one the schema's own refusal prescribes for the old value, so the tree and the diagnostic agree. Renamed (18 ids, 16 files): `packages/cli` 14, `packages/lint` 3, `packages/metadata` 1, `packages/plugins/plugin-dev` 3 (see below), plus the docblock noted last. Three of these were NOT single-literal swaps, and are called out because a naive rename would have silently deleted what the test pins: * `dev-i18n-packages-reader.test.ts` builds a dependency CYCLE between two packages, and the `dependencies` keys address the ids BY NAME. The ids and both dependency keys are renamed together; renaming only the ids would have dissolved the cycle and left the test asserting nothing. It still reports `Circular dependency detected`. * `metadata-type-schema-gate.test.ts` and `format-zod-union.test.ts` and `authoring-rule-command-parity.test.ts` each carry a `namespace` that repeats the old id, and the first also carries a route path `/api/v1/apps/gate_probe/things`. Only `manifest.id` is renamed — `namespace` has its own rule and the path pairs with it. * `plugin-dev/src/index.ts` is a DOCBLOCK example, not a fixture: prose teaching `manifest: { id: 'my-app' }`, an id its own schema now refuses. It cannot red a test; it is corrected because it is published teaching. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../cli/src/commands/migrate/duplicates.integration.test.ts | 2 +- .../cli/src/commands/migrate/duplicates.null-seam.test.ts | 2 +- .../migrate/meta.stored-flow-resolution.integration.test.ts | 2 +- .../utils/platform-migrations-arming.integration.test.ts | 2 +- .../utils/schema-migrate.deferred-ddl.integration.test.ts | 2 +- packages/cli/src/utils/schema-migrate.integration.test.ts | 4 ++-- .../utils/schema-migrate.readonly-probe.integration.test.ts | 2 +- .../src/utils/schema-migrate.teardown.integration.test.ts | 2 +- packages/cli/test/authoring-rule-command-parity.test.ts | 2 +- packages/cli/test/format-zod-union.test.ts | 2 +- packages/cli/test/metadata-type-schema-gate.test.ts | 2 +- packages/cli/test/migrate-meta-default-range.test.ts | 4 ++-- packages/lint/src/validate-expressions.test.ts | 2 +- packages/lint/src/validate-rule-compilability.test.ts | 2 +- packages/lint/src/validate-rule-schema-formats.test.ts | 2 +- .../src/plugin-artifact-view-container-object.test.ts | 2 +- .../plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts | 6 +++--- packages/plugins/plugin-dev/src/index.ts | 2 +- 18 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/commands/migrate/duplicates.integration.test.ts b/packages/cli/src/commands/migrate/duplicates.integration.test.ts index e2011dfed24..ea3e9b6a811 100644 --- a/packages/cli/src/commands/migrate/duplicates.integration.test.ts +++ b/packages/cli/src/commands/migrate/duplicates.integration.test.ts @@ -101,7 +101,7 @@ beforeAll(async () => { writeFileSync( join(dir, 'dist', 'objectstack.json'), JSON.stringify({ - manifest: { id: 'dup_smoke', name: 'Duplicates Smoke', version: '0.0.0', type: 'app' }, + manifest: { id: 'com.example.dup-smoke', name: 'Duplicates Smoke', version: '0.0.0', type: 'app' }, objects: [ { name: 'crm_case', diff --git a/packages/cli/src/commands/migrate/duplicates.null-seam.test.ts b/packages/cli/src/commands/migrate/duplicates.null-seam.test.ts index b9d7b7e1e06..a2f8da23593 100644 --- a/packages/cli/src/commands/migrate/duplicates.null-seam.test.ts +++ b/packages/cli/src/commands/migrate/duplicates.null-seam.test.ts @@ -59,7 +59,7 @@ beforeAll(() => { writeFileSync( join(dir, 'dist', 'objectstack.json'), JSON.stringify({ - manifest: { id: 'dup_null_seam', name: 'Null Seam', version: '0.0.0', type: 'app' }, + manifest: { id: 'com.example.dup-null-seam', name: 'Null Seam', version: '0.0.0', type: 'app' }, objects: [ { name: 'crm_case', diff --git a/packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts b/packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts index 3ef12eca3f8..ede7c30417d 100644 --- a/packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts +++ b/packages/cli/src/commands/migrate/meta.stored-flow-resolution.integration.test.ts @@ -41,7 +41,7 @@ const SYSTEM = { context: { isSystem: true } }; const ARTIFACT = { // #8687: manifest fields under `manifest:` — the flat spelling is refused. - manifest: { id: 'stored_flow_smoke', name: 'Stored Flow Smoke', version: '0.0.0', type: 'app' }, + manifest: { id: 'com.example.stored-flow-smoke', name: 'Stored Flow Smoke', version: '0.0.0', type: 'app' }, objects: [{ name: 'sfs_lead', fields: { title: { type: 'text' } } }], }; diff --git a/packages/cli/src/utils/platform-migrations-arming.integration.test.ts b/packages/cli/src/utils/platform-migrations-arming.integration.test.ts index 643901dae23..a7ba89be87c 100644 --- a/packages/cli/src/utils/platform-migrations-arming.integration.test.ts +++ b/packages/cli/src/utils/platform-migrations-arming.integration.test.ts @@ -234,7 +234,7 @@ beforeEach(async () => { writeFileSync( join(dir, 'dist', 'objectstack.json'), JSON.stringify({ - manifest: { id: 'os_9380', name: 'Platform Migration Arming', version: '0.0.0', type: 'app' }, + manifest: { id: 'com.example.os-9380', name: 'Platform Migration Arming', version: '0.0.0', type: 'app' }, objects: [ { name: 'crm_case', diff --git a/packages/cli/src/utils/schema-migrate.deferred-ddl.integration.test.ts b/packages/cli/src/utils/schema-migrate.deferred-ddl.integration.test.ts index f00ef1f1333..7148b0592b7 100644 --- a/packages/cli/src/utils/schema-migrate.deferred-ddl.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.deferred-ddl.integration.test.ts @@ -24,7 +24,7 @@ import { composeForDeclarations } from './schema-migration-plugins.js'; const ARTIFACT = { // #8687: manifest fields under `manifest:` — the flat spelling is refused. - manifest: { id: 'defer_smoke', name: 'Defer Smoke', version: '0.0.0', type: 'app' }, + manifest: { id: 'com.example.defer-smoke', name: 'Defer Smoke', version: '0.0.0', type: 'app' }, objects: [ { name: 'defer_widget', diff --git a/packages/cli/src/utils/schema-migrate.integration.test.ts b/packages/cli/src/utils/schema-migrate.integration.test.ts index 0cb8acfa580..19c9b4a31fb 100644 --- a/packages/cli/src/utils/schema-migrate.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.integration.test.ts @@ -36,7 +36,7 @@ describe('bootSchemaStack + migrate engine (integration)', () => { JSON.stringify({ // #8687: manifest fields belong under `manifest:` — the flat spelling // was silently stripped before the strict close and is refused now. - manifest: { id: 'mig_smoke', name: 'Migrate Smoke', version: '0.0.0', type: 'app' }, + manifest: { id: 'com.example.mig-smoke', name: 'Migrate Smoke', version: '0.0.0', type: 'app' }, objects: [ { name: 'mig_biz_unit', @@ -150,7 +150,7 @@ describe('bootSchemaStack — dev-provisioned __search companions are not orphan writeFileSync( join(dir, 'dist', 'objectstack.json'), JSON.stringify({ - manifest: { id: 'mig_pinyin_smoke', name: 'Migrate Pinyin Smoke', version: '0.0.0', type: 'app' }, + manifest: { id: 'com.example.mig-pinyin-smoke', name: 'Migrate Pinyin Smoke', version: '0.0.0', type: 'app' }, i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'], fallbackLocale: 'en' }, objects: [ { diff --git a/packages/cli/src/utils/schema-migrate.readonly-probe.integration.test.ts b/packages/cli/src/utils/schema-migrate.readonly-probe.integration.test.ts index bbd1c4decba..36963bb84df 100644 --- a/packages/cli/src/utils/schema-migrate.readonly-probe.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.readonly-probe.integration.test.ts @@ -37,7 +37,7 @@ import { bootSchemaStack, type PendingSchemaWork } from './schema-migrate.js'; const ARTIFACT = { // #8687: manifest fields under `manifest:` — the flat spelling is refused. - manifest: { id: 'probe_smoke', name: 'Probe Smoke', version: '0.0.0', type: 'app' }, + manifest: { id: 'com.example.probe-smoke', name: 'Probe Smoke', version: '0.0.0', type: 'app' }, objects: [ { name: 'probe_widget', fields: { name: { type: 'text', required: true }, colour: { type: 'text' } } }, { name: 'probe_gadget', fields: { label: { type: 'text' } } }, diff --git a/packages/cli/src/utils/schema-migrate.teardown.integration.test.ts b/packages/cli/src/utils/schema-migrate.teardown.integration.test.ts index c0fe51118fb..d11b31bb8ac 100644 --- a/packages/cli/src/utils/schema-migrate.teardown.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.teardown.integration.test.ts @@ -53,7 +53,7 @@ describe('[#4747] bootSchemaStack teardown disarms the ADR-0057 sweep', () => { join(dir, 'dist', 'objectstack.json'), JSON.stringify({ // #8687: manifest fields under `manifest:` — the flat spelling is refused. - manifest: { id: 'teardown_smoke', name: 'Teardown Smoke', version: '0.0.0', type: 'app' }, + manifest: { id: 'com.example.teardown-smoke', name: 'Teardown Smoke', version: '0.0.0', type: 'app' }, objects: [ { name: 'td_note', diff --git a/packages/cli/test/authoring-rule-command-parity.test.ts b/packages/cli/test/authoring-rule-command-parity.test.ts index 86e56dcbf2a..0da5c8381b2 100644 --- a/packages/cli/test/authoring-rule-command-parity.test.ts +++ b/packages/cli/test/authoring-rule-command-parity.test.ts @@ -35,7 +35,7 @@ const cliBin = join(fileURLToPath(new URL('.', import.meta.url)), '..', 'bin', ' /** A stack that satisfies the security linter, so only the planted defect gates. */ const withBaseline = (stack: Record) => ({ - manifest: { id: 'parity', namespace: 'parity', version: '1.0.0', name: 'Parity', type: 'app', engines: { protocol: '^17' } }, + manifest: { id: 'com.example.parity', namespace: 'parity', version: '1.0.0', name: 'Parity', type: 'app', engines: { protocol: '^17' } }, ...stack, }); diff --git a/packages/cli/test/format-zod-union.test.ts b/packages/cli/test/format-zod-union.test.ts index 3a55542ea2a..42e2ef9127d 100644 --- a/packages/cli/test/format-zod-union.test.ts +++ b/packages/cli/test/format-zod-union.test.ts @@ -167,7 +167,7 @@ describe('[#5341] formatZodErrors expands invalid_union branches', () => { * says so in its own failure message, where the next reader will be standing. */ const TOOLTIP_ALIAS_STACK = { - manifest: { id: 'union_probe', name: 'Union Probe', namespace: 'union_probe', version: '1.0.0', type: 'app' }, + manifest: { id: 'com.example.union-probe', name: 'Union Probe', namespace: 'union_probe', version: '1.0.0', type: 'app' }, views: [ { name: 'union_probe_view', diff --git a/packages/cli/test/metadata-type-schema-gate.test.ts b/packages/cli/test/metadata-type-schema-gate.test.ts index 73f191f3960..3dda2920e9e 100644 --- a/packages/cli/test/metadata-type-schema-gate.test.ts +++ b/packages/cli/test/metadata-type-schema-gate.test.ts @@ -156,7 +156,7 @@ function undeclaredKeyRejections(result: { success: boolean; error?: any }): str } const MANIFEST = { - id: 'gate_probe', + id: 'com.example.gate-probe', name: 'Gate Probe', namespace: 'gate_probe', version: '1.0.0', diff --git a/packages/cli/test/migrate-meta-default-range.test.ts b/packages/cli/test/migrate-meta-default-range.test.ts index e168127752b..dd3c469cdbd 100644 --- a/packages/cli/test/migrate-meta-default-range.test.ts +++ b/packages/cli/test/migrate-meta-default-range.test.ts @@ -71,7 +71,7 @@ const INSTALLED = String(PROTOCOL_MAJOR); */ const RETIRED_KEY_CONFIG = ` export default { - manifest: { id: 'default_range_repro', name: 'Default Range Repro', version: '1.0.0', type: 'app' }, + manifest: { id: 'com.example.default-range-repro', name: 'Default Range Repro', version: '1.0.0', type: 'app' }, objects: [{ name: 'dr_ticket', label: 'Ticket', fields: { title: { type: 'text', label: 'Title' } } }], dashboards: [ { name: 'kpi_a', label: 'KPI A', widgets: [], refreshInterval: 300 }, @@ -86,7 +86,7 @@ export default { /** The same shape already canonical — the control every "it fired" line needs. */ const CANONICAL_CONFIG = ` export default { - manifest: { id: 'default_range_canon', name: 'Default Range Canon', version: '1.0.0', type: 'app' }, + manifest: { id: 'com.example.default-range-canon', name: 'Default Range Canon', version: '1.0.0', type: 'app' }, objects: [{ name: 'dr_thing', label: 'Thing', fields: { title: { type: 'text', label: 'Title' } } }], dashboards: [{ name: 'kpi_a', label: 'KPI A', widgets: [], refreshIntervalSeconds: 300 }], }; diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 5f53eddf340..a516c5ec73e 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -3012,7 +3012,7 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t /** * ── The alias spellings this rule deliberately does NOT read (#5017) ───────── */ -const MANIFEST = { id: 'expr_probe', name: 'expr_probe', version: '1.0.0', type: 'app' } as const; +const MANIFEST = { id: 'com.example.expr-probe', name: 'expr_probe', version: '1.0.0', type: 'app' } as const; /** A fixture is only a fixture if the spec accepts it. */ function specValid(stack: Record): Record { diff --git a/packages/lint/src/validate-rule-compilability.test.ts b/packages/lint/src/validate-rule-compilability.test.ts index 88ec703b498..01610ecd9f1 100644 --- a/packages/lint/src/validate-rule-compilability.test.ts +++ b/packages/lint/src/validate-rule-compilability.test.ts @@ -34,7 +34,7 @@ import { AUTHORING_COMMANDS, AUTHORING_RULES, authoringRulesFor, runAuthoringRul const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); const RUNTIME_VALIDATOR = 'packages/objectql/src/validation/rule-validator.ts'; -const MANIFEST = { id: 'rule_compilability_probe', name: 'rule_compilability_probe', version: '1.0.0', type: 'app' } as const; +const MANIFEST = { id: 'com.example.rule-compilability-probe', name: 'rule_compilability_probe', version: '1.0.0', type: 'app' } as const; /** Does the schema refuse any KEY in this stack (as opposed to any VALUE)? */ function unrecognizedKeysIn(stack: unknown): string[] { diff --git a/packages/lint/src/validate-rule-schema-formats.test.ts b/packages/lint/src/validate-rule-schema-formats.test.ts index 1f24bf0e70c..34062476fcf 100644 --- a/packages/lint/src/validate-rule-schema-formats.test.ts +++ b/packages/lint/src/validate-rule-schema-formats.test.ts @@ -34,7 +34,7 @@ import { registeredFormatNames, validateRuleCompilability } from './validate-rul import { AUTHORING_COMMANDS, AUTHORING_RULES, authoringRulesFor, runAuthoringRules } from './authoring-rules.js'; const srcDir = dirname(fileURLToPath(import.meta.url)); -const MANIFEST = { id: 'schema_format_probe', name: 'schema_format_probe', version: '1.0.0', type: 'app' } as const; +const MANIFEST = { id: 'com.example.schema-format-probe', name: 'schema_format_probe', version: '1.0.0', type: 'app' } as const; /** One object carrying the given validation rules. */ const objectWith = (...validations: unknown[]) => ({ diff --git a/packages/metadata/src/plugin-artifact-view-container-object.test.ts b/packages/metadata/src/plugin-artifact-view-container-object.test.ts index ef248474366..c7be5b51564 100644 --- a/packages/metadata/src/plugin-artifact-view-container-object.test.ts +++ b/packages/metadata/src/plugin-artifact-view-container-object.test.ts @@ -62,7 +62,7 @@ vi.mock('@objectstack/core', async (orig) => ({ createLogger: () => logger, })); -const MANIFEST = { id: 'crm', name: 'CRM', version: '1.0.0', type: 'app' }; +const MANIFEST = { id: 'com.example.crm', name: 'CRM', version: '1.0.0', type: 'app' }; /** * The card's shape: the binding lives ONLY in the container's own top-level diff --git a/packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts b/packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts index bd106b235b9..e43552d105e 100644 --- a/packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts +++ b/packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts @@ -281,10 +281,10 @@ describe('#15232 — DevPlugin i18n auto-detect over a multi-package stack', () // platform's one topological sorter, not from the artifact gate. A caller // matching on `code` alone would miss it — `DevPlugin`'s catch does not. const cyclic = { - manifest: { id: 'a', name: 'A', version: '1.0.0', type: 'app' }, + manifest: { id: 'com.example.a', name: 'A', version: '1.0.0', type: 'app' }, packages: [ - { manifest: { id: 'a', name: 'A', version: '1.0.0', type: 'app', dependencies: { b: '^1.0.0' } } }, - { manifest: { id: 'b', name: 'B', version: '1.0.0', type: 'module', dependencies: { a: '^1.0.0' } } }, + { manifest: { id: 'com.example.a', name: 'A', version: '1.0.0', type: 'app', dependencies: { 'com.example.b': '^1.0.0' } } }, + { manifest: { id: 'com.example.b', name: 'B', version: '1.0.0', type: 'module', dependencies: { 'com.example.a': '^1.0.0' } } }, ], }; let caught: (Error & { code?: unknown; status?: unknown }) | undefined; diff --git a/packages/plugins/plugin-dev/src/index.ts b/packages/plugins/plugin-dev/src/index.ts index ba3d2fb0d61..ae25d6a8fe6 100644 --- a/packages/plugins/plugin-dev/src/index.ts +++ b/packages/plugins/plugin-dev/src/index.ts @@ -22,7 +22,7 @@ * import { DevPlugin } from '@objectstack/plugin-dev'; * * export default defineStack({ - * manifest: { id: 'my-app', name: 'My App', version: '0.1.0', type: 'app' }, + * manifest: { id: 'com.example.my-app', name: 'My App', version: '0.1.0', type: 'app' }, * plugins: [new DevPlugin()], * }); * ``` From 9ea5ebad6409465913337e7060e8f8af846b3908 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 00:51:55 +0000 Subject: [PATCH 10/19] test: rename the single-occurrence refused manifest ids outside packages/qa MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 19 more ids, each the target the schema's own refusal prescribes for the old value, rewritten at the exact offset of the `manifest.id` literal so that a `namespace`, a `name` or a route path repeating the old spelling is left alone. Scope of THIS commit is deliberately the single-occurrence ones. Every id here occurs exactly once in its own file, so the rename cannot silently unhook an assertion that addresses the old value. Not included, and reported rather than guessed: * 15 ids whose literal occurs MORE than once in its own file, so renaming the declaration alone would leave assertions naming the old value — `pkg-a` (7 occurrences), `test` (6), `a` (6), `my-app` (5), `demo` (3) and ten more. These need a coordinated rename of the id and every reference, which is reading work per file, not a mechanical swap. * `com.test.14397` — the schema offers NO suggestion for it, because the failing segment starts with a digit and no mechanical repair exists. * `artifact-granted-permissions.test.ts` — not a rename at all: it pins the ADR-0130 D4 door ORDER, that `''` passes the schema and is caught one door later. The new rule moves the refusal to door 1, so no conforming id can preserve what the test pins. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- packages/cli/src/utils/stack-collections.test.ts | 2 +- .../lint-hook-rules-reach-handler-hooks.e2e.test.ts | 10 +++++----- packages/cli/test/lint-protocol-range.test.ts | 4 ++-- packages/cli/test/migrate-meta.e2e.test.ts | 6 +++--- packages/cli/test/score-lint-crash.test.ts | 2 +- .../create-objectstack/src/rewrite-identity.test.ts | 2 +- packages/lint/src/validate-object-references.test.ts | 2 +- .../src/suggested-audience-bindings.test.ts | 2 +- packages/qa/dogfood/test/derive-topology.test.ts | 2 +- packages/qa/dogfood/test/rls-runner.test.ts | 2 +- .../package-door-execctx-fault-reachability.test.ts | 2 +- .../src/domains/packages-capability-gate.test.ts | 2 +- 12 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/utils/stack-collections.test.ts b/packages/cli/src/utils/stack-collections.test.ts index b9be13214ed..e11da6f8ef5 100644 --- a/packages/cli/src/utils/stack-collections.test.ts +++ b/packages/cli/src/utils/stack-collections.test.ts @@ -156,7 +156,7 @@ describe('#15006 — the wrap gate and the i18n gate', () => { it('auto-registers i18n on both shapes — this one DID lose', () => { expect(bundleDeclaresTranslations(additive())).toBe(true); expect(bundleDeclaresTranslations(optionB())).toBe(true); - expect(bundleDeclaresTranslations({ manifest: { id: 'a' } })).toBe(false); + expect(bundleDeclaresTranslations({ manifest: { id: 'com.example.a' } })).toBe(false); // The nested-bundle shape a host/aggregator config composes. expect(bundleDeclaresTranslations({ manifest: { translations: [{ en: {} }] } })).toBe(true); expect(bundleDeclaresTranslations({ i18n: { defaultLocale: 'en' } })).toBe(true); diff --git a/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts b/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts index df53b38b72b..7d80107d465 100644 --- a/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts +++ b/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts @@ -86,7 +86,7 @@ const OBJECT = `{ /** INTAKE: the reference app's shape — an inline handler, no `body`. */ const CONFIG_HANDLER = ` export default { - manifest: { id: 'com.example.reach_handler', name: 'reach_handler', version: '1.0.0', type: 'app' }, + manifest: { id: 'com.example.reach-handler', name: 'reach_handler', version: '1.0.0', type: 'app' }, objects: [${OBJECT}], hooks: [{ name: 'escalate', @@ -102,7 +102,7 @@ export default { /** CONTROL: the identical statement authored as an explicit `body`. */ const CONFIG_BODY = ` export default { - manifest: { id: 'com.example.reach_body', name: 'reach_body', version: '1.0.0', type: 'app' }, + manifest: { id: 'com.example.reach-body', name: 'reach_body', version: '1.0.0', type: 'app' }, objects: [${OBJECT}], hooks: [{ name: 'escalate', @@ -124,7 +124,7 @@ export default { */ const CONFIG_HANDLER_OK = ` export default { - manifest: { id: 'com.example.reach_handler_ok', name: 'reach_handler_ok', version: '1.0.0', type: 'app' }, + manifest: { id: 'com.example.reach-handler-ok', name: 'reach_handler_ok', version: '1.0.0', type: 'app' }, objects: [${OBJECT}], hooks: [{ name: 'retitle', @@ -151,7 +151,7 @@ export default { */ const CONFIG_ACTION_TARGET = ` export default { - manifest: { id: 'com.example.reach_action_target', name: 'reach_action_target', version: '1.0.0', type: 'app' }, + manifest: { id: 'com.example.reach-action-target', name: 'reach_action_target', version: '1.0.0', type: 'app' }, objects: [{ name: 'crm_case', label: 'Case', @@ -189,7 +189,7 @@ export default { */ const CONFIG_FUNCTIONS_NAMELESS = ` export default { - manifest: { id: 'com.example.reach_functions_nameless', name: 'reach_functions_nameless', version: '1.0.0', type: 'app' }, + manifest: { id: 'com.example.reach-functions-nameless', name: 'reach_functions_nameless', version: '1.0.0', type: 'app' }, objects: [${OBJECT}], functions: [{ handler: async (ctx: any) => { diff --git a/packages/cli/test/lint-protocol-range.test.ts b/packages/cli/test/lint-protocol-range.test.ts index 4df63a06cae..a22fb75623a 100644 --- a/packages/cli/test/lint-protocol-range.test.ts +++ b/packages/cli/test/lint-protocol-range.test.ts @@ -28,8 +28,8 @@ describe('lint protocol/missing-engines-range', () => { }); it('accepts the engines.platform and legacy engine.objectstack fallbacks', () => { - expect(protocolIssues({ manifest: { id: 'a', engines: { platform: '>=15' } } })).toEqual([]); - expect(protocolIssues({ manifest: { id: 'b', engine: { objectstack: '^15.0.0' } } })).toEqual([]); + expect(protocolIssues({ manifest: { id: 'com.example.a', engines: { platform: '>=15' } } })).toEqual([]); + expect(protocolIssues({ manifest: { id: 'com.example.b', engine: { objectstack: '^15.0.0' } } })).toEqual([]); }); it('stays silent for a bare metadata fragment with no manifest', () => { diff --git a/packages/cli/test/migrate-meta.e2e.test.ts b/packages/cli/test/migrate-meta.e2e.test.ts index 2cf437513a8..9805a8771ec 100644 --- a/packages/cli/test/migrate-meta.e2e.test.ts +++ b/packages/cli/test/migrate-meta.e2e.test.ts @@ -42,7 +42,7 @@ const PRE17_CONFIG = ` export default { // #8687: top-level name/label were never stack keys (silently stripped // before the strict close, refused now) — the identity lives in manifest. - manifest: { id: 'migrate_meta_e2e', name: 'Migrate Meta E2E', version: '1.0.0', type: 'app' }, + manifest: { id: 'com.example.migrate-meta-e2e', name: 'Migrate Meta E2E', version: '1.0.0', type: 'app' }, objects: [{ name: 'e2e_ticket', label: 'Ticket', @@ -382,7 +382,7 @@ import { defineStack } from '@objectstack/spec'; import { defineAgent, defineSkill } from '@objectstack/spec/ai'; export default defineStack({ - manifest: { id: 'retired_key_e2e', name: 'Retired Key E2E', version: '1.0.0', type: 'app', namespace: 'rk' }, + manifest: { id: 'com.example.retired-key-e2e', name: 'Retired Key E2E', version: '1.0.0', type: 'app', namespace: 'rk' }, objects: [{ name: 'rk_ticket', label: 'Ticket', @@ -494,7 +494,7 @@ export default defineStack({ describe('os migrate meta — the chain line names the protocol, not a package version', () => { const LABEL_CONFIG = ` export default { - manifest: { id: 'chain_label_e2e', name: 'Chain Label E2E', version: '1.0.0', type: 'app' }, + manifest: { id: 'com.example.chain-label-e2e', name: 'Chain Label E2E', version: '1.0.0', type: 'app' }, objects: [{ name: 'label_ticket', label: 'Ticket', fields: { title: { type: 'text', label: 'Title' } } }], }; `; diff --git a/packages/cli/test/score-lint-crash.test.ts b/packages/cli/test/score-lint-crash.test.ts index ef90e28958d..815f6e92f7b 100644 --- a/packages/cli/test/score-lint-crash.test.ts +++ b/packages/cli/test/score-lint-crash.test.ts @@ -58,7 +58,7 @@ const STACK = { /** Schema-INVALID (`namespace` fails its pattern), so the parse half has a verdict. */ const SCHEMA_INVALID_STACK = { - manifest: { id: 'bad', namespace: 'X', version: '1.0.0', name: 'Bad', type: 'app' as const }, + manifest: { id: 'com.example.bad', namespace: 'X', version: '1.0.0', name: 'Bad', type: 'app' as const }, }; function withLinterThrowing(thrown: unknown, fn: () => T): T { diff --git a/packages/create-objectstack/src/rewrite-identity.test.ts b/packages/create-objectstack/src/rewrite-identity.test.ts index ffa41bb1e53..bb2bfa0e15a 100644 --- a/packages/create-objectstack/src/rewrite-identity.test.ts +++ b/packages/create-objectstack/src/rewrite-identity.test.ts @@ -72,7 +72,7 @@ describe('readTemplateNamespace', () => { ); fs.writeFileSync( path.join(dir, 'objectstack.config.ts'), - 'export default defineStack({ manifest: { id: "x" } });\n', + 'export default defineStack({ manifest: { id: "com.example.x" } });\n', ); expect(readTemplateNamespace(dir)).toBe('fallback'); }); diff --git a/packages/lint/src/validate-object-references.test.ts b/packages/lint/src/validate-object-references.test.ts index f199cbc1465..1eb48ed002c 100644 --- a/packages/lint/src/validate-object-references.test.ts +++ b/packages/lint/src/validate-object-references.test.ts @@ -291,7 +291,7 @@ describe('validateObjectReferences — artifact packages[] as resolution context // content (ADR-0130 D4). Inventing a name for such an entry would silence // the ladder, which is the one mistake this context must not make. const findings = validateObjectReferences( - perPackageStack(ORDERS_BODY, [{ ref: 'sha256-x' }, { manifest: { id: 'x' } }, 'not-an-entry']), + perPackageStack(ORDERS_BODY, [{ ref: 'sha256-x' }, { manifest: { id: 'com.example.x' } }, 'not-an-entry']), ); expect(findings).toHaveLength(1); expect(findings[0].path).toBe('objects[0].fields.account.reference'); diff --git a/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts b/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts index e2b043c2278..9dc90950d34 100644 --- a/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts +++ b/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts @@ -231,7 +231,7 @@ describe('syncAudienceBindingSuggestions (ADR-0090 D5/D9)', () => { it('ignores non-isDefault sets and unowned declarations', async () => { const ql = makeQl([ - { enabled: true, manifest: { id: 'p1', permissions: [{ name: 'plain', objects: {} }] } }, + { enabled: true, manifest: { id: 'com.example.p1', permissions: [{ name: 'plain', objects: {} }] } }, { enabled: true, manifest: { permissions: [{ name: 'orphan', isDefault: true, objects: {} }] } }, ]); const out = await syncAudienceBindingSuggestions(ql); diff --git a/packages/qa/dogfood/test/derive-topology.test.ts b/packages/qa/dogfood/test/derive-topology.test.ts index 202cd5689fa..a05fa11e47b 100644 --- a/packages/qa/dogfood/test/derive-topology.test.ts +++ b/packages/qa/dogfood/test/derive-topology.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect } from 'vitest'; import { deriveCrudCases, fillRelationalRefs, type CrudCase } from '@objectstack/verify'; const obj = (name: string, fields: Record) => ({ name, fields }); -const cfg = (...objects: any[]) => ({ manifest: { id: 'fixture' }, objects }); +const cfg = (...objects: any[]) => ({ manifest: { id: 'com.example.fixture' }, objects }); function byName(cases: CrudCase[]): Map { return new Map(cases.map((c) => [c.object, c])); diff --git a/packages/qa/dogfood/test/rls-runner.test.ts b/packages/qa/dogfood/test/rls-runner.test.ts index 632691ed97d..7b5006afa71 100644 --- a/packages/qa/dogfood/test/rls-runner.test.ts +++ b/packages/qa/dogfood/test/rls-runner.test.ts @@ -32,7 +32,7 @@ import { runRlsProofs, declaredPositionNames } from '@objectstack/verify'; import type { VerifyStack } from '@objectstack/verify'; const CONFIG = { - manifest: { id: 'fixture' }, + manifest: { id: 'com.example.fixture' }, objects: [{ name: 'note', fields: { name: { type: 'text', required: true } } }], }; diff --git a/packages/rest/src/package-door-execctx-fault-reachability.test.ts b/packages/rest/src/package-door-execctx-fault-reachability.test.ts index a649ae1a6b0..d9bb704da7e 100644 --- a/packages/rest/src/package-door-execctx-fault-reachability.test.ts +++ b/packages/rest/src/package-door-execctx-fault-reachability.test.ts @@ -526,7 +526,7 @@ describe('[#13255] consequence — the door\'s answer for each fault class', () const routes = mount(serverWith(klass.faulted())); const extra = klass.req ?? {}; const bucket = klass.ctx === 'loud' ? loud : quiet; - bucket.push((await drive(routes, 'POST', `${PKGS}/publish`, { ...extra, body: { manifest: { id: 'x', version: '1.0.0' } } })).status); + bucket.push((await drive(routes, 'POST', `${PKGS}/publish`, { ...extra, body: { manifest: { id: 'com.example.x', version: '1.0.0' } } })).status); } // The ruled class: the outage is the answer, on EVERY route — not one door // taught to be loud while its siblings kept the disguise. diff --git a/packages/runtime/src/domains/packages-capability-gate.test.ts b/packages/runtime/src/domains/packages-capability-gate.test.ts index e16700d08bb..87eecc65652 100644 --- a/packages/runtime/src/domains/packages-capability-gate.test.ts +++ b/packages/runtime/src/domains/packages-capability-gate.test.ts @@ -145,7 +145,7 @@ const WRITE_ROUTES: WriteCase[] = [ // `overwrite` so the allow-path clears the 409 duplicate guard (the shared // registry double answers `getPackage` truthy for any id); the write gate // runs FIRST, so the deny cases still 403/401 before this is consulted. - { name: 'POST /packages (install)', path: '/', method: 'POST', body: { manifest: { id: 'pkg-new', name: 'n', version: '1.0.0' } }, query: { overwrite: 'true' }, target: (_p, r) => r.installPackage }, + { name: 'POST /packages (install)', path: '/', method: 'POST', body: { manifest: { id: 'com.example.pkg-new', name: 'n', version: '1.0.0' } }, query: { overwrite: 'true' }, target: (_p, r) => r.installPackage }, { name: 'PATCH /:id/enable', path: '/pkg-a/enable', method: 'PATCH', target: (_p, r) => r.enablePackage }, { name: 'PATCH /:id/disable', path: '/pkg-a/disable', method: 'PATCH', target: (_p, r) => r.disablePackage }, { name: 'POST /:id/publish-drafts', path: '/pkg-a/publish-drafts', method: 'POST', target: (p) => p.publishPackageDrafts }, From ea52fd6ceca34ea03e58fced166b9144c7ad9f73 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 01:25:38 +0000 Subject: [PATCH 11/19] test: rename the coupled manifest ids with their in-file references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last 16, each read before it was touched. Every replacement is anchored on the `id:` key, so a `name`, a `namespace` or a field named the same is left alone, and each file's occurrence count was asserted before and after. Reading first is what made these safe, and two would have gone wrong without it: * `artifact-collections.test.ts` has `'a'` six times, but only two are the package id — the other four are a FIELD named `a` (`{ a: { name: 'a', type: 'text' } }`). A whole-file swap would have renamed a field the assertions address. * `package-registry.test.ts` and `lint-protocol-range.test.ts` repeat the old id as a `namespace`, and `serve-host-config.test.ts`, `metadata-collection.test.ts` and others repeat it as a free-form `name`. `namespace` has its own rule and `name` has none; neither is renamed. Where the repeat WAS a real reference it moved with the id: all seven `pkg-a` in `packages-capability-gate.test.ts` (package record ids and manifest ids in the same mock), both in `packages-uninstall-envelope.test.ts`, both `test-app` registrations in objectql, and both `locale-producer-app` sites in runtime. `com.test.14397` becomes `com.test.card-14397`: the schema offers no suggestion for it, because the last segment starts with a digit, so the name was ruled rather than derived. `ManifestSchema` confirms the replacement parses. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- ...erve-host-config-security-registrar.pin.test.ts | 2 +- packages/cli/src/utils/stack-collections.test.ts | 2 +- packages/cli/test/lint-protocol-range.test.ts | 4 ++-- packages/cli/test/serve-host-config.test.ts | 14 +++++++------- .../lint/src/validate-org-axis-red-lines.test.ts | 2 +- .../lint/src/validate-security-posture.test.ts | 2 +- packages/objectql/src/plugin.integration.test.ts | 4 ++-- packages/plugins/plugin-dev/src/dev-plugin.test.ts | 2 +- .../app-plugin-shutdown-emits-unregistered.test.ts | 2 +- .../src/app-plugin.seed-locale-producer.test.ts | 4 ++-- packages/runtime/src/artifact-collections.test.ts | 4 ++-- .../src/domains/packages-capability-gate.test.ts | 12 ++++++------ .../domains/packages-uninstall-envelope.test.ts | 2 +- packages/spec/src/kernel/package-registry.test.ts | 2 +- .../spec/src/shared/metadata-collection.test.ts | 4 ++-- 15 files changed, 31 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts b/packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts index 71eeeac773e..b159a5b7492 100644 --- a/packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts +++ b/packages/cli/src/commands/serve-host-config-security-registrar.pin.test.ts @@ -196,7 +196,7 @@ describe('#14397 — `os dev` over a HOST config composes ONE registrar for stac }); it('behavioural: the option the source passes is the one AppPlugin reads', () => { - const bundle = { manifest: { id: 'com.test.14397', name: 'pin', version: '1.0.0' } }; + const bundle = { manifest: { id: 'com.test.card-14397', name: 'pin', version: '1.0.0' } }; // The exact two literals the composition above can pass. expect(new AppPlugin(bundle, undefined, {}).securityMetadataRegistrar).toBe('app-plugin'); expect( diff --git a/packages/cli/src/utils/stack-collections.test.ts b/packages/cli/src/utils/stack-collections.test.ts index e11da6f8ef5..95503636b81 100644 --- a/packages/cli/src/utils/stack-collections.test.ts +++ b/packages/cli/src/utils/stack-collections.test.ts @@ -103,7 +103,7 @@ describe('#15006 — the auto-registration gates answer the same on BOTH shapes' }); it('says no when nothing anywhere declares an object', () => { - expect(shouldAutoRegisterObjectQL({ manifest: { id: 'x', name: 'x' } }, [])).toBe(false); + expect(shouldAutoRegisterObjectQL({ manifest: { id: 'com.example.x', name: 'x' } }, [])).toBe(false); expect(shouldAutoRegisterStorageDriver({}, [])).toBe(false); expect(shouldAutoRegisterObjectQL(undefined, [])).toBe(false); }); diff --git a/packages/cli/test/lint-protocol-range.test.ts b/packages/cli/test/lint-protocol-range.test.ts index a22fb75623a..882ebb1f98c 100644 --- a/packages/cli/test/lint-protocol-range.test.ts +++ b/packages/cli/test/lint-protocol-range.test.ts @@ -12,7 +12,7 @@ const protocolIssues = (config: any) => lintConfig(config).filter((i) => i.rule describe('lint protocol/missing-engines-range', () => { it('warns when a manifest declares no compatibility range', () => { const issues = protocolIssues({ - manifest: { id: 'demo', namespace: 'demo', version: '1.0.0', name: 'Demo', type: 'app' }, + manifest: { id: 'com.example.demo', namespace: 'demo', version: '1.0.0', name: 'Demo', type: 'app' }, }); expect(issues).toHaveLength(1); expect(issues[0]!.severity).toBe('warning'); @@ -22,7 +22,7 @@ describe('lint protocol/missing-engines-range', () => { it('accepts engines.protocol', () => { expect( protocolIssues({ - manifest: { id: 'demo', engines: { protocol: `^${PROTOCOL_MAJOR}` } }, + manifest: { id: 'com.example.demo', engines: { protocol: `^${PROTOCOL_MAJOR}` } }, }), ).toEqual([]); }); diff --git a/packages/cli/test/serve-host-config.test.ts b/packages/cli/test/serve-host-config.test.ts index e2abccfc277..378a9e38372 100644 --- a/packages/cli/test/serve-host-config.test.ts +++ b/packages/cli/test/serve-host-config.test.ts @@ -13,7 +13,7 @@ describe('Host config detection', () => { it('should detect host config with instantiated plugins', () => { const config = { - manifest: { id: 'dev-workspace', name: 'dev_workspace' }, + manifest: { id: 'com.example.dev-workspace', name: 'dev_workspace' }, plugins: [ { name: 'objectql', init: async () => {}, start: async () => {} }, { name: 'driver', init: async () => {}, start: async () => {} }, @@ -24,7 +24,7 @@ describe('Host config detection', () => { it('should NOT detect pure app bundle config (no plugins)', () => { const config = { - manifest: { id: 'my-app', name: 'my_app' }, + manifest: { id: 'com.example.my-app', name: 'my_app' }, objects: [{ name: 'task', fields: [] }], }; expect(isHostConfig(config)).toBe(false); @@ -32,7 +32,7 @@ describe('Host config detection', () => { it('should NOT detect config with empty plugins array', () => { const config = { - manifest: { id: 'my-app', name: 'my_app' }, + manifest: { id: 'com.example.my-app', name: 'my_app' }, objects: [{ name: 'task', fields: [] }], plugins: [], }; @@ -41,7 +41,7 @@ describe('Host config detection', () => { it('should NOT detect config with string plugin references', () => { const config = { - manifest: { id: 'my-app', name: 'my_app' }, + manifest: { id: 'com.example.my-app', name: 'my_app' }, plugins: ['@objectstack/plugin-auth', '@objectstack/objectql'], }; expect(isHostConfig(config)).toBe(false); @@ -49,7 +49,7 @@ describe('Host config detection', () => { it('should NOT detect config with plain object plugins (no init method)', () => { const config = { - manifest: { id: 'my-app', name: 'my_app' }, + manifest: { id: 'com.example.my-app', name: 'my_app' }, plugins: [ { name: 'some-plugin', version: '1.0.0' }, ], @@ -59,7 +59,7 @@ describe('Host config detection', () => { it('should detect if at least one plugin has init method', () => { const config = { - manifest: { id: 'dev-workspace' }, + manifest: { id: 'com.example.dev-workspace' }, plugins: [ { name: 'plain-bundle', version: '1.0.0' }, { name: 'real-plugin', init: async () => {}, start: async () => {} }, @@ -70,7 +70,7 @@ describe('Host config detection', () => { it('should handle config without plugins property', () => { const config = { - manifest: { id: 'my-app', name: 'my_app' }, + manifest: { id: 'com.example.my-app', name: 'my_app' }, }; expect(isHostConfig(config)).toBe(false); }); diff --git a/packages/lint/src/validate-org-axis-red-lines.test.ts b/packages/lint/src/validate-org-axis-red-lines.test.ts index 782d6a9f4e8..e159622a71a 100644 --- a/packages/lint/src/validate-org-axis-red-lines.test.ts +++ b/packages/lint/src/validate-org-axis-red-lines.test.ts @@ -329,7 +329,7 @@ describe('validateOrgAxisRedLines — ① no permission inheritance on the org a * schema fact that makes it unreachable, so "put the fallback back, just in * case" fails a test with the evidence attached rather than passing quietly. */ -const MANIFEST = { id: 'org_axis_probe', name: 'org_axis_probe', version: '1.0.0', type: 'app' } as const; +const MANIFEST = { id: 'com.example.org-axis-probe', name: 'org_axis_probe', version: '1.0.0', type: 'app' } as const; /** The violating RLS policy shape, spelled for the object-level key that does not exist. */ const ORG_WALKING_POLICY = { name: 'rollup', using: 'parent_organization_id = current_user.organization_id' }; diff --git a/packages/lint/src/validate-security-posture.test.ts b/packages/lint/src/validate-security-posture.test.ts index dada2360ed1..b3c2ad05e5c 100644 --- a/packages/lint/src/validate-security-posture.test.ts +++ b/packages/lint/src/validate-security-posture.test.ts @@ -1316,7 +1316,7 @@ describe('validateSecurityPosture — reads only keys the spec declares (meta-te * the fallback back, just in case" fails a test with the evidence attached * rather than passing quietly. */ -const MANIFEST = { id: 'security_probe', name: 'security_probe', version: '1.0.0', type: 'app' } as const; +const MANIFEST = { id: 'com.example.security-probe', name: 'security_probe', version: '1.0.0', type: 'app' } as const; /** Does the schema refuse any KEY in this stack (as opposed to any VALUE)? */ function unrecognizedKeysIn(stack: unknown): string[] { diff --git a/packages/objectql/src/plugin.integration.test.ts b/packages/objectql/src/plugin.integration.test.ts index 250d97aa493..788e064eba4 100644 --- a/packages/objectql/src/plugin.integration.test.ts +++ b/packages/objectql/src/plugin.integration.test.ts @@ -202,7 +202,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { dependencies: ['com.objectstack.engine.objectql'], init: async (ctx) => { ctx.getService<{ register(m: any): void }>('manifest').register({ - id: 'test-app', + id: 'com.example.test-app', name: 'test_app', version: '1.0.0', type: 'app', @@ -256,7 +256,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => { // Arrange — legacy pattern for backward compatibility const mockApp = { manifest: { - id: 'test-app', + id: 'com.example.test-app', name: 'test_app', version: '1.0.0', type: 'app' diff --git a/packages/plugins/plugin-dev/src/dev-plugin.test.ts b/packages/plugins/plugin-dev/src/dev-plugin.test.ts index 6d539ae9d73..8dca3903b19 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.test.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.test.ts @@ -61,7 +61,7 @@ describe('DevPlugin', () => { seedAdminUser: false, verbose: false, services: { auth: false, dispatcher: false, security: false }, - stack: { manifest: { id: 'test', name: 'test', version: '1.0.0', type: 'app' } }, + stack: { manifest: { id: 'com.example.test', name: 'test', version: '1.0.0', type: 'app' } }, }); expect(plugin).toBeDefined(); }); diff --git a/packages/runtime/src/app-plugin-shutdown-emits-unregistered.test.ts b/packages/runtime/src/app-plugin-shutdown-emits-unregistered.test.ts index ba43b0b5a01..19185af7d97 100644 --- a/packages/runtime/src/app-plugin-shutdown-emits-unregistered.test.ts +++ b/packages/runtime/src/app-plugin-shutdown-emits-unregistered.test.ts @@ -48,7 +48,7 @@ const PROJECT: AppPluginProjectContext = { projectName: 'catalog-teardown', }; -const BUNDLE = { manifest: { id: 'demo_app', name: 'demo_app', label: 'Demo' } }; +const BUNDLE = { manifest: { id: 'com.example.demo-app', name: 'demo_app', label: 'Demo' } }; /** Captures the catalog events AppPlugin puts on the kernel bus. */ class CatalogRecorderPlugin implements Plugin { diff --git a/packages/runtime/src/app-plugin.seed-locale-producer.test.ts b/packages/runtime/src/app-plugin.seed-locale-producer.test.ts index 2c2ff5c2787..3cd696b8cbd 100644 --- a/packages/runtime/src/app-plugin.seed-locale-producer.test.ts +++ b/packages/runtime/src/app-plugin.seed-locale-producer.test.ts @@ -162,7 +162,7 @@ describe('AppPlugin supplies SeedLoaderConfig.locale (#16595)', () => { const seededObjects = () => Object.keys(store).sort(); const bundle = (i18n?: unknown, datasets = ALL_DATASETS) => ({ - id: 'locale-producer-app', + id: 'com.example.locale-producer-app', ...(i18n ? { i18n } : {}), data: datasets, }); @@ -244,7 +244,7 @@ describe('AppPlugin supplies SeedLoaderConfig.locale (#16595)', () => { /** The legacy nested-manifest bundle shape resolves the same key. */ it('reads `i18n.defaultLocale` off a nested `manifest` bundle too', async () => { const plugin = new AppPlugin({ - manifest: { id: 'locale-producer-app', i18n: { defaultLocale: 'zh-CN' } }, + manifest: { id: 'com.example.locale-producer-app', i18n: { defaultLocale: 'zh-CN' } }, data: ALL_DATASETS, }); diff --git a/packages/runtime/src/artifact-collections.test.ts b/packages/runtime/src/artifact-collections.test.ts index b33f5e3b840..b38e283394f 100644 --- a/packages/runtime/src/artifact-collections.test.ts +++ b/packages/runtime/src/artifact-collections.test.ts @@ -82,7 +82,7 @@ describe('resolveArtifactCollections', () => { // The D7 branch: every single-package artifact and every `defineStack()` // config the platform has ever booted takes it, and identity is the only // way to say "this cannot have moved" rather than to hope so. - const single = { manifest: { id: 'a', name: 'A' }, objects: [obj('o')] }; + const single = { manifest: { id: 'com.example.a', name: 'A' }, objects: [obj('o')] }; expect(resolveArtifactCollections(single)).toBe(single); expect(resolveArtifactCollections(null)).toBe(null); expect(resolveArtifactCollections(undefined)).toBe(undefined); @@ -102,7 +102,7 @@ describe('resolveArtifactCollections', () => { // fresh copy, `{ ...artifact }` would fire and every reader downstream // would be handed a different object than the one it was given. const objects = [obj('account')]; - const empty = { manifest: { id: 'a', name: 'A' }, objects, packages: [] as unknown[] }; + const empty = { manifest: { id: 'com.example.a', name: 'A' }, objects, packages: [] as unknown[] }; const resolved = resolveArtifactCollections(empty); expect(resolved).toBe(empty); expect(resolved.objects).toBe(objects); diff --git a/packages/runtime/src/domains/packages-capability-gate.test.ts b/packages/runtime/src/domains/packages-capability-gate.test.ts index 87eecc65652..6f1b7bd1ec1 100644 --- a/packages/runtime/src/domains/packages-capability-gate.test.ts +++ b/packages/runtime/src/domains/packages-capability-gate.test.ts @@ -49,13 +49,13 @@ const system = () => ctx({ isSystem: true }); // ── fake kernel ────────────────────────────────────────────────────────────── function make(overrides: { protocol?: any; metadata?: any; registry?: any } = {}) { const registry = overrides.registry ?? { - getAllPackages: vi.fn().mockReturnValue([{ id: 'pkg-a', status: 'active' }]), - getPackage: vi.fn().mockReturnValue({ id: 'pkg-a', manifest: { id: 'pkg-a', name: 'A' } }), + getAllPackages: vi.fn().mockReturnValue([{ id: 'com.example.pkg-a', status: 'active' }]), + getPackage: vi.fn().mockReturnValue({ id: 'com.example.pkg-a', manifest: { id: 'com.example.pkg-a', name: 'A' } }), installPackage: vi.fn().mockImplementation((m: any) => ({ id: m.id, manifest: m })), - enablePackage: vi.fn().mockReturnValue({ id: 'pkg-a' }), - disablePackage: vi.fn().mockReturnValue({ id: 'pkg-a' }), + enablePackage: vi.fn().mockReturnValue({ id: 'com.example.pkg-a' }), + disablePackage: vi.fn().mockReturnValue({ id: 'com.example.pkg-a' }), uninstallPackage: vi.fn().mockReturnValue(true), - updatePackageManifest: vi.fn().mockReturnValue({ id: 'pkg-a' }), + updatePackageManifest: vi.fn().mockReturnValue({ id: 'com.example.pkg-a' }), }; const objectql = { registry }; const kernel: any = { @@ -80,7 +80,7 @@ function fullProtocol() { rollbackToPackageCommit: vi.fn().mockResolvedValue({ success: true }), reassignOrphanedMetadata: vi.fn().mockResolvedValue({ reassigned: 0 }), duplicatePackage: vi.fn().mockResolvedValue({ package: { id: 'pkg-b' } }), - updatePackage: vi.fn().mockResolvedValue({ package: { manifest: { id: 'pkg-a' } } }), + updatePackage: vi.fn().mockResolvedValue({ package: { manifest: { id: 'com.example.pkg-a' } } }), deletePackage: vi.fn().mockResolvedValue({ deletedCount: 1 }), getMetaItems: vi.fn().mockResolvedValue({ items: [] }), }; diff --git a/packages/runtime/src/domains/packages-uninstall-envelope.test.ts b/packages/runtime/src/domains/packages-uninstall-envelope.test.ts index f27da3c55cc..7a318ef54ff 100644 --- a/packages/runtime/src/domains/packages-uninstall-envelope.test.ts +++ b/packages/runtime/src/domains/packages-uninstall-envelope.test.ts @@ -75,7 +75,7 @@ const authed = (caps: string[] = ['manage_metadata']): any => ({ function make(deletePackageResult: any, opts: { registryRemoved?: boolean } = {}) { const registry = { getAllPackages: vi.fn().mockReturnValue([]), - getPackage: vi.fn().mockReturnValue({ id: 'pkg-a', manifest: { id: 'pkg-a', name: 'A' } }), + getPackage: vi.fn().mockReturnValue({ id: 'com.example.pkg-a', manifest: { id: 'com.example.pkg-a', name: 'A' } }), uninstallPackage: vi.fn().mockReturnValue(opts.registryRemoved ?? true), }; const protocol = { diff --git a/packages/spec/src/kernel/package-registry.test.ts b/packages/spec/src/kernel/package-registry.test.ts index 4e16b91e1e8..70038c6b44d 100644 --- a/packages/spec/src/kernel/package-registry.test.ts +++ b/packages/spec/src/kernel/package-registry.test.ts @@ -127,7 +127,7 @@ describe('InstalledPackageSchema', () => { it('should reject invalid manifest', () => { expect(() => InstalledPackageSchema.parse({ - manifest: { id: 'test' }, + manifest: { id: 'com.example.test' }, })).toThrow(); }); }); diff --git a/packages/spec/src/shared/metadata-collection.test.ts b/packages/spec/src/shared/metadata-collection.test.ts index 9339009c643..65aed09bd06 100644 --- a/packages/spec/src/shared/metadata-collection.test.ts +++ b/packages/spec/src/shared/metadata-collection.test.ts @@ -167,7 +167,7 @@ describe('normalizeMetadataCollection', () => { describe('normalizeStackInput', () => { it('should normalize map-formatted metadata fields to arrays', () => { const input = { - manifest: { id: 'test', name: 'test', version: '1.0.0', type: 'app' }, + manifest: { id: 'com.example.test', name: 'test', version: '1.0.0', type: 'app' }, objects: { task: { fields: { title: { type: 'text' } } }, }, @@ -213,7 +213,7 @@ describe('normalizeStackInput', () => { it('should not modify fields not in MAP_SUPPORTED_FIELDS', () => { const input = { - manifest: { id: 'test' }, + manifest: { id: 'com.example.test' }, i18n: { defaultLocale: 'en' }, plugins: ['@objectstack/plugin-dev'], views: [{ list: { type: 'grid' } }], From e9d4431a2d8f64c2cbc63caa299912dd154a6543 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 01:29:30 +0000 Subject: [PATCH 12/19] chore(spec): regenerate api-surface and export-origins on the merged tree The os-regen merge sequence's collection commit. `api-surface/kernel.json` and `export-origins/kernel.json` were edited on BOTH sides, so the merge driver ran, exited 0 and silently kept one side; step 2 took main's side and this commit carries the regeneration from the merged tree, built first so api-surface reads a dist that matches src rather than reporting phantom removals. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- packages/spec/api-surface/kernel.json | 7 ------- packages/spec/export-origins/kernel.json | 7 ------- 2 files changed, 14 deletions(-) diff --git a/packages/spec/api-surface/kernel.json b/packages/spec/api-surface/kernel.json index 7f561c1d346..316acb19f64 100644 --- a/packages/spec/api-surface/kernel.json +++ b/packages/spec/api-surface/kernel.json @@ -124,8 +124,6 @@ "GetPackageResponse (type)", "GetPackageResponseParsed (type)", "GetPackageResponseSchema (const)", - "HealthStatus (type)", - "HealthStatusSchema (const)", "HotReloadConfig (type)", "HotReloadConfigParsed (type)", "HotReloadConfigSchema (const)", @@ -415,11 +413,6 @@ "ServiceRegistryConfigSchema (const)", "ServiceScopeType (const)", "ServiceScopeType (type)", - "StartupOptions (type)", - "StartupOptionsParsed (type)", - "StartupOptionsSchema (const)", - "StartupOrchestrationResult (type)", - "StartupOrchestrationResultSchema (const)", "TenantRuntimeContext (type)", "TenantRuntimeContextParsed (type)", "TenantRuntimeContextSchema (const)", diff --git a/packages/spec/export-origins/kernel.json b/packages/spec/export-origins/kernel.json index 9dad040f768..e2caf683d29 100644 --- a/packages/spec/export-origins/kernel.json +++ b/packages/spec/export-origins/kernel.json @@ -123,8 +123,6 @@ "GetPackageResponse": "src/kernel/package-registry.zod.ts#GetPackageResponse (type)", "GetPackageResponseParsed": "src/kernel/package-registry.zod.ts#GetPackageResponseParsed (type)", "GetPackageResponseSchema": "src/kernel/package-registry.zod.ts#GetPackageResponseSchema (const)", - "HealthStatus": "src/kernel/startup-orchestrator.zod.ts#HealthStatus (type)", - "HealthStatusSchema": "src/kernel/startup-orchestrator.zod.ts#HealthStatusSchema (const)", "HotReloadConfig": "src/kernel/plugin-lifecycle-advanced.zod.ts#HotReloadConfig (type)", "HotReloadConfigParsed": "src/kernel/plugin-lifecycle-advanced.zod.ts#HotReloadConfigParsed (type)", "HotReloadConfigSchema": "src/kernel/plugin-lifecycle-advanced.zod.ts#HotReloadConfigSchema (const)", @@ -412,11 +410,6 @@ "ServiceRegistryConfigParsed": "src/kernel/service-registry.zod.ts#ServiceRegistryConfigParsed (type)", "ServiceRegistryConfigSchema": "src/kernel/service-registry.zod.ts#ServiceRegistryConfigSchema (const)", "ServiceScopeType": "src/kernel/service-registry.zod.ts#ServiceScopeType (type)", - "StartupOptions": "src/kernel/startup-orchestrator.zod.ts#StartupOptions (type)", - "StartupOptionsParsed": "src/kernel/startup-orchestrator.zod.ts#StartupOptionsParsed (type)", - "StartupOptionsSchema": "src/kernel/startup-orchestrator.zod.ts#StartupOptionsSchema (const)", - "StartupOrchestrationResult": "src/kernel/startup-orchestrator.zod.ts#StartupOrchestrationResult (type)", - "StartupOrchestrationResultSchema": "src/kernel/startup-orchestrator.zod.ts#StartupOrchestrationResultSchema (const)", "TenantRuntimeContext": "src/kernel/context.zod.ts#TenantRuntimeContext (type)", "TenantRuntimeContextParsed": "src/kernel/context.zod.ts#TenantRuntimeContextParsed (type)", "TenantRuntimeContextSchema": "src/kernel/context.zod.ts#TenantRuntimeContextSchema (const)", From 4f277510f35680bf85738999f891e03cbf6a5e8a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 08:47:41 +0000 Subject: [PATCH 13/19] test(runtime): the `''` manifest-id pins move to DOOR 1, and the changeset names the fail-OPEN reversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ManifestSchema.id` now carries `MANIFEST_ID_PATTERN`, and `AssembledPackageBodySchema` extends `ManifestSchema`, so the artifact package entry schema carries it too. Three pins in the `#13457` door block described a world where `''` survived that schema: - `''` was refused by `artifactPackageId` one door later (DOOR 2); - `{ id: '', name: 'x' }` was refused by NEITHER door and carried as `x` through the `id || name` fallback; - a consent record keyed by `''` bound to nothing and the package still loaded with no consent record — fail-OPEN. All three are refused at DOOR 1 now. The pins are rewritten to the measured behaviour rather than loosened: each still names ITS OWN door by message and asserts the absence of the other door's, so none of them would survive deleting a whole door — the property the block's header exists to protect. The last of the three reverses direction, so the changeset says so in those words: fail-OPEN to fail-CLOSED on a consent/permission path, and the affected population is artifacts carrying `manifest.id: ''`, which the registry face has always refused at publish and whose granted-permissions consent already silently did not apply. The refusal is the artifact PACKAGE door refusing a malformed id, ⛔ not the permission seam acquiring teeth: #17147's repo-wide "registered, not enforced" pin is untouched and still green. #17148 (whether an unbindable consent record should refuse the artifact) is NOT settled by this and is noted as still open in the case that used to imply it. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../17534-manifest-id-reverse-domain.md | 39 +++++ .../artifact-granted-permissions.test.ts | 136 +++++++++++++----- 2 files changed, 136 insertions(+), 39 deletions(-) diff --git a/.changeset/17534-manifest-id-reverse-domain.md b/.changeset/17534-manifest-id-reverse-domain.md index af77d7bf96a..836eb956b5d 100644 --- a/.changeset/17534-manifest-id-reverse-domain.md +++ b/.changeset/17534-manifest-id-reverse-domain.md @@ -53,3 +53,42 @@ conforming one: the bundled `create-objectstack` template ships `com.example.blank` and interpolates `com.example.` in kebab form, and `os init` derives its id from the project name instead of interpolating the snake_case namespace (`os init my-app` produced `com.example.my_app`). + +## ⚠️ One consent path reverses direction: fail-OPEN → fail-CLOSED + +Narrowing `manifest.id` also narrows the **accept set of the artifact load +path**, and on one route that is a **fail-OPEN → fail-CLOSED reversal on a +consent/permission path**. Stating it explicitly because a reversal in that +direction is owed a named direction and a named population, however small the +population turns out to be. + +**What changed.** `AssembledPackageBodySchema` extends `ManifestSchema`, so the +artifact package entry schema now carries this rule too. An assembled package +whose `manifest.id` is `''` used to parse: `artifactPackageId` is +`manifest.id || manifest.name`, so such a package was carried under its `name`, +while an install-time `grantedPermissions` record keyed by `''` matched no +carried package and was registered nowhere. The package loaded **with no +consent record at all** — reported as unbound, warned about, and otherwise +allowed to run. That is the fail-OPEN half. Such an entry is now refused +outright (`INVALID_ARTIFACT_PACKAGE_ENTRY`, 422) and the artifact does not +materialize at all — fail-CLOSED. + +**Who is affected: artifacts carrying `manifest.id: ''`, and they were already +half-broken in both directions.** + +- They could never be **published**: the registry face + (`PackageSchema.manifestId`) has carried this exact pattern all along — the + same regex literal, now the shared `MANIFEST_ID_PATTERN` — so the publish path + has always refused them. +- Their granted-permissions **consent already did not apply**: a record keyed by + `''` bound to nothing, silently, on every load. + +⇒ For that population this converts a silent, already-ineffective consent +binding into an explicit refusal that names `manifest.id`. Nobody who could +publish an artifact loses the ability to load it; what they lose is a shape that +only ever half-worked. + +⛔ This is the **artifact package door** refusing a malformed id, **not** the +permission enforcer acquiring teeth. The install-time granted permission set is +still registered and not enforced (#17147) — nothing on the tree queries that +registry, and the repo-wide pin asserting so is unchanged and still green. diff --git a/packages/runtime/src/security/artifact-granted-permissions.test.ts b/packages/runtime/src/security/artifact-granted-permissions.test.ts index 6fe9a424f79..e2346356ad8 100644 --- a/packages/runtime/src/security/artifact-granted-permissions.test.ts +++ b/packages/runtime/src/security/artifact-granted-permissions.test.ts @@ -185,19 +185,31 @@ describe('#13457 — a consent record that binds to nothing is said out loud', ( // emits it under NO name, so to a consumer that case is indistinguishable from // "no consent record". // -// ⚠️ An earlier revision of this block claimed that case "cannot reach this -// seam", closed by two doors. MEASURED FALSE (#13457 contract review ⑤): the -// doors refuse two spellings and the THIRD — `{ id: '', name: 'x' }` — walks -// through both, because `artifactPackageId` is `id || name`. The fixture hid it -// by setting id and name to `''` together. Corrected here, and the case that -// escapes is pinned rather than described. +// ⚠️ HISTORY, kept because both corrections are load-bearing. An earlier +// revision of this block claimed that case "cannot reach this seam", closed by +// two doors. MEASURED FALSE (#13457 contract review ⑤): the doors refused two +// spellings and the THIRD — `{ id: '', name: 'x' }` — walked through both, +// because `artifactPackageId` is `id || name`. The fixture had hidden it by +// setting id and name to `''` together, and the escape was pinned here rather +// than described. // -// ⛔ Each door test pins ITS OWN door. The earlier spelling asserted +// ⭐ #17534 CLOSED that escape, and this block now pins the closure. +// `ManifestSchema.id` carries `MANIFEST_ID_PATTERN` +// (`packages/spec/src/kernel/manifest.zod.ts`) — the reverse-domain rule the +// registry face has always enforced — so `''` is no longer a valid manifest id +// to the SCHEMA. Every `''` spelling, the bare one and the +// `{ id: '', name: 'x' }` one that used to walk through, is now refused at +// DOOR 1, before `artifactPackageId` is ever consulted. ⇒ the `id || name` +// fallback is unreachable for `''`, and the fail-OPEN residual the last case in +// this block used to pin is GONE — the artifact is refused outright instead. +// +// ⛔ Each door test still pins ITS OWN door. The pre-#13457 spelling asserted // `/no usable package id|not a package entry/` on BOTH, so either test passed on // either door: it pinned "refused by some door", never which — an alternation // that would survive deleting a whole door. Both doors raise the SAME ADR-0112 -// code and status, so only the message separates them. -describe('#13457 — which unattributable-consent spellings the doors refuse, and the one they do not', () => { +// code and status, so only the message separates them, and every case below +// asserts its own door's message AND the absence of the other's. +describe('#13457 / #17534 — which unattributable-consent spellings the doors refuse, and at which door', () => { /** The refusal both doors share, so the message assertions carry the rest. */ const envelope = (err: any) => { expect(err).toBeDefined(); @@ -210,8 +222,10 @@ describe('#13457 — which unattributable-consent spellings the doors refuse, an }; it('DOOR 1 (schema) — no top-level `id` is refused by `ArtifactPackageSchema`, which names `manifest.id`', () => { - // `ManifestSchema.id` is a required `z.string()`, so the entry never - // reaches the id door at all. + // `ManifestSchema.id` is REQUIRED, so a missing id is a schema issue + // and the entry never reaches the id door at all. This case turns on + // requiredness alone — it predates `MANIFEST_ID_PATTERN` and is + // unaffected by it, which is why it was already green. const err = thrownBy(() => carriedPackageIds({ packages: [{ manifest: { name: '', version: '1.0.0' } }] })); envelope(err); expect(err.message).toContain('is not a package entry'); @@ -220,33 +234,70 @@ describe('#13457 — which unattributable-consent spellings the doors refuse, an expect(err.message).not.toContain('no usable package id'); }); - it('DOOR 2 (id) — `\'\'` passes the schema and is refused by `artifactPackageId`, one door later', () => { - // `z.string()` has no `.min(1)`, so `''` is a VALID manifest id to the - // schema; it is `artifactPackageId` that yields `undefined` for it. + it('DOOR 1 (schema) — `\'\'` is refused by `MANIFEST_ID_PATTERN`, so the id door one later never runs', () => { + // ⭐ #17534 moved this case one door EARLIER, which is why it is a + // DOOR 1 pin now and was a DOOR 2 pin before. `ManifestSchema.id` used + // to be a bare `z.string()`, so `''` was a VALID manifest id to the + // schema and it was `artifactPackageId` that yielded `undefined` for it, + // one door later. The id now carries the reverse-domain pattern, so the + // entry never survives the schema at all. const err = thrownBy(() => carriedPackageIds({ packages: [{ manifest: body('') }] })); envelope(err); - expect(err.message).toContain('no usable package id'); - // ⛔ THIS door, not the other one: the schema admitted the entry. - expect(err.message).not.toContain('is not a package entry'); + expect(err.message).toContain('is not a package entry'); + expect(err.message).toContain('manifest.id'); + // The refusal ECHOES the value it refused (#4001), so an author who + // wrote an empty id is told which key was empty rather than only which + // rule was broken. + expect(err.message).toContain("Invalid package id ''"); + // ⛔ THIS door, not the other one: the id door never ran. + expect(err.message).not.toContain('no usable package id'); }); - // ⭐ The correction: the spelling NEITHER door refuses. - it('NEITHER door refuses `{ id: \'\', name: \'x\' }` — `artifactPackageId` is `id || name`, so it is carried as `x`', () => { - expect(carriedPackageIds({ packages: [{ manifest: body('', { id: '', name: 'x' }) }] })) - .toEqual(['x']); + // ⭐ The escape #13457 corrected this block to pin, now CLOSED by #17534. + // `artifactPackageId` is still `id || name` — ⛔ untouched by that change — + // but DOOR 1 refuses the entry before the fallback is ever consulted, so an + // empty id can no longer be carried under a sibling `name`. + it('`{ id: \'\', name: \'x\' }` is refused at DOOR 1 too — the `id || name` fallback never runs for `\'\'`', () => { + const err = thrownBy( + () => carriedPackageIds({ packages: [{ manifest: body('', { id: '', name: 'x' }) }] }), + ); + // That it THREW is the assertion that it was not carried: before #17534 + // this exact call returned `['x']` and never reached here. + envelope(err); + expect(err.message).toContain('is not a package entry'); + expect(err.message).toContain('manifest.id'); + expect(err.message).toContain("Invalid package id ''"); + // ⛔ THIS door, not the other one: the sibling `name` was never + // consulted, so the id door had nothing to refuse. + expect(err.message).not.toContain('no usable package id'); }); - it('so a consent record keyed by the unattributable `\'\'` binds to NOTHING — loudly, and fail-OPEN', () => { - // ⛔ What this pins is that the residual is fail-OPEN, not that it is - // handled: the `''` key names no carried package, so it is reported as - // `unbound` and registered nowhere. Nothing is silently DENIED — the - // package still loads with no consent record at all, exactly as an - // artifact that never declared one does. Whether an unbindable consent - // record should instead REFUSE the artifact is an open decision - // (#17148), and this test is what will go red when it is taken. + it('so an artifact whose consent record is keyed by `\'\'` never materializes — nothing is registered, and the residual is fail-CLOSED', () => { + // ⭐ #17534 REVERSED the direction this case pins, and that reversal is + // why the changeset names it. What it used to pin was a fail-OPEN + // residual: the `''` key named no carried package, so it was reported + // `unbound`, the package still loaded with no consent record at all, and + // nothing was denied. DOOR 1 now refuses the entry, so the whole + // artifact is refused at materialize time and NO package loads — + // fail-CLOSED. + // + // ⛔ Read the refusal's provenance precisely: it is the artifact PACKAGE + // door (`resolveArtifactPackageOrder`) refusing a malformed manifest id, + // NOT the permission seam acquiring teeth. Nothing on this tree queries + // the registry these entries land in — see this file's header, and + // `granted-permissions-not-enforced.pin.test.ts` in `@objectstack/core` + // for the repo-wide measurement, which is still green. + // + // ⛔ #17148 is NOT what took this red, and is NOT settled by it. Whether + // an UNBINDABLE consent record should refuse the artifact is still open, + // and still open for every key that is unbindable while being a LEGAL + // id — `{ 'com.acme.ghost': … }`, pinned earlier in this file, still + // binds to nothing and still only warns. What closed here is narrower: + // `''` stopped being a legal id at all, so this one spelling can no + // longer reach the unbindable state. const e = enforcer(); const log = logger(); - const binding = registerArtifactGrantedPermissions( + const err = thrownBy(() => registerArtifactGrantedPermissions( { manifest: { id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', type: 'app' }, packages: [{ manifest: body('', { id: '', name: 'x' }) }], @@ -254,17 +305,24 @@ describe('#13457 — which unattributable-consent spellings the doors refuse, an }, e, { logger: log as never }, - ); + )); - expect(binding.carried).toEqual(['x']); - expect(binding.registered).toEqual([]); - expect(binding.unbound).toEqual(['']); - // Through the enforcer's OWN readback: neither the unattributable key - // nor the package it failed to name is registered, so neither is denied. + envelope(err); + expect(err.message).toContain('manifest.id'); + // ⛔ THIS door, not the other one. + expect(err.message).not.toContain('no usable package id'); + + // The "loudly" half of the old title is gone with the residual it + // described: the refusal itself is the loud part now, and the + // bound-to-NO-package warning is never reached. + expect(log.warn).not.toHaveBeenCalled(); + + // Through the enforcer's OWN readback — this file's standing discipline: + // the binding record is what this module says it did, the enforcer is + // what actually happened. Neither the unattributable key nor the package + // it failed to name is registered, and unlike the fail-OPEN residual this + // replaces, the package it would have named did not load either. expect(e.getPluginPermissions('')).toBeUndefined(); expect(e.getPluginPermissions('x')).toBeUndefined(); - expect( - log.warn.mock.calls.some((c: unknown[]) => String(c[0]).includes('bound to NO package')), - ).toBe(true); }); }); From 7030cf6f9a47c0a71a8a1b4d504f8897436dcdba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 15:07:47 +0000 Subject: [PATCH 14/19] chore(spec): regenerate api-surface, export-origins and reference docs on the merged tree The merge of origin/main routed six generator-owned artifacts through the os-regen driver, which exits 0 while keeping one side. Regenerated from the merged sources on a committed base, so main's additions and this branch's `MANIFEST_ID_PATTERN` / `MANIFEST_ID_EXAMPLES` entries are both present. Claude-Session: https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE Co-authored-by: Claude --- content/docs/references/api/package-api.mdx | 272 +++++++++++++++++- content/docs/references/api/protocol.mdx | 67 ++++- content/docs/references/kernel/manifest.mdx | 2 +- .../references/kernel/package-registry.mdx | 2 +- packages/spec/api-surface/kernel.json | 1 + packages/spec/export-origins/kernel.json | 1 + 6 files changed, 316 insertions(+), 29 deletions(-) diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index b2fa4b9c92d..c01a3198c92 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -13,7 +13,7 @@ Base path: /api/v1/packages **Endpoints** ``` -POST /api/v1/packages/install — Install a package +POST /api/v1/packages — Install a package POST /api/v1/packages/upgrade — Upgrade a package POST /api/v1/packages/resolve-dependencies — Resolve dependencies POST /api/v1/packages/upload — Upload an artifact @@ -30,8 +30,8 @@ DELETE /api/v1/packages/:packageId — Uninstall a package ## TypeScript Usage ```typescript -import { AssembledInstalledPackageSchema, GetInstalledPackageRequestSchema, GetInstalledPackageResponseSchema, InstalledPackageAtEitherStageSchema, ListInstalledPackagesRequestSchema, ListInstalledPackagesResponseSchema, PackageApiErrorCode, PackageInstallRequestSchema, PackageInstallResponseSchema, PackagePathParamsSchema, PackageRollbackRequestSchema, PackageUpgradeRequestSchema, PackageUpgradeResponseSchema, ResolveDependenciesRequestSchema, ResolveDependenciesResponseSchema, UninstallPackageApiRequestSchema, UninstallPackageApiResponseSchema, UploadArtifactRequestSchema, UploadArtifactResponseSchema } from '@objectstack/spec/api'; -import type { AssembledInstalledPackage, GetInstalledPackageRequest, GetInstalledPackageResponse, InstalledPackageAtEitherStage, ListInstalledPackagesRequest, ListInstalledPackagesResponse, PackageApiErrorCode, PackageInstallRequest, PackageInstallResponse, PackagePathParams, PackageRollbackRequest, PackageUpgradeRequest, PackageUpgradeResponse, ResolveDependenciesRequest, ResolveDependenciesResponse, UninstallPackageApiRequest, UninstallPackageApiResponse, UploadArtifactRequest, UploadArtifactResponse } from '@objectstack/spec/api'; +import { AssembledInstalledPackageSchema, GetInstalledPackageRequestSchema, GetInstalledPackageResponseSchema, InstalledPackageAtEitherStageSchema, ListInstalledPackagesRequestSchema, ListInstalledPackagesResponseSchema, PackageApiErrorCode, PackageInstallBodySchema, PackageInstallRequestSchema, PackageInstallResponseSchema, PackagePathParamsSchema, PackageRollbackRequestSchema, PackageUpgradeRequestSchema, PackageUpgradeResponseSchema, ResolveDependenciesRequestSchema, ResolveDependenciesResponseSchema, UninstallPackageApiRequestSchema, UninstallPackageApiResponseSchema, UploadArtifactRequestSchema, UploadArtifactResponseSchema } from '@objectstack/spec/api'; +import type { AssembledInstalledPackage, GetInstalledPackageRequest, GetInstalledPackageResponse, InstalledPackageAtEitherStage, ListInstalledPackagesRequest, ListInstalledPackagesResponse, PackageApiErrorCode, PackageInstallBody, PackageInstallRequest, PackageInstallResponse, PackagePathParams, PackageRollbackRequest, PackageUpgradeRequest, PackageUpgradeResponse, ResolveDependenciesRequest, ResolveDependenciesResponse, UninstallPackageApiRequest, UninstallPackageApiResponse, UploadArtifactRequest, UploadArtifactResponse } from '@objectstack/spec/api'; // Validate data const result = AssembledInstalledPackageSchema.parse(data); @@ -135,11 +135,14 @@ Installed package row whose manifest is the assembled package body ## GetInstalledPackageRequest +Get installed package request + ### Properties | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **packageId** | `string` | ✅ | Package identifier | +| **version** | `string` | optional | Scope the read to this exact installed version; `latest` or omitted reads the installed row | --- @@ -154,7 +157,7 @@ Get installed package response | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | +| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … } \| … +1 more` | ✅ | Installed package details | ### Nested Shape: `GetInstalledPackageResponse.error` @@ -171,6 +174,15 @@ Get installed package response | **details** | `any` | optional | Additional error context (e.g. field validation errors) | | **requestId** | `string` | optional | Request ID for tracking | +### Nested Shape: `GetInstalledPackageResponse.meta` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **timestamp** | `string` | ✅ | | +| **duration** | `integer` | optional | Server-side processing duration in milliseconds | +| **requestId** | `string` | optional | | +| **traceId** | `string` | optional | | + ### Nested Shape: `GetInstalledPackageResponse.data[option 1]` Installed package with runtime lifecycle state @@ -389,8 +401,9 @@ List installed packages request | :--- | :--- | :--- | :--- | | **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional | Filter by package status | | **enabled** | `boolean` | optional | Filter by enabled state | -| **limit** | `integer` | optional (default: `50`) | Maximum number of packages to return | -| **cursor** | `string` | optional | Cursor for pagination | +| **type** | `string` | optional | Filter by the installed manifest's `type` — exact match, unmatched values select nothing | +| **limit** | `never` | optional | [REMOVED] `limit` / `cursor` were removed from GET /api/v1/packages in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — both were declared here and read by nothing: the serving door filters on `status` / `type` and then returns every remaining row, so no page was ever withheld and no continuation token was ever minted. `limit` also declared `.default(50)`, so a reader of the published schema was entitled to believe an unparameterised list is capped at 50 rows; it has never been capped at all, and nothing parses a query string through this schema, so that default has never been stamped onto anything. Delete the key. This route is NOT paginated — it answers the whole installed set, which is a bounded table of tens of rows, and `hasMore` on the response is a constant `false` that is now true by construction. Filter with `status` and `type` instead of asking for a window. A first-class package cursor, if one is ever designed, will be a response-minted opaque token, not this key. | +| **cursor** | `never` | optional | [REMOVED] `limit` / `cursor` were removed from GET /api/v1/packages in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — both were declared here and read by nothing: the serving door filters on `status` / `type` and then returns every remaining row, so no page was ever withheld and no continuation token was ever minted. `limit` also declared `.default(50)`, so a reader of the published schema was entitled to believe an unparameterised list is capped at 50 rows; it has never been capped at all, and nothing parses a query string through this schema, so that default has never been stamped onto anything. Delete the key. This route is NOT paginated — it answers the whole installed set, which is a bounded table of tens of rows, and `hasMore` on the response is a constant `false` that is now true by construction. Filter with `status` and `type` instead of asking for a window. A first-class package cursor, if one is ever designed, will be a response-minted opaque token, not this key. | --- @@ -405,7 +418,7 @@ List installed packages response | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | +| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ packages: (object \| object)[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | | ### Nested Shape: `ListInstalledPackagesResponse.error` @@ -422,6 +435,15 @@ List installed packages response | **details** | `any` | optional | Additional error context (e.g. field validation errors) | | **requestId** | `string` | optional | Request ID for tracking | +### Nested Shape: `ListInstalledPackagesResponse.meta` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **timestamp** | `string` | ✅ | | +| **duration** | `integer` | optional | Server-side processing duration in milliseconds | +| **requestId** | `string` | optional | | +| **traceId** | `string` | optional | | + ### Nested Shape: `ListInstalledPackagesResponse.data` | Property | Type | Required | Description | @@ -429,7 +451,7 @@ List installed packages response | **packages** | `({ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … } \| … +1 more)[]` | ✅ | Installed packages | | **total** | `integer` | optional | Total matching packages | | **nextCursor** | `string` | optional | Cursor for the next page | -| **hasMore** | `boolean` | ✅ | Whether more packages are available | +| **hasMore** | `boolean` | ✅ | Whether more packages are available — this door serves one page, so always `false` | --- @@ -453,6 +475,177 @@ List installed packages response * `upload_failed` +--- + +## PackageInstallBody + +Install package request body, wrapped or as a bare manifest + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +Install package request + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install (AUTHORING stage: `objects` are glob patterns) | +| **settings** | `Record` | optional | User-provided settings at install time | +| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install — honoured at POST /api/v1/packages: the installed row's `enabled` is written from this key | +| **overwrite** | `boolean` | optional | Overwrite an already-installed package id instead of answering 409 Conflict | +| **platformVersion** | `string` | optional | Current platform version for compatibility verification | +| **artifactRef** | `{ url: string; sha256: string; size: integer; format?: Enum<'tgz' \| 'zip'>; … }` | optional | Artifact reference for marketplace installation | + +### Nested Shape: `PackageInstallBody[option 1].manifest` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | +| **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | +| **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | +| **version** | `string` | ✅ | Package version (semantic versioning) | +| **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| …>` | ✅ | Type of package | +| **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **name** | `string` | ✅ | Human-readable package name | +| **description** | `string` | optional | Package description | +| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | +| **objects** | `string[]` | optional | Glob patterns for ObjectQL schemas files | +| **datasources** | `string[]` | optional | Glob patterns for Datasource definitions | +| **dependencies** | `Record` | optional | Package dependencies | +| **configuration** | `never` | optional | [REMOVED] `manifest.configuration` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: no settings UI rendered it and no loader resolved a setting from it, so authoring it configured nothing. Worse, `properties.*.secret` promised "value is encrypted/masked (e.g. API Keys)" while nothing encrypted, masked or even parsed the flag — a false assurance about credential handling. Delete the key. A plugin is configured by the host that composes it: pass options to its constructor in `defineStack({ plugins: [new MyPlugin({ … })] })`, which is the enforced channel. A declarative settings surface must be designed with an enforcing reader first, not revived here. | +| **contributes** | `{ kinds?: object[] }` | optional | Platform contributions | +| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Initial seed data (prefer top-level data field) | +| **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | +| **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | +| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +8 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | +| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | +| **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | +| **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | +| **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | + +### Nested Shape: `PackageInstallBody[option 1].artifactRef` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **url** | `string` | ✅ | Artifact download URL | +| **sha256** | `string` | ✅ | SHA256 checksum | +| **size** | `integer` | ✅ | Artifact size in bytes | +| **format** | `Enum<'tgz' \| 'zip'>` | optional (default: `"tgz"`) | Artifact format | +| **uploadedAt** | `string` | ✅ | Upload timestamp | + +--- + +#### Option 2 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | Unique package identifier — must match reverse-domain notation (e.g. com.acme.crm) | +| **namespace** | `string` | optional | Short namespace identifier; also the mandatory prefix of every object name (e.g. "todo" → object names "todo_task", "todo_project") | +| **defaultDatasource** | `string` | optional (default: `"default"`) | Default datasource for all objects in this package | +| **version** | `string` | ✅ | Package version (semantic versioning) | +| **type** | `Enum<'plugin' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql' \| 'module' \| 'gateway' \| 'adapter'>` | ✅ | Type of package | +| **scope** | `Enum<'cloud' \| 'system' \| 'project'>` | optional (default: `"project"`) | Deployment scope: cloud \| system \| project | +| **name** | `string` | ✅ | Human-readable package name | +| **description** | `string` | optional | Package description | +| **permissions** | `string[] \| { services?: string[]; hooks?: string[]; network?: string[]; fs?: string[] }` | optional | Required permissions at the AUTHORING stage: legacy string[] or structured plugin block (ADR-0025 §3.2) — at the assembled stage the same key is the ADR-0090 `PermissionSet[]` collection instead (`AssembledPackageBodySchema`) | +| **objects** | `string[]` | optional | Glob patterns for ObjectQL schemas files | +| **datasources** | `string[]` | optional | Glob patterns for Datasource definitions | +| **dependencies** | `Record` | optional | Package dependencies | +| **configuration** | `never` | optional | [REMOVED] `manifest.configuration` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the block: no settings UI rendered it and no loader resolved a setting from it, so authoring it configured nothing. Worse, `properties.*.secret` promised "value is encrypted/masked (e.g. API Keys)" while nothing encrypted, masked or even parsed the flag — a false assurance about credential handling. Delete the key. A plugin is configured by the host that composes it: pass options to its constructor in `defineStack({ plugins: [new MyPlugin({ … })] })`, which is the enforced channel. A declarative settings surface must be designed with an enforcing reader first, not revived here. | +| **contributes** | `{ kinds?: object[] }` | optional | Platform contributions | +| **data** | `{ object: string; externalId?: string \| string[]; mode?: Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>; env?: Enum<'prod' \| 'dev' \| 'test'>[]; … }[]` | optional | Initial seed data (prefer top-level data field) | +| **capabilities** | `never` | optional | [REMOVED] `manifest.capabilities` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no discovery path ever consulted the block: nothing read `implements`, `provides`, `requires`, `extensionPoints` or `extensions`, so the declared "interoperability and automatic discovery" never happened. Delete the key. Real dependency resolution runs off top-level `manifest.dependencies`, which stays. Capability-based discovery must be designed with an enforcing reader first, not revived here. | +| **extensions** | `never` | optional | [REMOVED] `manifest.extensions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — an untyped map with zero readers: whatever was parked here was stored and never consulted. Delete the key. Extend the platform through the enforced channels instead: `contributes.kinds` registers metadata kinds, `navigationContributions` injects navigation into other packages' apps, and code-level extension happens in the plugin itself (`init`/`start`). | +| **navigationContributions** | `{ app: string; group?: string; priority?: integer; items: (object \| … +8 more)[] }[]` | optional | Navigation items this package contributes into apps owned by other packages | +| **loading** | `never` | optional | [REMOVED] `manifest.loading` was removed in @objectstack/spec 17.0.0 (ADR-0049 enforce-or-remove) — the entire block (`strategy`, `preload`, `codeSplitting`, `dynamicImport`, `initialization`, `dependencyResolution`, `hotReload`, `caching`, `sandboxing`, `monitoring`) had no runtime reader in any repo, so authoring it configured nothing. Delete the key. Plugins are composed at boot — `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder`); the set is fixed until the process restarts. ⚠️ `loading.sandboxing` in particular never isolated anything: it did not run plugins in a process, vm, iframe or web-worker, and `allowedServices` gated no call. If you were relying on it for isolation, you had none — and the plugin trust tier (`manifest.runtime`) does not give it back: that tier is enforced at the cloud marketplace PUBLISH gate only (an unverified publisher requesting the `node` tier is rejected with HTTP 422 and forced to manual review), while load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares. ⛔ Nor do the permission declarations give it back: the install-time granted set is REGISTERED on the PluginPermissionEnforcer at load and queried by nothing, so it refuses no operation. Neither surface confines a plugin today — do not author either one expecting isolation. | +| **engine** | `{ objectstack: string }` | optional | Platform compatibility requirements (legacy; superseded by `engines`) | +| **engines** | `{ platform?: string; protocol?: string }` | optional | Plugin compatibility ranges (ADR-0025 §3.2; supersedes `engine`) | +| **runtime** | `Enum<'node' \| 'sandbox' \| 'worker'>` | optional | Plugin trust tier the plugin declares (ADR-0025 §3.6) — enforced at the cloud marketplace publish gate (unverified publisher requesting `node` → HTTP 422 + manual review); load-side enforcement is NOT implemented, so a locally installed plugin is not isolated by the tier it declares | +| **packaging** | `Enum<'bundled' \| 'manifest-deps'>` | optional | Dependency packaging strategy (ADR-0025 §3.3) | +| **main** | `string` | optional | Entry module of a code-bearing plugin, relative to the plugin root; `os plugin build` bundles it and writes `dist/index.mjs` here in the compiled manifest (ADR-0025 §3.4) | +| **integrity** | `Record` | optional | Per-file content digests of the plugin artifact (ADR-0025 §3.2) | + +### Nested Shape: `PackageInstallBody[option 2].permissions` + +Structured plugin permission grants (ADR-0025 §3.2) + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **services** | `string[]` | optional | Platform services the plugin may resolve (e.g. "object", "http") | +| **hooks** | `string[]` | optional | Lifecycle hooks the plugin may register (e.g. "record.beforeInsert") | +| **network** | `string[]` | optional | Network hosts the plugin may reach (e.g. "api.acme.com") | +| **fs** | `string[]` | optional | Filesystem paths the plugin may access | + +### Nested Shape: `PackageInstallBody[option 2].contributes` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **kinds** | `{ id: string; description?: string }[]` | optional | Metadata kind identifiers this package registers | +| **events** | `never` | optional | [REMOVED] `manifest.contributes.events` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read the list: its only in-repo author already subscribed imperatively in plugin code, so the declaration was decorative. Delete the key. Subscribe to system events in the plugin itself — `ctx.hook('kernel:ready', …)` (or the events service) from `init`/`start` is the enforced channel; record lifecycle hooks register on the data engine. | +| **menus** | `never` | optional | [REMOVED] `manifest.contributes.menus` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no renderer ever read it; two alias maps already redirected this spelling to `navigation`. Delete the key. Declare navigation in the app's `navigation` tree, or inject items into another package's app via `manifest.navigationContributions` (ADR-0029 D7), which the engine registers. | +| **themes** | `never` | optional | [REMOVED] `manifest.contributes.themes` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it never had an effect: theme registration reaches the registry only through the stack-level `themes` collection (a `ThemeSchema` surface, unrelated to this `{ id, label, path }` shape), never through `contributes.themes`. Delete the key; declare themes in the stack `themes` collection instead. | +| **translations** | `never` | optional | [REMOVED] `manifest.contributes.translations` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — no loader ever read these `{ locale, path }` entries; authoring them registered no translations. Delete the key. Declare translations as `translation` metadata: `defineTranslationBundle({ … })` in the stack's `translations` collection (`defineStack({ translations: […] })`), which the engine registers and the i18n pipeline serves. | +| **actions** | `never` | optional | [REMOVED] `manifest.contributes.actions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it; actions declared here were never invocable. Delete the key. Declare actions in the stack `actions` collection (registered by the engine) or register imperatively via `engine.registerAction`. | +| **drivers** | `never` | optional | [REMOVED] `manifest.contributes.drivers` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it never had an effect: a storage driver is wired by registering a kernel SERVICE named `driver.*` (the objectql plugin picks it up and calls `registerDriver`), and its only in-repo author was registered that way, not by this declaration. Delete the key. | +| **fieldTypes** | `never` | optional | [REMOVED] `manifest.contributes.fieldTypes` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — there is no `registerFieldType` seam anywhere: the declaration advertised an extension point the platform does not have, so authoring it configured nothing. Delete the key. The field-type vocabulary is the spec `FieldType` enum; extending it is a spec change, not a manifest declaration. | +| **functions** | `never` | optional | [REMOVED] `manifest.contributes.functions` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it; ObjectQL functions declared here were never registered. Delete the key. Declare functions on the stack (`defineStack({ functions: […] })`), which the hook binder registers via `engine.registerFunction`. | +| **routes** | `never` | optional | [REMOVED] `manifest.contributes.routes` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing ever read it: the HttpDispatcher never registered a prefix from the declaration, so an entry here parsed cleanly and served nothing while published material kept recommending it. Delete the key. A route that needs real handler CODE is mounted imperatively: resolve the `http.server` service from the plugin context and register the handler on `kernel:ready`. A declarative endpoint over a pipeline the platform already runs (query/return records, trigger a flow) is `defineStack({ apis })`. | +| **commands** | `never` | optional | [REMOVED] `manifest.contributes.commands` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — the CLI never resolved commands from this declaration: commands are auto-discovered through oclif's native plugin system (the plugin package declares an `oclif` section in its own `package.json`; see `cli-extension.zod.ts`), and the `objectstack.config.ts` plugins array no longer determines CLI commands. Delete the key. | + +### Nested Shape: `PackageInstallBody[option 2].data[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **object** | `string` | ✅ | Target Object Name | +| **externalId** | `string \| string[]` | optional (default: `"name"`) | Field (or composite list of fields) matched for the uniqueness check | +| **mode** | `Enum<'insert' \| 'update' \| 'upsert' \| 'replace' \| 'ignore'>` | optional (default: `"upsert"`) | Conflict resolution strategy | +| **env** | `Enum<'prod' \| 'dev' \| 'test'>[]` | optional (default: `["prod","dev","test"]`) | Applicable environments | +| **locale** | `string[]` | optional | Applicable locales (BCP-47 tags); omitted applies to every locale | +| **records** | `Record[]` | ✅ | Data records | +| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | +| **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | +| **_lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Layer that set _lock (artifact \| package \| env-forced). | +| **_provenance** | `Enum<'package' \| 'org' \| 'env-forced'>` | optional | Origin of the item (package \| org \| env-forced). | +| **_packageId** | `string` | optional | Owning package machine id. | +| **_packageVersion** | `string` | optional | Owning package version. | +| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | + +### Nested Shape: `PackageInstallBody[option 2].navigationContributions[number]` + +A navigation contribution: a package injecting nav items into an app it does not own (ADR-0029 D7) + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **app** | `string` | ✅ | Target app name to contribute navigation into (e.g. "setup") | +| **group** | `string` | optional | Target group nav-item id to append into (e.g. "group_integrations"); omit to append at the app top level. Naming a group the target app does not declare is not refused: the items are appended at the app top level anyway and a `nav_contribution_group_missing` diagnostic is emitted — by the runtime at `warn`, and by `os build` and `os validate` at compile time. | +| **priority** | `integer` | optional (default: `200`) | Merge priority within the target group — lower applied first (matches object extender priority) | +| **items** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Navigation items contributed into the target app/group | + +### Nested Shape: `PackageInstallBody[option 2].engine` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **objectstack** | `string` | ✅ | ObjectStack platform version requirement (SemVer range, e.g. ">=3.0.0") | + +### Nested Shape: `PackageInstallBody[option 2].engines` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **platform** | `string` | optional | ObjectStack platform release range (SemVer, e.g. ">=4.0 <5") | +| **protocol** | `string` | optional | Runtime/metadata protocol range, checked first (ADR §3.10 #3) | + +--- + + --- ## PackageInstallRequest @@ -463,9 +656,10 @@ Install package request | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install (AUTHORING stage: `objects` are glob patterns) | | **settings** | `Record` | optional | User-provided settings at install time | -| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install | +| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install — honoured at POST /api/v1/packages: the installed row's `enabled` is written from this key | +| **overwrite** | `boolean` | optional | Overwrite an already-installed package id instead of answering 409 Conflict | | **platformVersion** | `string` | optional | Current platform version for compatibility verification | | **artifactRef** | `{ url: string; sha256: string; size: integer; format?: Enum<'tgz' \| 'zip'>; … }` | optional | Artifact reference for marketplace installation | @@ -522,7 +716,7 @@ Install package response | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | +| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ package: object; dependencyResolution?: object; namespaceConflicts?: object[]; message?: string }` | ✅ | | ### Nested Shape: `PackageInstallResponse.error` @@ -539,6 +733,15 @@ Install package response | **details** | `any` | optional | Additional error context (e.g. field validation errors) | | **requestId** | `string` | optional | Request ID for tracking | +### Nested Shape: `PackageInstallResponse.meta` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **timestamp** | `string` | ✅ | | +| **duration** | `integer` | optional | Server-side processing duration in milliseconds | +| **requestId** | `string` | optional | | +| **traceId** | `string` | optional | | + ### Nested Shape: `PackageInstallResponse.data` | Property | Type | Required | Description | @@ -636,7 +839,7 @@ Upgrade package response | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | +| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; phase: string; plan?: object; snapshotId?: string; … }` | ✅ | | ### Nested Shape: `PackageUpgradeResponse.error` @@ -653,6 +856,15 @@ Upgrade package response | **details** | `any` | optional | Additional error context (e.g. field validation errors) | | **requestId** | `string` | optional | Request ID for tracking | +### Nested Shape: `PackageUpgradeResponse.meta` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **timestamp** | `string` | ✅ | | +| **duration** | `integer` | optional | Server-side processing duration in milliseconds | +| **requestId** | `string` | optional | | +| **traceId** | `string` | optional | | + ### Nested Shape: `PackageUpgradeResponse.data` | Property | Type | Required | Description | @@ -722,7 +934,7 @@ Resolve dependencies response | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | +| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ dependencies: object[]; canProceed: boolean; requiredActions: object[]; installOrder: string[]; … }` | ✅ | Dependency resolution result with topological sort | ### Nested Shape: `ResolveDependenciesResponse.error` @@ -739,6 +951,15 @@ Resolve dependencies response | **details** | `any` | optional | Additional error context (e.g. field validation errors) | | **requestId** | `string` | optional | Request ID for tracking | +### Nested Shape: `ResolveDependenciesResponse.meta` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **timestamp** | `string` | ✅ | | +| **duration** | `integer` | optional | Server-side processing duration in milliseconds | +| **requestId** | `string` | optional | | +| **traceId** | `string` | optional | | + ### Nested Shape: `ResolveDependenciesResponse.data` | Property | Type | Required | Description | @@ -754,11 +975,14 @@ Resolve dependencies response ## UninstallPackageApiRequest +Uninstall package request + ### Properties | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **packageId** | `string` | ✅ | Package identifier | +| **keepData** | `boolean` | optional | Preserve object tables and remove metadata only; on the wire, `?keepData=true` or `?keepData=1` | --- @@ -773,7 +997,7 @@ Uninstall package response | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | +| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ packageId: string; success: boolean; message?: string }` | ✅ | | ### Nested Shape: `UninstallPackageApiResponse.error` @@ -790,6 +1014,15 @@ Uninstall package response | **details** | `any` | optional | Additional error context (e.g. field validation errors) | | **requestId** | `string` | optional | Request ID for tracking | +### Nested Shape: `UninstallPackageApiResponse.meta` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **timestamp** | `string` | ✅ | | +| **duration** | `integer` | optional | Server-side processing duration in milliseconds | +| **requestId** | `string` | optional | | +| **traceId** | `string` | optional | | + ### Nested Shape: `UninstallPackageApiResponse.data` | Property | Type | Required | Description | @@ -843,7 +1076,7 @@ Upload artifact response | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | +| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | | **data** | `{ success: boolean; artifactRef?: object; submissionId?: string; message?: string }` | ✅ | | ### Nested Shape: `UploadArtifactResponse.error` @@ -860,6 +1093,15 @@ Upload artifact response | **details** | `any` | optional | Additional error context (e.g. field validation errors) | | **requestId** | `string` | optional | Request ID for tracking | +### Nested Shape: `UploadArtifactResponse.meta` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **timestamp** | `string` | ✅ | | +| **duration** | `integer` | optional | Server-side processing duration in milliseconds | +| **requestId** | `string` | optional | | +| **traceId** | `string` | optional | | + ### Nested Shape: `UploadArtifactResponse.data` | Property | Type | Required | Description | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 2ab3b2932d1..88d647fc075 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -414,7 +414,7 @@ Canonical cross-paradigm action/node descriptor (ADR-0018) | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | +| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | | **succeeded** | `number` | ✅ | Number of records that succeeded | @@ -435,6 +435,15 @@ Canonical cross-paradigm action/node descriptor (ADR-0018) | **details** | `any` | optional | Additional error context (e.g. field validation errors) | | **requestId** | `string` | optional | Request ID for tracking | +### Nested Shape: `BatchDataResponse.meta` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **timestamp** | `string` | ✅ | | +| **duration** | `integer` | optional | Server-side processing duration in milliseconds | +| **requestId** | `string` | optional | | +| **traceId** | `string` | optional | | + ### Nested Shape: `BatchDataResponse.results[number]` | Property | Type | Required | Description | @@ -641,7 +650,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | +| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | | **succeeded** | `number` | ✅ | Number of records that succeeded | @@ -662,6 +671,15 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **details** | `any` | optional | Additional error context (e.g. field validation errors) | | **requestId** | `string` | optional | Request ID for tracking | +### Nested Shape: `DeleteManyDataResponse.meta` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **timestamp** | `string` | ✅ | | +| **duration** | `integer` | optional | Server-side processing duration in milliseconds | +| **requestId** | `string` | optional | | +| **traceId** | `string` | optional | | + ### Nested Shape: `DeleteManyDataResponse.results[number]` | Property | Type | Required | Description | @@ -849,7 +867,7 @@ Enable package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | The unique machine name of the object to query (e.g. "account"). | -| **query** | `{ object: string; fields?: string[]; where?: any; search?: string \| object; … }` | optional | Structured query definition (filter, sort, select, pagination). | +| **query** | `{ object: string; fields?: string[]; where?: [string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record \| any; search?: string \| object; … }` | optional | Structured query definition (filter, sort, select, pagination) — the canonical QueryAST or its transport spelling. | ### Nested Shape: `FindDataRequest.query` @@ -857,10 +875,10 @@ Enable package response | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | Object name (e.g. account) | | **fields** | `string[]` | optional | Fields to retrieve — names of the queried object's OWN columns. A dotted path (`owner.name`) is not a projection: no driver resolves one, and the ingress refuses it with `400 INVALID_FIELD`. Related data is read with `expand`, whose nested QueryAST both filters (`where`) and selects (`fields`) the related record's columns. The projection must RETAIN the foreign-key column: `fields: ['title']` with `expand: 'project_id'` resolves nothing, because the relation is carried by that key — add `'project_id'` and it works. Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes), the same remedy the sort axis prescribes. | -| **where** | `any` | optional | Filtering criteria (WHERE) | -| **search** | `string \| { query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | +| **where** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record \| any` | optional | Filtering criteria (WHERE) — a filter condition, or the input-only `FilterArray` sugar (`['status', '=', 'open']`), which is lowered through `parseFilterAST` before the query is produced. | +| **search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … }` | optional | Full-text search — the query text (canonical, ADR-0061 D1), or a structured FullTextSearch configuration | | **searchFields** | `string[]` | optional | Narrow the search to these fields (server-intersected with the allowed searchable set — can only narrow, never widen; ADR-0061 D1) | -| **orderBy** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Sorting instructions (ORDER BY) | +| **orderBy** | `{ field: string; order?: Enum<'asc' \| 'desc'> }[]` | optional | Sorting instructions (ORDER BY) | | **limit** | `number` | optional | Max records to return (LIMIT) | | **offset** | `number` | optional | Records to skip (OFFSET) | | **top** | `number` | optional | Alias for limit (OData compatibility) | @@ -872,6 +890,22 @@ Enable package response | **windowFunctions** | `never` | optional | [REMOVED] `query.windowFunctions` was removed in @objectstack/spec 17 (ADR-0049) — `find()` never applied it: no engine or driver read the key on the query path, so every OVER clause it declared was silently dropped. Delete the key. Window functions are a SQL-driver capability behind `SqlDriver.findWithWindowFunctions(object, query)` (embedder-level; not on the `IDataDriver` contract or the REST surface); request-level analytics are `aggregations` + `groupBy`. | | **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | | **expand** | `Record` | optional | Recursive relation loading map. Keys are lookup/master_detail field names; values are nested QueryAST objects that control select (`fields`) and filter (`where`, AND-merged with the batch $in), plus further expansion on the related object. The engine resolves expand via batch $in queries (driver-agnostic) with a default max depth of 3; per-parent `limit`/`offset`/`orderBy` are NOT applied on this path. | +| **$filter** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record \| any \| null` | optional | Transport spelling of `where` (OData `$filter`) | +| **filters** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record \| any \| null` | optional | Transport spelling of `where` (plural of `filter`) | +| **$top** | `number \| string \| null` | optional | Transport spelling of `limit` (OData `$top`) — a number, or the digits a querystring carries it as | +| **$skip** | `number \| string \| null` | optional | Transport spelling of `offset` (OData `$skip`) — a number, or the digits a querystring carries it as | +| **$orderby** | `Record> \| Record \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` (OData `$orderby`) | +| **$select** | `string \| string[] \| null` | optional | Transport spelling of `fields` (OData `$select`) | +| **$expand** | `string \| string[] \| Record \| null` | optional | Transport spelling of `expand` (OData `$expand`) | +| **$search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … } \| null` | optional | Transport spelling of `search` (OData `$search`) | +| **$searchFields** | `string \| string[] \| null` | optional | Transport spelling of `searchFields` (OData `$searchFields`) | +| **$count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Transport spelling of the response total-count flag (OData `$count`) | +| **count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Response total-count flag — only an explicit `false` skips the COUNT query | +| **filter** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record \| any \| null` | optional | Transport spelling of `where` | +| **select** | `string \| string[] \| null` | optional | Transport spelling of `fields` | +| **sort** | `Record> \| Record \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` | +| **skip** | `number \| string \| null` | optional | Transport spelling of `offset` | +| **populate** | `string \| string[] \| null` | optional | Transport spelling of `expand` | --- @@ -1638,9 +1672,9 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | -| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | +| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -1723,9 +1757,9 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | -| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; view?: string; preventNavigation?: boolean; openNewTab?: boolean; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | +| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[]; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -1876,7 +1910,7 @@ Install package request | :--- | :--- | :--- | :--- | | **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install | | **settings** | `Record` | optional | User-provided settings at install time | -| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install | +| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install — restates the install-door request key, whose one authority is api/PackageInstallRequest; this protocol primitive does not read it | | **platformVersion** | `string` | optional | Current platform version for compatibility verification | ### Nested Shape: `InstallPackageRequest.manifest` @@ -2819,7 +2853,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | :--- | :--- | :--- | :--- | | **success** | `boolean` | ✅ | Operation success status | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | -| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata | +| **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | | **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed | | **total** | `number` | ✅ | Total number of records in the batch | | **succeeded** | `number` | ✅ | Number of records that succeeded | @@ -2840,6 +2874,15 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **details** | `any` | optional | Additional error context (e.g. field validation errors) | | **requestId** | `string` | optional | Request ID for tracking | +### Nested Shape: `UpdateManyDataResponse.meta` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **timestamp** | `string` | ✅ | | +| **duration** | `integer` | optional | Server-side processing duration in milliseconds | +| **requestId** | `string` | optional | | +| **traceId** | `string` | optional | | + ### Nested Shape: `UpdateManyDataResponse.results[number]` | Property | Type | Required | Description | diff --git a/content/docs/references/kernel/manifest.mdx b/content/docs/references/kernel/manifest.mdx index 46ac1cd2971..9e2b28ad399 100644 --- a/content/docs/references/kernel/manifest.mdx +++ b/content/docs/references/kernel/manifest.mdx @@ -107,7 +107,7 @@ A navigation contribution: a package injecting nav items into an app it does not | **app** | `string` | ✅ | Target app name to contribute navigation into (e.g. "setup") | | **group** | `string` | optional | Target group nav-item id to append into (e.g. "group_integrations"); omit to append at the app top level. Naming a group the target app does not declare is not refused: the items are appended at the app top level anyway and a `nav_contribution_group_missing` diagnostic is emitted — by the runtime at `warn`, and by `os build` and `os validate` at compile time. | | **priority** | `integer` | optional (default: `200`) | Merge priority within the target group — lower applied first (matches object extender priority) | -| **items** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Navigation items contributed into the target app/group | +| **items** | `({ id: string; label?: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | ✅ | Navigation items contributed into the target app/group | ### Nested Shape: `Manifest.engine` diff --git a/content/docs/references/kernel/package-registry.mdx b/content/docs/references/kernel/package-registry.mdx index d88a7dc809c..ce4b5b76eba 100644 --- a/content/docs/references/kernel/package-registry.mdx +++ b/content/docs/references/kernel/package-registry.mdx @@ -184,7 +184,7 @@ Install package request | :--- | :--- | :--- | :--- | | **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest to install | | **settings** | `Record` | optional | User-provided settings at install time | -| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install | +| **enableOnInstall** | `boolean` | optional (default: `true`) | Whether to enable immediately after install — restates the install-door request key, whose one authority is api/PackageInstallRequest; this protocol primitive does not read it | | **platformVersion** | `string` | optional | Current platform version for compatibility verification | ### Nested Shape: `InstallPackageRequest.manifest` diff --git a/packages/spec/api-surface/kernel.json b/packages/spec/api-surface/kernel.json index 316acb19f64..ae06cb57733 100644 --- a/packages/spec/api-surface/kernel.json +++ b/packages/spec/api-surface/kernel.json @@ -438,6 +438,7 @@ "UpgradeSnapshotParsed (type)", "UpgradeSnapshotSchema (const)", "VIEW_LAYOUT_WITHOUT_BINDING (const)", + "VIEW_ROW_COLOR_UNRESOLVABLE_VALUE (const)", "VIEW_ROW_COLOR_WITHOUT_COLORS (const)", "VIEW_TREE_WITHOUT_PARENT_FIELD (const)", "ValidationError (type)", diff --git a/packages/spec/export-origins/kernel.json b/packages/spec/export-origins/kernel.json index e2caf683d29..ec35fabbd7d 100644 --- a/packages/spec/export-origins/kernel.json +++ b/packages/spec/export-origins/kernel.json @@ -435,6 +435,7 @@ "UpgradeSnapshotParsed": "src/kernel/package-upgrade.zod.ts#UpgradeSnapshotParsed (type)", "UpgradeSnapshotSchema": "src/kernel/package-upgrade.zod.ts#UpgradeSnapshotSchema (const)", "VIEW_LAYOUT_WITHOUT_BINDING": "src/kernel/functional-completeness.ts#VIEW_LAYOUT_WITHOUT_BINDING (const)", + "VIEW_ROW_COLOR_UNRESOLVABLE_VALUE": "src/kernel/functional-completeness.ts#VIEW_ROW_COLOR_UNRESOLVABLE_VALUE (const)", "VIEW_ROW_COLOR_WITHOUT_COLORS": "src/kernel/functional-completeness.ts#VIEW_ROW_COLOR_WITHOUT_COLORS (const)", "VIEW_TREE_WITHOUT_PARENT_FIELD": "src/kernel/functional-completeness.ts#VIEW_TREE_WITHOUT_PARENT_FIELD (const)", "ValidationError": "src/kernel/plugin-validator.zod.ts#ValidationError (type)", From 94a467565aaabb4d5d181072af3a3c98a6909406 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 15:10:57 +0000 Subject: [PATCH 15/19] docs(adr): ADR-0130 D4 records the entry door order and the fail-CLOSED direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling option A, component 2. `ManifestSchema.id` now carries `MANIFEST_ID_PATTERN`, and `AssembledPackageBodySchema` inherits it, so a `packages[]` entry whose `manifest.id` is empty is refused at the schema door — before `artifactPackageId`'s `id || name` fallback is consulted. D4 states that order, cites the pattern, and records the direction the change reverses on a consent path: what used to leave a `''`-keyed consent record unbound with the package loaded anyway (fail-OPEN) now refuses the artifact outright (fail-CLOSED). D4's two branches are unchanged; this is an addition to the record, not a reversal of a decision. Claude-Session: https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE Co-authored-by: Claude --- ...lease-artifact-as-co-ownership-boundary.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/adr/0130-release-artifact-as-co-ownership-boundary.md b/docs/adr/0130-release-artifact-as-co-ownership-boundary.md index 017490daa7b..b62aa73d73a 100644 --- a/docs/adr/0130-release-artifact-as-co-ownership-boundary.md +++ b/docs/adr/0130-release-artifact-as-co-ownership-boundary.md @@ -274,6 +274,44 @@ is an additive key on an existing object rather than a shape change. The reserva structural commitment only; the segmented form itself needs its own decision and is a Non-goal here. +**The entry door refuses an unusable `manifest.id` before the id-or-name fallback is consulted** +(2026-09-16, [#17534](https://github.com/objectstack-ai/objectstack/issues/17534) — an addition +to this record; D4's two branches above read exactly as accepted). `ManifestSchema.id` now +carries `MANIFEST_ID_PATTERN` (`packages/spec/src/kernel/manifest.zod.ts#MANIFEST_ID_PATTERN`) — +the reverse-domain rule the registry face (`PackageSchema.manifestId`, +`packages/spec/src/marketplace/package.zod.ts`) has always enforced, declared once and referenced +from both sites — and `AssembledPackageBodySchema` inherits it through `.extend()`. The order an +entry meets the two doors in is therefore: + +- **DOOR 1 — the schema.** `ArtifactPackageSchema.safeParse(entry)` + (`packages/core/src/artifact-packages.ts#resolveArtifactPackageOrder`) refuses an `id` of `''` + here, as `INVALID_ARTIFACT_PACKAGE_ENTRY` / `422`, naming `manifest.id` and echoing the value it + refused. `''` is no longer a valid manifest id to the schema at all. +- **DOOR 2 — the id.** `artifactPackageId` + (`packages/core/src/artifact-packages.ts#artifactPackageId`) is still `id || name` — ⛔ + deliberately untouched, because `ObjectQL.registerApp` still keys the installed package that way + — but for `''` it never runs: DOOR 1 refuses the entry first, so `{ id: '', name: 'x' }` can no + longer be carried under its sibling `name`. An entry that survives DOOR 1 carries an id matching + the pattern, non-empty by construction, so DOOR 2's `no usable package id` refusal is + unreachable from the `packages` branch. It is kept rather than deleted: it is the one + declaration of that requirement, and `artifactPackageId` is read by seams outside this path. + +**The direction is fail-OPEN → fail-CLOSED, on a consent path**, and it is recorded here rather +than left to the pins. Before: an artifact whose `grantedPermissions` record was keyed by `''` +was carried under its `name`, the consent record bound to nothing, it was reported `unbound`, and +the package registered with no consent record — nothing was denied. Now: the entry is refused at +materialize time and no package inside that artifact registers. The affected population is +artifacts that were already half-broken — their consent record never applied and the registry +face refused to publish them — so what changes is that a silent failure becomes an explicit one. +⛔ Read the refusal's provenance precisely: it is the artifact package door refusing a malformed +manifest id, **not** the permission seam acquiring teeth — +`packages/core/src/security/granted-permissions-not-enforced.pin.test.ts` still measures that +nothing on this tree enforces `grantedPermissions`. + +The order is pinned behaviourally, each case asserting its own door's message **and** the absence +of the other door's, in `packages/runtime/src/security/artifact-granted-permissions.test.ts` (the +`#13457 / #17534` block) — an alternation over both messages would survive deleting a whole door. + ### D5 — Topological ordering is an acceptance criterion, and reuses the one sorter Packages inside an artifact MUST be registered in **dependency topological order**. A package From 0fc32b42f9a8e3775bd74e3b2986c0fd202dadb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 15:24:18 +0000 Subject: [PATCH 16/19] test(spec): two package-api pins move to the post-narrowing reality `origin/main` landed `packages/spec/src/api/package-api.test.ts` while this branch was open, and two of its cases measured the accept set this branch narrows: the control that completes the domain-handler-registry drive (`pkg-a` is not reverse-domain notation, so completing the missing keys no longer makes it parse) and the residual that ran the other way (a whitespace-only `id` used to parse here while the door answered 400). Both are rewritten to what is now measured, each with a lit control proving the id is what decides it. No behaviour moves and the pattern is not relaxed. Claude-Session: https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE Co-authored-by: Claude --- packages/spec/src/api/package-api.test.ts | 33 ++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/spec/src/api/package-api.test.ts b/packages/spec/src/api/package-api.test.ts index 8a93cf15174..929402ccba0 100644 --- a/packages/spec/src/api/package-api.test.ts +++ b/packages/spec/src/api/package-api.test.ts @@ -867,12 +867,23 @@ describe('#18058 — install contract bound to the live door', () => { expect(PackageInstallBodySchema.safeParse(DOOR_DRIVE_REGISTRY).success).toBe(false); }); - it('the missing keys are what decide it — completing each drive turns it green', () => { + it('the missing keys are what decide it — and since #17534 the registry drive needs its id repaired too', () => { // The control that makes the two refusals above a measurement of the // MANIFEST's required keys rather than of the bare branch existing at all. expect(PackageInstallBodySchema.safeParse({ ...DOOR_DRIVE_CONFLICT, type: 'app' }).success).toBe(true); + // ⭐ #17534 moved this half. `ManifestSchema.id` carries + // `MANIFEST_ID_PATTERN` now, and `pkg-a` is not reverse-domain notation, + // so completing the missing keys is no longer sufficient for THIS drive — + // it stays refused, on the id's shape rather than on an absent key. + // ⛔ The remedy is to say that, not to relax the pattern: the drive posts + // an id the registry face has always refused to publish. + const registryKeysCompleted = { ...DOOR_DRIVE_REGISTRY, version: '1.0.0', type: 'app' }; + expect(PackageInstallBodySchema.safeParse(registryKeysCompleted).success).toBe(false); + // Lit control — the id is what decides it now: the same body with a + // reverse-domain id parses green, so the refusal above is not the missing + // keys coming back. expect(PackageInstallBodySchema.safeParse({ - ...DOOR_DRIVE_REGISTRY, version: '1.0.0', type: 'app', + ...registryKeysCompleted, id: 'com.acme.pkg-a', }).success).toBe(true); }); @@ -893,10 +904,20 @@ describe('#18058 — install contract bound to the live door', () => { } }); - it('and the residual runs the OTHER way too — a whitespace-only `id` parses here and the door answers 400', () => { - // `handlePackages` trims before keying and refuses an empty id, so this - // is the one class where the declaration is WIDER than the door. - expect(PackageInstallBodySchema.safeParse({ manifest: { ...SDK_MANIFEST, id: ' ' } }).success).toBe(true); + it('⭐ #17534 closed the one spelling that ran the OTHER way — a whitespace-only `id` is refused HERE now, not only by the door', () => { + // What this pinned before: `handlePackages` trims before keying and + // refuses an empty id, while this declaration ADMITTED `' '` — the one + // measured class where the declaration was WIDER than the door. + // `ManifestSchema.id` now carries `MANIFEST_ID_PATTERN`, which no + // whitespace-only string matches, so the declaration refuses it first and + // the two faces agree on this spelling. + expect(PackageInstallBodySchema.safeParse({ manifest: { ...SDK_MANIFEST, id: ' ' } }).success).toBe(false); + // Lit control — the id is what decided it: the same wrapped body with the + // fixture's own conforming id parses green. + expect(PackageInstallBodySchema.safeParse({ manifest: SDK_MANIFEST }).success).toBe(true); + // ⛔ NOT a claim that declaration and door are now equal: the refusals + // pinned above still run the other way — bodies the door answers 201 to + // that this declaration refuses. One spelling closed; the class remains. }); }); From 6783718d6ac5a39c79fc32637e8f03b74fd526fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 16:16:31 +0000 Subject: [PATCH 17/19] test(cli): the #18490 package-id pins move to the post-narrowing reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on this branch's merge head reds `packages/cli`'s integration tier: the #18490 file composes an artifact whose `manifest.id` is `''` and parses it through `ObjectStackDefinitionSchema`, which this branch's pattern now refuses. That file said it would red first and that the pins under it would then measure nothing, so the red is converted into the pin it asked for: the first case now pins the refusal itself, on `manifest.id`, with a lit control proving the id is what decides it, and the three cross-door cases keep measuring that the build names a package exactly as the runtime fold does at an identity a command can actually hand down. The divergence the file was written around is structurally closed — a parsed package's id is non-empty by construction, so the owner's `id || name` never falls back. The degenerate fixture is not kept alive by bypassing the parse. Claude-Session: https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE Co-authored-by: Claude --- ...nav-contribution-groups.package-id.test.ts | 97 ++++++++++++++----- 1 file changed, 72 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/utils/nav-contribution-groups.package-id.test.ts b/packages/cli/src/utils/nav-contribution-groups.package-id.test.ts index bc8e7b2ef58..42316b4168f 100644 --- a/packages/cli/src/utils/nav-contribution-groups.package-id.test.ts +++ b/packages/cli/src/utils/nav-contribution-groups.package-id.test.ts @@ -27,6 +27,26 @@ * `ObjectQL`, and the assertion is that the two STRINGS match — so the day * either rule moves, this reds instead of agreeing with itself. * + * ## ⭐ #17534 — the divergent input is no longer reachable through the parse + * + * This file's first case used to be a floor: `ManifestSchema` constrained + * NEITHER id key to be non-empty, so `''` parsed and the two rules could + * disagree on a stack `os build` would accept. It said, in as many words, that + * a spec change refusing that input should red HERE first. #17534 is that + * change: `ManifestSchema.id` carries `MANIFEST_ID_PATTERN`, so a composed + * artifact whose `manifest.id` is `''` is refused at the parse door, by name. + * + * What that does to this file is structural, not cosmetic — the owner derives + * `manifest.id || manifest.name`, and after #17534 a parsed package's `id` is + * always a pattern-matching, non-empty string, so the fallback never runs and + * the two rules can no longer disagree on ANY parsed input. So the first case + * is now the closure itself (the refusal, quoted), and the cross-door cases + * keep measuring what they were written to measure — that the build names a + * package EXACTLY as the runtime fold does, and never with the deleted copy's + * positional spelling — at an identity a command can actually hand down. + * ⛔ The degenerate fixture is not kept alive by bypassing the parse: a pin on + * an input no command can produce measures nothing and reads as if it did. + * * ## Why this is a file of its own, and why it is INTEGRATION tier * * The cross-door half constructs `new ObjectQL(`, which is a KERNEL signal in @@ -50,6 +70,8 @@ const APP = 'multi_crm'; const GROUP = 'sales_group'; const TYPO = 'sales_grp'; const CORE_ID = 'com.example.multi.core'; +/** The contributing package's id — reverse-domain, as #17534 requires of every parsed manifest. */ +const ORDERS_ID = 'com.example.multi.orders'; /** The App package — owns the app and the group container the module aims at. */ const coreStack = () => ({ @@ -79,17 +101,22 @@ const coreStack = () => ({ }); /** - * The contributing package, carrying the divergent identity: both id keys `''`. + * The contributing package. * * ⚠️ Its own namespace, not the app package's. The composed-artifact leg does * not care, but the runtime leg INSTALLS both packages into one registry and * ADR-0048 refuses a second package under a namespace another already owns — * a conflict that would make this file red for a reason that has nothing to do * with what it measures. + * + * ⭐ #17534: `id` is the identity both doors derive; `name` stays `''` so the + * owner's `id || name` is still exercised on a manifest that gives the fallback + * nothing to fall back TO — the nearest reachable neighbour of the input this + * file was written around. */ -const emptyIdOrdersStack = (group: string) => ({ +const ordersStack = (group: string, id: string = ORDERS_ID) => ({ manifest: { - id: '', + id, name: '', namespace: 'ord', version: '1.0.0', @@ -108,14 +135,18 @@ const emptyIdOrdersStack = (group: string) => ({ }], }); -/** The artifact as the commands actually hand it down — through their own parse. */ -const parsedEmptyIdArtifact = (group: string): AnyRec => { +/** The composed artifact, up to but NOT through the parse — the parse is what two cases below read. */ +const composedArtifact = (group: string, id: string = ORDERS_ID): AnyRec => { const composed = composeStacks( - [emptyIdOrdersStack(group), coreStack()], + [ordersStack(group, id), coreStack()], { manifest: 'preserve' }, ) as unknown as Record; - const normalized = normalizeStackInput(composed, { onConversionNotice: () => {} }); - const result = ObjectStackDefinitionSchema.safeParse(normalized); + return normalizeStackInput(composed, { onConversionNotice: () => {} }) as unknown as AnyRec; +}; + +/** The artifact as the commands actually hand it down — through their own parse. */ +const parsedArtifact = (group: string, id: string = ORDERS_ID): AnyRec => { + const result = ObjectStackDefinitionSchema.safeParse(composedArtifact(group, id)); if (!result.success) { throw new Error(`fixture does not parse: ${JSON.stringify(result.error.issues.slice(0, 3))}`); } @@ -123,20 +154,32 @@ const parsedEmptyIdArtifact = (group: string): AnyRec => { }; describe('#18490 — one package, two doors, one name', () => { - it('an empty `manifest.id` AND `manifest.name` REACHES this check — the divergence is not hypothetical', () => { - // The floor under everything below. `ManifestSchema` requires both keys as - // strings and constrains NEITHER to be non-empty, so `''` parses — which is - // the only reason the two rules could ever disagree on a stack `os build` - // or `os validate` would actually accept. If a spec change starts refusing - // it, this reds FIRST and says the pins under it now measure nothing. - const parsed = parsedEmptyIdArtifact(GROUP); - expect(artifactPackages(parsed).map((pkg) => pkg.id).sort()).toEqual(['', CORE_ID]); + it('⭐ #17534 — an empty `manifest.id` no longer REACHES this check: the parse door refuses it by name', () => { + // The floor, inverted. It used to read: `ManifestSchema` constrains NEITHER + // id key to be non-empty, so `''` parses and the two rules can disagree on + // a stack `os build` would accept — and it promised to red FIRST if a spec + // change started refusing that input. This is that red, converted into the + // pin it asked for: `ManifestSchema.id` carries `MANIFEST_ID_PATTERN`, so + // the composed artifact is refused at the parse, on `manifest.id`, with the + // value echoed. ⇒ Every parsed package now has a non-empty id, the owner's + // `id || name` never falls back, and the divergence this file was written + // around cannot occur on any input a command hands down. + const refused = ObjectStackDefinitionSchema.safeParse(composedArtifact(GROUP, '')); + expect(refused.success).toBe(false); + const issues = refused.success ? [] : refused.error.issues; + expect(issues.some((issue) => issue.path.join('.') === 'packages.0.manifest.id')).toBe(true); + expect(issues.map((issue) => issue.message).join('\n')).toContain("Invalid package id ''"); + + // Lit control — the id is what decides it: the same fixture with a + // reverse-domain id parses, and the owner names both packages. + expect(artifactPackages(parsedArtifact(GROUP)).map((pkg) => pkg.id).sort()) + .toEqual([CORE_ID, ORDERS_ID]); }); it('the build names that package EXACTLY as the runtime fold does', async () => { // The build door: the shipped derivation, reached the way both commands // reach it — `findNavGroupDiagnostics(result.data)`, one argument. - const built = await findNavGroupDiagnostics(parsedEmptyIdArtifact(TYPO)); + const built = await findNavGroupDiagnostics(parsedArtifact(TYPO)); expect(built).toHaveLength(1); // The runtime door: the same two packages installed into a real registry. @@ -146,7 +189,7 @@ describe('#18490 — one package, two doors, one name', () => { const engine = new ObjectQL(); const core = coreStack(); engine.registerApp({ ...core.manifest, apps: core.apps }); - engine.registerApp({ ...emptyIdOrdersStack(TYPO).manifest }); + engine.registerApp({ ...ordersStack(TYPO).manifest }); engine.registry.getApp(APP); const folded = engine.registry.getAppNavDiagnostics(APP); expect(folded).toHaveLength(1); @@ -161,8 +204,8 @@ describe('#18490 — one package, two doors, one name', () => { // Its own assertion, because the pin above would also pass if BOTH doors // moved to `packages[0]`. The runtime has no positional fallback, so a // build printing one is a build naming a package the runtime never will. - const built = await findNavGroupDiagnostics(parsedEmptyIdArtifact(TYPO)); - expect(built[0].packageId).toBe(''); + const built = await findNavGroupDiagnostics(parsedArtifact(TYPO)); + expect(built[0].packageId).toBe(ORDERS_ID); expect(built[0].packageId).not.toBe('packages[0]'); expect(built[0].message).not.toContain('packages[0]'); }); @@ -170,11 +213,15 @@ describe('#18490 — one package, two doors, one name', () => { it('two packages that both resolve to the empty id still produce TWO findings', async () => { // The id is CARRIED and PRINTED on this path — never a map key, a dedupe // key or a sort key. Pinned because "both collapse into one finding" is the - // failure an empty id would cause if it ever became one, and it would - // present as the report going QUIET rather than as an error. + // failure a SHARED id would cause if it ever became one, and it would + // present as the report going QUIET rather than as an error. ⭐ #17534 moved + // the shared value from `''` to a reverse-domain id; the property under + // test — two packages resolving to ONE name still produce two findings — is + // unchanged, and a duplicate id is still reachable here because this path + // carries the id rather than keying on it. const second = { manifest: { - id: '', + id: ORDERS_ID, name: '', navigationContributions: [{ app: APP, @@ -183,13 +230,13 @@ describe('#18490 — one package, two doors, one name', () => { }], }, }; - const parsed = parsedEmptyIdArtifact(TYPO); + const parsed = parsedArtifact(TYPO); const widened: AnyRec = { ...parsed, packages: [...((parsed.packages ?? []) as unknown[]), second], }; const found = await findNavGroupDiagnostics(widened); expect(found).toHaveLength(2); - expect(found.map((d) => d.packageId)).toEqual(['', '']); + expect(found.map((d) => d.packageId)).toEqual([ORDERS_ID, ORDERS_ID]); }); }); From 866c8cfc904d6e59a9214f4e7457b591134b6989 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 17:11:36 +0000 Subject: [PATCH 18/19] docs,test: the last literal manifest ids the new pattern refuses move to reverse-domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ManifestSchema.id` now carries `MANIFEST_ID_PATTERN`, so every remaining literal `manifest.id` in the tree that the pattern refuses is either a fixture that would not survive its own schema or published guidance teaching an author a spelling the runtime rejects. Both are renamed here; nothing about the pattern moves. Fixtures (no assertion reads the id string, so each case keeps its meaning): - packages/lint/src/validate-translation-references.test.ts — the 13 `crm_core` / `crm_service` package ids become `com.example.crm-core` / `com.example.crm-service` (these arrived from `main` through the merge). - packages/create-objectstack/src/rewrite-identity.test.ts — the scaffolded config fixture's `id: 'x'` becomes `com.example.x`, the spelling its own sibling fixture in the same file already used. Published guidance: - packages/spec/prompts/create-new-project.md — `com.example.my_erp` → `com.example.my-erp` (this file ships in the spec tarball). - content/docs/getting-started/your-first-project.mdx — `my-app` → `com.example.my-app`, which is what `create-objectstack` now derives for a project named `my-app`; `namespace: 'my_app'` is untouched (a namespace admits underscores and this key does not). - content/docs/api/declarative-endpoints.mdx, content/docs/protocol/kernel/http-protocol.mdx — `acme-crm` → `com.acme.crm`; `namespace: 'acme'` untouched. - content/docs/api/metadata-api.mdx — `plugin-auth` → `com.objectstack.plugin-auth` on both request bodies and both echoed responses. - packages/services/service-package/README.md — `crm` → `com.example.crm`, including the `get`/`delete` calls that address the same package. - docs/qa/platform-checklist/areas/{api-backend,platform-core}.json — the scratch probe ids the two install-door items POST move to reverse-domain spellings; both items bump `revision` and append the history entry that edit owes. Deliberately NOT renamed, each for a stated reason: - packages/runtime/src/domain-handler-registry.test.ts:591 `pkg-a` — it pins the HTTP install door's residual, which answers 201 to an id every other door now refuses; renaming it would delete the only pin on that gap. - the `FROM` examples in this PR's own changeset — showing the refused spelling is what a FROM → TO migration note is for. - the `manifestId` values in the local install-ledger fixtures (packages/cli/src/commands/doctor-ledger-*.test.ts, packages/cloud-connection/src/*.test.ts) — that is a plain `manifestId: string` field on the ledger entry interface, judged by no schema; it is not `ManifestSchema.id` and not `PackageSchema.manifestId`. - the `Not A Reverse Domain` fixtures in packages/spec — the negative controls of the template-manifest gate and its test. Claude-Session: https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE Co-authored-by: Claude --- content/docs/api/declarative-endpoints.mdx | 2 +- content/docs/api/metadata-api.mdx | 8 +++--- .../getting-started/your-first-project.mdx | 2 +- .../docs/protocol/kernel/http-protocol.mdx | 2 +- .../platform-checklist/areas/api-backend.json | 24 ++++++++++------ .../areas/platform-core.json | 24 ++++++++++------ .../src/rewrite-identity.test.ts | 2 +- .../validate-translation-references.test.ts | 28 +++++++++---------- packages/services/service-package/README.md | 6 ++-- packages/spec/prompts/create-new-project.md | 2 +- 10 files changed, 56 insertions(+), 44 deletions(-) diff --git a/content/docs/api/declarative-endpoints.mdx b/content/docs/api/declarative-endpoints.mdx index e4ae65983af..8cf1a6d2b77 100644 --- a/content/docs/api/declarative-endpoints.mdx +++ b/content/docs/api/declarative-endpoints.mdx @@ -47,7 +47,7 @@ import { defineStack } from '@objectstack/spec'; export default defineStack({ manifest: { - id: 'acme-crm', + id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', type: 'app', diff --git a/content/docs/api/metadata-api.mdx b/content/docs/api/metadata-api.mdx index 605319e398a..550473a733c 100644 --- a/content/docs/api/metadata-api.mdx +++ b/content/docs/api/metadata-api.mdx @@ -105,16 +105,16 @@ List installed packages. Install a package from its manifest (SDK: `client.packages.install`). Re-installing an already-installed `id` returns **409 Conflict** unless `overwrite: true`. -**Body**: `{ manifest: { id: "plugin-auth", name: "Plugin Auth", version: "1.0.0", ... }, settings?: { ... }, enableOnInstall?: true, overwrite?: false }` -**Response**: `{ package: { id: "plugin-auth", version: "1.0.0", ... }, message?: "..." }` +**Body**: `{ manifest: { id: "com.objectstack.plugin-auth", name: "Plugin Auth", version: "1.0.0", ... }, settings?: { ... }, enableOnInstall?: true, overwrite?: false }` +**Response**: `{ package: { id: "com.objectstack.plugin-auth", version: "1.0.0", ... }, message?: "..." }` ### `POST /packages/publish` Publish a package (manifest + metadata) to the package marketplace registry. This is publisher tooling, not part of the app SDK surface. -**Body**: `{ manifest: { id: "plugin-auth", name: "Plugin Auth", version: "1.0.0", ... }, metadata: { objects: [...], views: [...], ... } }` -**Response**: `{ success: true, message: "...", package: { id: "plugin-auth", version: "1.0.0" } }` +**Body**: `{ manifest: { id: "com.objectstack.plugin-auth", name: "Plugin Auth", version: "1.0.0", ... }, metadata: { objects: [...], views: [...], ... } }` +**Response**: `{ success: true, message: "...", package: { id: "com.objectstack.plugin-auth", version: "1.0.0" } }` ### `GET /packages/:id` diff --git a/content/docs/getting-started/your-first-project.mdx b/content/docs/getting-started/your-first-project.mdx index 26f9763498e..ce0518fc120 100644 --- a/content/docs/getting-started/your-first-project.mdx +++ b/content/docs/getting-started/your-first-project.mdx @@ -109,7 +109,7 @@ import * as objects from './src/objects/index.js'; export default defineStack({ manifest: { - id: 'my-app', + id: 'com.example.my-app', namespace: 'my_app', version: '0.1.0', type: 'app', diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index 4338ea37eea..00fdaf6440c 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -1179,7 +1179,7 @@ import { defineStack } from '@objectstack/spec'; export default defineStack({ manifest: { - id: 'acme-crm', + id: 'com.acme.crm', name: 'Acme CRM', version: '1.0.0', type: 'app', diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index 64486cd5022..de1da6a79d3 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -922,7 +922,7 @@ "title": "Package REST lifecycle: POST /packages creates (201), a duplicate name is refused 409 (no silent manifest clobber), PATCH partial-patches the manifest, and an explicit overwrite re-install replaces in place without duplicating", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P2", "surface": "api", "personas": [ @@ -931,31 +931,31 @@ "fixtures": { "app": "showcase", "requires": [ - "a runtime that accepts package install/patch over HTTP (os dev's dispatcher install route, packages.install → POST /api/v1/packages); a scratch package id (e.g. qa_pkg_lifecycle_probe) so no shipped package is mutated" + "a runtime that accepts package install/patch over HTTP (os dev's dispatcher install route, packages.install → POST /api/v1/packages); a scratch package id (e.g. com.example.qa-pkg-lifecycle-probe) so no shipped package is mutated" ], "knownGaps": [ "if the deployment blocks runtime package install (read-only metadata), record it as a fixture requirement and treat the item blocked(environment) rather than failing the install probes" ] }, "steps": [ - "boot showcase isolated; sign in as admin; choose a scratch id qa_pkg_lifecycle_probe", - "POST /api/v1/packages with { manifest: { id: 'qa_pkg_lifecycle_probe', name: 'QA Probe', version: '1.0.0', scope: 'custom', type: 'app' } }; capture status + body; then GET /api/v1/packages/qa_pkg_lifecycle_probe to confirm it persisted (the dispatcher install lands in BOTH the in-memory registry and durable sys_packages)", + "boot showcase isolated; sign in as admin; choose a scratch id com.example.qa-pkg-lifecycle-probe", + "POST /api/v1/packages with { manifest: { id: 'com.example.qa-pkg-lifecycle-probe', name: 'QA Probe', version: '1.0.0', scope: 'custom', type: 'app' } }; capture status + body; then GET /api/v1/packages/com.example.qa-pkg-lifecycle-probe to confirm it persisted (the dispatcher install lands in BOTH the in-memory registry and durable sys_packages)", "POST the SAME manifest again with NO overwrite flag; capture status + body", - "PATCH /api/v1/packages/qa_pkg_lifecycle_probe with { name: 'QA Probe Renamed', description: 'edited', version: '1.1.0' }; GET the package back and confirm the patch persisted and that id/scope/type and the lifecycle fields (enabled/status/installedAt) are untouched", + "PATCH /api/v1/packages/com.example.qa-pkg-lifecycle-probe with { name: 'QA Probe Renamed', description: 'edited', version: '1.1.0' }; GET the package back and confirm the patch persisted and that id/scope/type and the lifecycle fields (enabled/status/installedAt) are untouched", "PATCH three malformed bodies: { name: '' }, { version: 'not-semver' }, and {} (nothing to update); capture each rejection", "re-install with the explicit opt-in: POST /api/v1/packages?overwrite=true (or body { overwrite: true }) carrying the same id and a changed manifest (version 2.0.0); capture status", - "GET /api/v1/packages and count rows whose id == qa_pkg_lifecycle_probe — must be exactly one", + "GET /api/v1/packages and count rows whose id == com.example.qa-pkg-lifecycle-probe — must be exactly one", "confirm the LIVE routing seam: POST /api/v1/packages resolves to the dispatcher install route (REST moved marketplace publish OFF the bare path to POST /api/v1/packages/publish in #3610), and PATCH /api/v1/packages/:id answers non-404 though it is absent from packages/rest/src/rest-route-ledger.ts (dispatcher-only)" ], "acceptance": [ { "clause": "a scratch package installs → 201: POST /api/v1/packages returns 201 with the created package, and a follow-up GET reads it back (the install lands in the in-memory registry AND durable sys_packages, per packages.ts routing through protocol.installPackage)", "oracle": "api", - "verify": "POST status 201; GET /api/v1/packages/qa_pkg_lifecycle_probe returns the package", + "verify": "POST status 201; GET /api/v1/packages/com.example.qa-pkg-lifecycle-probe returns the package", "evidence": "the POST + GET responses" }, { - "clause": "a duplicate name is refused 409, NEVER silently overwritten: the second POST (no overwrite) answers 409 with message \"Package 'qa_pkg_lifecycle_probe' already exists\" and the bare-409 derived code RESOURCE_CONFLICT (HttpStatusErrorCodeMap 409) — the #2995 data-loss footgun (a silent re-install destroying the existing manifest) is closed", + "clause": "a duplicate name is refused 409, NEVER silently overwritten: the second POST (no overwrite) answers 409 with message \"Package 'com.example.qa-pkg-lifecycle-probe' already exists\" and the bare-409 derived code RESOURCE_CONFLICT (HttpStatusErrorCodeMap 409) — the #2995 data-loss footgun (a silent re-install destroying the existing manifest) is closed", "oracle": "api", "verify": "step-3 response: status 409, code RESOURCE_CONFLICT, message names the existing id; the stored manifest is unchanged from step 2", "evidence": "the 409 response + a re-read proving the manifest survived" @@ -975,7 +975,7 @@ { "clause": "the explicit overwrite re-install replaces in place WITHOUT duplicating: POST with overwrite=true (body or query) succeeds and the subsequent GET /api/v1/packages lists exactly ONE row for the id — the overwrite is the deliberate opt-out of the 409 guard, and it never leaves two package rows behind", "oracle": "api", - "verify": "step-6 status is a success (201/200); step-7 count of qa_pkg_lifecycle_probe rows == 1; the manifest reflects the overwrite (version 2.0.0)", + "verify": "step-6 status is a success (201/200); step-7 count of com.example.qa-pkg-lifecycle-probe rows == 1; the manifest reflects the overwrite (version 2.0.0)", "evidence": "the overwrite response + the deduped list" }, { @@ -1006,6 +1006,12 @@ "date": "2026-08-08", "change": "new — package REST lifecycle (create/409-dup/patch/overwrite-reinstall), grounded in the dispatcher packages domain and the 409 data-loss guard; per PENDING-GAPS §G3", "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-09-20", + "change": "the scratch probe id these steps POST is rewritten to a reverse-domain spelling (com.example.qa-pkg-lifecycle-probe) — `ManifestSchema.id` now carries `MANIFEST_ID_PATTERN`, so the underscored spellings this item used to name are refused by every door but the HTTP install door, and a checklist that teaches one is the trap the pattern was added to close. Steps, legs and expected verdicts are otherwise unchanged", + "ref": "#18319" } ] }, diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 28bec318465..54047bdf3a0 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -1281,7 +1281,7 @@ "title": "The package manifest is enforced at the install boundary: an incompatible `engines` range is refused before the registry write, the namespace gate holds ownership, and a namespace-less runtime package has one derived", "since": "v15", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": [ @@ -1292,7 +1292,7 @@ "requires": [ "a runtime that accepts package install over HTTP — os dev's dispatcher install route, POST /api/v1/packages → protocol.installPackage (the same door api-backend.package-rest-lifecycle drives for its 201/409/PATCH contract; this item drives the manifest GATES that run BEFORE the row is written)", "the RUNNING runtime's protocol major, read from the server rather than assumed — every engines range below is derived from it as RT. A literal '^11' rots the moment the major turns, and a rotted range makes the compatible and incompatible legs swap places silently", - "scratch package ids only (qa_manifest_probe_*, com.example.leave, com.example.holiday) so no shipped package is mutated" + "scratch package ids only (com.example.qa-manifest-probe-*, com.example.leave, com.example.holiday) so no shipped package is mutated" ], "knownGaps": [ "the DURABLE half of the handshake is NOT scored here: ADR-0087 also refuses an incompatible `sys_packages` row during boot-time rehydration, with boot CONTINUING (v15 release notes). Staging it needs an already-stored incompatible row — DB-level access, or a runtime whose major moved under a previously-installed package. Record blocked(fixture) if attempted. The install-path refusal this item does score is the same assertProtocolCompat call one boundary earlier, so a green here says nothing about the rehydration arm", @@ -1303,13 +1303,13 @@ }, "steps": [ "boot showcase isolated; sign in as admin. Read the runtime's protocol version from the running server (the boot banner / GET /api/v1/meta), take its leading major as RT, and derive every range below from RT — never from a literal", - "BASELINE: POST /api/v1/packages with { manifest: { id: 'qa_manifest_probe_a', name: 'Manifest Probe A', version: '1.0.0', scope: 'custom', type: 'app', namespace: 'qa_mfp', engines: { protocol: '^RT' } } }; capture status + body and GET it back — this is the compatible control every refusal below is judged against", - "INCOMPATIBLE: POST id 'qa_manifest_probe_old' with engines: { protocol: '^(RT-1)' }; capture the refusal IN FULL (status, code, message, rangeSource, targetMajor, migrateCommand). Then GET /api/v1/packages/qa_manifest_probe_old and GET /api/v1/packages and confirm NO row exists for that id", - "PRECEDENCE, both directions: POST 'qa_manifest_probe_prec_ok' with engines: { protocol: '^RT', platform: '^(RT-1)' } → expect install (protocol is consulted first, so the incompatible platform range is never reached); POST 'qa_manifest_probe_prec_no' with engines: { protocol: '^(RT-1)', platform: '^RT' } → expect refusal whose diagnostic names `engines.protocol` as the range source", - "LEGACY leg: POST 'qa_manifest_probe_legacy' with NO `engines` and engine: { objectstack: '^(RT-1)' } → expect refusal whose diagnostic names `engine.objectstack` as the source; repeat with '^RT' → expect install. This proves the third precedence rung is wired, not merely typed", - "GRANDFATHERED: POST 'qa_manifest_probe_norange' with no `engines` and no `engine` at all → expect install, plus the server's `[protocol] package '…' declares no engines.protocol range` warning in the log", - "UNPARSED: POST 'qa_manifest_probe_wsrange' with engines: { protocol: 'workspace:*' } → expect install, plus the `declares an unrecognized engines.protocol range` warning. This is the direction that would break every workspace-linked package if it inverted", - "NAMESPACE OWNERSHIP, three legs: (a) POST 'qa_manifest_probe_b' declaring namespace 'qa_mfp' — the one probe A already owns → expect refusal naming the namespace, the current owner and the incoming id; (b) re-POST probe A's OWN manifest unchanged (same id, same namespace) → expect NO conflict (reinstall/HMR is a normal path); (c) POST 'qa_manifest_probe_shared' declaring a shareable platform namespace (base / system / sys) → expect NO conflict (shareable namespaces are exempt)", + "BASELINE: POST /api/v1/packages with { manifest: { id: 'com.example.qa-manifest-probe-a', name: 'Manifest Probe A', version: '1.0.0', scope: 'custom', type: 'app', namespace: 'qa_mfp', engines: { protocol: '^RT' } } }; capture status + body and GET it back — this is the compatible control every refusal below is judged against", + "INCOMPATIBLE: POST id 'com.example.qa-manifest-probe-old' with engines: { protocol: '^(RT-1)' }; capture the refusal IN FULL (status, code, message, rangeSource, targetMajor, migrateCommand). Then GET /api/v1/packages/com.example.qa-manifest-probe-old and GET /api/v1/packages and confirm NO row exists for that id", + "PRECEDENCE, both directions: POST 'com.example.qa-manifest-probe-prec-ok' with engines: { protocol: '^RT', platform: '^(RT-1)' } → expect install (protocol is consulted first, so the incompatible platform range is never reached); POST 'com.example.qa-manifest-probe-prec-no' with engines: { protocol: '^(RT-1)', platform: '^RT' } → expect refusal whose diagnostic names `engines.protocol` as the range source", + "LEGACY leg: POST 'com.example.qa-manifest-probe-legacy' with NO `engines` and engine: { objectstack: '^(RT-1)' } → expect refusal whose diagnostic names `engine.objectstack` as the source; repeat with '^RT' → expect install. This proves the third precedence rung is wired, not merely typed", + "GRANDFATHERED: POST 'com.example.qa-manifest-probe-norange' with no `engines` and no `engine` at all → expect install, plus the server's `[protocol] package '…' declares no engines.protocol range` warning in the log", + "UNPARSED: POST 'com.example.qa-manifest-probe-wsrange' with engines: { protocol: 'workspace:*' } → expect install, plus the `declares an unrecognized engines.protocol range` warning. This is the direction that would break every workspace-linked package if it inverted", + "NAMESPACE OWNERSHIP, three legs: (a) POST 'com.example.qa-manifest-probe-b' declaring namespace 'qa_mfp' — the one probe A already owns → expect refusal naming the namespace, the current owner and the incoming id; (b) re-POST probe A's OWN manifest unchanged (same id, same namespace) → expect NO conflict (reinstall/HMR is a normal path); (c) POST 'com.example.qa-manifest-probe-shared' declaring a shareable platform namespace (base / system / sys) → expect NO conflict (shareable namespaces are exempt)", "NAMESPACE DERIVATION: POST id 'com.example.leave' with NO namespace declared → GET it back and read manifest.namespace; then POST 'com.example.holiday' WITH an explicit namespace 'timeoff' → GET it back and confirm the declared value survived untouched", "teardown: DELETE every scratch package created above, or simply discard the isolated file DB — the cheaper path an isolated boot makes free. ⚠️ Several legs here are NOT re-runnable in place (step 8b depends on probe A still being installed, and 8a stops being a conflict once probe A is gone), so a re-run starts from a clean DB rather than from the tail of the previous one" ], @@ -1377,6 +1377,12 @@ "date": "2026-08-24", "change": "new — authored to classify the `manifest` capability for the coverage ratchet, which had flagged it UNCLASSIFIED since its liveness ledger landed. Grounding decided the shape: the manifest is an authored, enforced surface (namespace ownership refuses at install, an incompatible engines range refuses before the registry write, a namespace-less runtime package has one derived), not an internal ledger, so it earns items rather than a waiver. This item takes the INSTALL boundary; cli.plugin-manifest-build-contract takes the packaging boundary; api-backend.package-rest-lifecycle already covered the install door's 201/409/PATCH contract and is mapped alongside them. Every range in the steps is derived from the running runtime's major rather than written literally, so the compatible and incompatible legs cannot swap places when the major turns", "ref": "#11421" + }, + { + "revision": 2, + "date": "2026-09-20", + "change": "the scratch probe ids these steps POST are rewritten to reverse-domain spellings (com.example.qa-manifest-probe-*) — `ManifestSchema.id` now carries `MANIFEST_ID_PATTERN`, so the underscored spellings this item used to name are refused by every door but the HTTP install door, and a checklist that teaches one is the trap the pattern was added to close. Steps, legs and expected verdicts are otherwise unchanged", + "ref": "#18319" } ] }, diff --git a/packages/create-objectstack/src/rewrite-identity.test.ts b/packages/create-objectstack/src/rewrite-identity.test.ts index bb2bfa0e15a..3b42075ca24 100644 --- a/packages/create-objectstack/src/rewrite-identity.test.ts +++ b/packages/create-objectstack/src/rewrite-identity.test.ts @@ -28,7 +28,7 @@ afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); const writeConfig = (ns: string) => fs.writeFileSync( path.join(dir, 'objectstack.config.ts'), - `export default defineStack({\n manifest: {\n id: 'x',\n namespace: '${ns}',\n },\n});\n`, + `export default defineStack({\n manifest: {\n id: 'com.example.x',\n namespace: '${ns}',\n },\n});\n`, ); const writeObject = (file: string, name: string) => diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 50926066779..a163819b5e2 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -628,12 +628,12 @@ describe('validateTranslationReferences — apps, dashboards, global actions', ( * all three placements here is what keeps that true. */ describe('validateTranslationReferences — contributed navigation (#18203)', () => { - /** Package `crm_core` owns the app; package `crm_service` contributes into it. */ + /** Package `com.example.crm-core` owns the app; package `com.example.crm-service` contributes into it. */ const contributedArtifact = (contributions: unknown[]) => ({ packages: [ { manifest: { - id: 'crm_core', + id: 'com.example.crm-core', apps: [ { name: 'crm_enterprise', @@ -645,7 +645,7 @@ describe('validateTranslationReferences — contributed navigation (#18203)', () ], }, }, - { manifest: { id: 'crm_service', navigationContributions: contributions } }, + { manifest: { id: 'com.example.crm-service', navigationContributions: contributions } }, ], objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], apps: [ @@ -777,7 +777,7 @@ describe('validateTranslationReferences — contributed navigation (#18203)', () it('reads contributions off the stack\'s own top-level `manifest` when there is no `packages[]`', () => { const findings = validateTranslationReferences({ manifest: { - id: 'crm_service', + id: 'com.example.crm-service', navigationContributions: [ { app: 'crm_enterprise', group: 'group_service', items: [{ id: 'nav_case', type: 'object' }] }, ], @@ -799,10 +799,10 @@ describe('validateTranslationReferences — contributed navigation (#18203)', () */ it('accepts the key in the per-package leg, where the app owner declares no contribution itself', () => { const artifactPackages = [ - { manifest: { id: 'crm_core', apps: [{ name: 'crm_enterprise', navigation: [{ id: 'group_service', type: 'group', children: [] }] }] } }, + { manifest: { id: 'com.example.crm-core', apps: [{ name: 'crm_enterprise', navigation: [{ id: 'group_service', type: 'group', children: [] }] }] } }, { manifest: { - id: 'crm_service', + id: 'com.example.crm-service', navigationContributions: [ { app: 'crm_enterprise', group: 'group_service', items: [{ id: 'nav_case', type: 'object' }] }, ], @@ -810,7 +810,7 @@ describe('validateTranslationReferences — contributed navigation (#18203)', () }, ]; const ownerBody = { - id: 'crm_core', + id: 'com.example.crm-core', apps: [{ name: 'crm_enterprise', navigation: [{ id: 'group_service', type: 'group', children: [] }] }], translations: localeKeys('nav_case'), }; @@ -929,7 +929,7 @@ describe('validateTranslationReferences — objectExtensions-injected surfaces ( */ it('reads an extension declared by a SIBLING package of the same artifact', () => { const ownerBody = { - id: 'crm_core', + id: 'com.example.crm-core', objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], translations: [{ 'zh-CN': { objects: { crm_lead: { fields: { sla_tier: { label: 'SLA 等级' } } } } } }], }; @@ -937,7 +937,7 @@ describe('validateTranslationReferences — objectExtensions-injected surfaces ( { manifest: ownerBody }, { manifest: { - id: 'crm_service', + id: 'com.example.crm-service', objectExtensions: [{ extend: 'crm_lead', fields: { sla_tier: { type: 'text' } } }], }, }, @@ -1097,7 +1097,7 @@ describe('validateTranslationReferences — an app a package contributes into wi }; /** The contributor package's own assembled body: contributions, translations, no apps. */ const contributorBody = (bundleApps: Record) => ({ - id: 'crm_service', + id: 'com.example.crm-service', navigationContributions: [contribution], translations: [{ 'zh-CN': { apps: bundleApps } }], }); @@ -1109,7 +1109,7 @@ describe('validateTranslationReferences — an app a package contributes into wi }); const ownerEntry = { manifest: { - id: 'crm_core', + id: 'com.example.crm-core', apps: [{ name: 'crm_enterprise', navigation: [{ id: 'group_service', type: 'group', children: [] }] }], }, }; @@ -1126,7 +1126,7 @@ describe('validateTranslationReferences — an app a package contributes into wi it('accepts it on the single-`defineStack` shape, contributions on the stack\'s own `manifest`', () => { const findings = validateTranslationReferences({ - manifest: { id: 'crm_service', navigationContributions: [contribution] }, + manifest: { id: 'com.example.crm-service', navigationContributions: [contribution] }, translations: [{ 'zh-CN': { apps: { crm_enterprise: { navigation: { nav_case: { label: '个案' } } } } } }], }); expect(findings).toEqual([]); @@ -1185,7 +1185,7 @@ describe('validateTranslationReferences — an app a package contributes into wi */ it('keeps contributed ids per app', () => { const body = { - id: 'crm_service', + id: 'com.example.crm-service', navigationContributions: [contribution, { app: 'ops_console', items: [{ id: 'nav_ops', type: 'dashboard' }] }], translations: [{ 'zh-CN': { apps: { ops_console: { navigation: { nav_case: { label: '个案' } } } } } }], }; @@ -1197,7 +1197,7 @@ describe('validateTranslationReferences — an app a package contributes into wi it('resolves the app even when no contributed item carries an id, and says so', () => { const body = { - id: 'crm_service', + id: 'com.example.crm-service', navigationContributions: [{ app: 'crm_enterprise', items: [{ type: 'object', objectName: 'crm_case' }] }], translations: [{ 'zh-CN': { apps: { crm_enterprise: { navigation: { nav_case: { label: '个案' } } } } } }], }; diff --git a/packages/services/service-package/README.md b/packages/services/service-package/README.md index 28ff8597aa5..b8f13bbac56 100644 --- a/packages/services/service-package/README.md +++ b/packages/services/service-package/README.md @@ -38,7 +38,7 @@ const packages = kernel.getService('package')!; await packages.publish({ manifest: { - id: 'crm', + id: 'com.example.crm', type: 'app', version: '1.2.0', name: 'CRM Package', @@ -51,9 +51,9 @@ await packages.publish({ }, }); -const latest = await packages.get('crm'); // defaults to 'latest' +const latest = await packages.get('com.example.crm'); // defaults to 'latest' const all = await packages.list(); -await packages.delete('crm', '1.0.0'); +await packages.delete('com.example.crm', '1.0.0'); ``` ## Key Exports diff --git a/packages/spec/prompts/create-new-project.md b/packages/spec/prompts/create-new-project.md index f9f50fc764a..1b035fab21d 100644 --- a/packages/spec/prompts/create-new-project.md +++ b/packages/spec/prompts/create-new-project.md @@ -79,7 +79,7 @@ my-app/ export default defineStack({ manifest: { - id: 'com.example.my_erp', + id: 'com.example.my-erp', version: '1.0.0', type: 'app', name: 'My ERP App', From 1d4c99bcbdf082c937d06157d488d8ac191939b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 18:24:29 +0000 Subject: [PATCH 19/19] test: the constant-carried manifest id the new pattern refuses moves to reverse-domain `protocol.package-publish-audit-rows.test.ts` declared `const PKG = 'pkg_helpdesk'`, which reaches `manifest: { id: PKG, namespace: NS }` on the `registry.getPackage` stub and is refused by `MANIFEST_ID_PATTERN`. The value becomes `com.example.helpdesk`. Every use reads the constant -- the three `toContain(PKG)` assertions included -- so each case keeps its meaning; the sibling assertion on the namespace prefix (`helpdesk_`) is unaffected, since the new value does not contain it. Verified against the BUILT `ManifestSchema.shape.id` after a spec build, with a lit control on the judge: 'pkg_helpdesk' refused, 'com.example.helpdesk' accepted. Claude-Session: https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE Co-authored-by: Claude --- .../src/protocol.package-publish-audit-rows.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts b/packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts index 24aa5020dd6..e4db9ffeff4 100644 --- a/packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts +++ b/packages/metadata-protocol/src/protocol.package-publish-audit-rows.test.ts @@ -369,7 +369,7 @@ const viewBody = (name: string, label: string, extra: Record = }); const ORG = 'org_alpha'; -const PKG = 'pkg_helpdesk'; +const PKG = 'com.example.helpdesk'; /** Audit rows for one operation, in write order. */ const opRows = (h: Harness, operation: string) =>