Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .changeset/17534-manifest-id-reverse-domain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
---
"@objectstack/spec": minor
"@objectstack/cli": minor
"create-objectstack": minor
---

feat(spec)!: `manifest.id` enforces the reverse-domain rule its registry face already had (#17534)

<!-- adr-0087: registered manifest-id-reverse-domain-required -->

**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.<project-name>` 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.
12 changes: 6 additions & 6 deletions content/docs/references/api/package-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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) |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/kernel/manifest.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/kernel/package-registry.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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) |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/kernel/package-upgrade.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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) |
Expand Down
33 changes: 30 additions & 3 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' } } }],
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading