You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 0ecc2c4
Browse filesBrowse the repository at this point in the historyBrowse files
feat(metadata-protocol): ensure door for a read-only base's paired customization package (#28512)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} -->
_Requested by **Sam** · [project
thread](https://claude.ai/code/project/chan_01LjZwNRFiG2fkp8SNf6rHNx?thread=cmsg_01LjZwNRFiG2fkp8SNf6rHNxSrmC56pjkzCzxHmbpYUu4i)_Fixes#28407
Clause-②: yes (narrowing)
The new route `POST /api/v1/packages/:id/ensure-customization` and its
two schemas (`EnsureCustomizationPackageRequestSchema`,
`EnsureCustomizationPackageResponseSchema`) widen the surface, and `POST
/api/v1/packages` and the duplicate door's target narrow theirs: a
pair-shaped id (`BASE.custom`) is refused unless its manifest declares
the pair namespace and a dependency on the base.
Metadata-protocol: added
Skills-docs: none — no skill under `skills/**` teaches the `/packages`
doors or `client.packages`; the docs half is in this diff (the generated
API reference for the two schemas, and the packages row of
`content/docs/api/client-sdk.mdx`).
## 维护者速读
- 改了什么:新增 `POST /api/v1/packages/:id/ensure-customization` 与 SDK
`client.packages.ensureCustomization`,为已安装的只读基础包答出它的配对定制包
`BASE.custom`,缺则经安装原语创建(命名空间 `BASE_NS_custom`,依赖基础包
`^MAJOR`);安装门、安装原语和复制门拒收缺这两项的配对形 id。
- 为什么改:ADR-0146 D3 的配对定制包此前没有创建入口,手工安装会从 id 派生出 `custom`
命名空间,也不声明对基础包的依赖。
- 风险与回滚:新路由是纯新增;收窄只拦 id 形如 `BASE.custom`
且缺显式命名空间或缺依赖的安装与复制,导出的完整配对照常安装。回滚即 revert 本 PR,无数据迁移。
-
先看:`packages/metadata-protocol/src/protocol-customization-package.ts`(`ensurePairOnce`
与同一配对的单飞等待)、`packages/runtime/src/domains/packages.ts`(新门的鉴权顺序与 `POST
/packages`
的配对拒收)、`packages/metadata-protocol/src/protocol-package-install.ts`(安装原语里的同一谓词)。
- 两分钟验证:`pnpm --filter @objectstack/runtime exec vitest run
src/domains/packages-ensure-customization.test.ts`,预期 `Tests 35 passed
(35)`。
- 需要你做的:无,席位落地。
## What changes
- **`@objectstack/spec`** (`api/package-lifecycle.zod.ts`):
`EnsureCustomizationPackageRequestSchema` (strict, one key
`basePackageId`) and `EnsureCustomizationPackageResponseSchema` (six
required fields: `basePackageId`, `packageId`, `namespace`,
`dependencyRange`, `created`, `writable`), with their `z.input` /
`z.infer` aliases. No existing shape moves. The authorable surface gains
exactly these 7 keys (`packages/spec/authorable-surface/api.json`):
`EnsureCustomizationPackageRequest:basePackageId`, and
`EnsureCustomizationPackageResponse:` `basePackageId`, `created`,
`dependencyRange`, `namespace`, `packageId`, `writable`; the JSON-schema
manifest gains the two schema names. That move is why the kind is
`added` and spec is `minor` (see Acceptance notes).
- **`@objectstack/metadata-protocol`**: a new module
`protocol-customization-package.ts` holds the body of the new class
method `ensureCustomizationPackage` (the protocol.ts split pattern) and
the predicate `customizationPairManifestRefusal`, exported from the
package index.
- Create: the pair id, namespace and range come from the N-08 helpers in
`@objectstack/spec/kernel`, never a local copy. The pair is installed
through `this.installPackage` with manifest id `BASE.custom`, name
`BASE_NAME (custom)`, version `0.1.0`, type `module`, explicit
`namespace`, and `dependencies` naming the base at `^MAJOR`.
- Idempotent: an existing pair that declares the namespace and a
dependency on the base is answered unchanged (`created: false`, its own
declared range). One that lacks either is refused `409` and never
adopted or rewritten.
- Concurrency: calls for the same pair on the same registry are
single-flighted. A call that arrives while another is creating the pair
waits for it to settle and then judges the registry afresh, so it never
answers for a pair whose persist then fails and is undone.
- Refusals, each leaving no package: `400` malformed request or base
that is itself a pair; `404` base not installed; `409` writable base,
base with no namespace, base namespace too long for the `_custom` suffix
(nothing truncated or hashed), non-SemVer base version, or an installed
pair lacking namespace or dependency; `422 NAMESPACE_CONFLICT` when
another package owns the pair namespace (the registry's code), including
an owner the registry would read as a co-owner.
- The predicate: an install whose id is pair-shaped must declare the
explicit pair namespace (the exact one when the base is installed) and a
dependency on the base, or it is refused `400` naming the ensure door.
`installPackage` asks it (every in-process caller, and the duplicate's
copy record), and `duplicatePackage` asks it before reading a row.
- **`@objectstack/runtime`**: the route in `domains/packages.ts`, gated
as the install door is: `manage_metadata`, then the walled refusal (only
the platform operator under `isolated` / `group`), then the closed query
set (empty), then a body check (no body or `{}`; a body with keys is
`400`, never ignored). `201` when created, `200` when present;
`DEPENDENCY_MISSING` keeps its facts. `POST /packages` asks the
predicate before either install limb. The route is mounted in
`dispatcher-plugin.ts` and has a ledger row in `route-ledger.ts`
(`disposition: 'sdk'`, client `packages.ensureCustomization`, response
schema named); the census sentence reads 114.
- **`@objectstack/client`**: `packages.ensureCustomization(baseId)`
returns `EnsureCustomizationPackageResponse`;
`content/docs/api/client-sdk.mdx` counts 18 packages methods.
## Tests
- `packages/runtime/src/domains/packages-ensure-customization.test.ts`
(new, 35 tests) over a real `ObjectStackProtocolImplementation` and
`SchemaRegistry`, with a recording `sys_packages` publish double. It
covers: create `com.acme.crm.custom` with namespace `crm_custom` (not
`custom`) and `^3`, `201`, parsed through the response schema, persisted
once, read back by `GET`; a code-provided base; re-ensure `200
created:false` with no write; an exported valid pair answered unchanged;
every refusal with its ADR-0112 envelope and no package or write left;
walled `isolated` and `group`, where the tenant admin gets `403` and the
operator `201`; `single` gives `201`; the capability gate; two real
concurrent calls give one `201`, one `200`, one write and one pair; the
persist-failure race; `POST /packages` on both arms (three refused
shapes, an exported pair that declares both installs, a non-pair id
untouched); an exported pair whose base is absent gets `422
DEPENDENCY_MISSING`; the install primitive refuses an in-process pair
manifest; the duplicate into a pair id is `400` before any row is read.
- Door-family sweeps gain the new door: walled refusal (fourteen doors),
capability gate, closed query, and the `501` probe in handle-typing.
- Spec conformance in `api/package-lifecycle.test.ts`: the strict
request refuses `namespace` and `dependencyRange`, and the response
capture parses and requires every member. Client: URL, method, no body,
URL-encoding and the answer; return-type precision pins
`EnsureCustomizationPackageResponse`.
- The card's two named tests stay green:
`packages/spec/src/kernel/namespace-prefix.test.ts` (10/10) and
`packages/metadata-protocol/src/protocol.duplicate-package-target-namespace.test.ts`
(13/13).
<details><summary>Ablations: 19 mutations, each red, each restored with
blob equal to HEAD</summary>
Run through `scripts/ablation-replace.mjs` in WRAP mode. Every leg
printed `ok mutation landed` and `ok restored: blob == HEAD … git diff
HEAD is empty`. The metadata-protocol legs rebuilt its dist first.
| id | mutation | red tests |
|---|---|---|
| A1 | ensure route: walled refusal ignored | walled 403/201 (isolated,
group), walled sweep (2) |
| A2 | ensure route: body check off | body carrying keys |
| A3 | ensure route: query check removed | query refused; closed-query
sweep (3) |
| A4 | ensure route: no 201 on create | 10, every create path |
| A5 | `POST /packages`: pair gate off | fallback arm, three refused
shapes (the composed arm stays refused by the install primitive) |
| A6 | ensure route: capability gate removed | own capability test;
capability sweep (2) |
| B1 | created pair namespace `custom` | 10 (the install primitive
refuses it, 400) |
| B2 | created pair range `>=0.0.0` | create, re-ensure |
| B3 | existing pair not recognised | re-ensure, exported pair, stray
pair, concurrency |
| B4 | writable base admitted | writable refusal |
| B5 | namespace owner check off | squatter, co-owner |
| B6 | single-flight wait removed | persist-failure race only |
| B7 | install primitive predicate off | in-process install refusal |
| B8 | duplicate predicate off | duplicate before any row |
| B9 | pair-of-pair sentence | base itself a pair |
| B10 | base pair-namespace check off | no namespace, too long |
| B11 | existing pair lack check off | stray pair |
| B12 | SemVer check off | non-SemVer base |
| B13 | not-installed check off | base not installed |
</details>
## Acceptance notes
- **Declaration measured.** `check-changeset-no-major`, driven with a
`pull_request` payload declaring `Metadata-protocol: none`, exits 1
because the two new schemas move
`packages/spec/authorable-surface/api.json` (the 7 keys above). With
`added` and `@objectstack/spec` at `minor` (metadata-protocol, runtime
and client `patch`) it exits 0, and `check-adr-0087-registration` exits
0, verifying `not-required (runtime-interface-only
packages/metadata-protocol/src/protocol.ts#ObjectStackProtocolImplementation)`.
- **OpenAPI: nothing to regenerate.**
`packages/spec/json-schema/openapi.json` is gitignored and its generator
(`build-openapi.ts`) emits only nine fixed contract schemas and no
`paths`. The served route section is built at serve time by
`packages/rest` from its route manager, so no committed artifact names a
route.
- **N-08 consequence.** `customizationBasePackageId` reads any id ending
in `.custom` as a pair, so a package named `com.acme.custom` is now the
pair of `com.acme` at the install doors and needs both declarations.
This follows from the ruled id rule, and is not new policy here.
- **Prerelease base, read and not tested.** A base at a prerelease
version gets `^MAJOR`, and ADR-0141 D2's check does not admit a
prerelease, so the pair's own install would be `422 DEPENDENCY_MISSING`
at the ensure door (the route keeps the facts).
- **Existing pair range.** The ensure door checks that an existing pair
declares a dependency on the base, not that its range admits the
installed base; ADR-0141 D2 judged that at the pair's install.
- **Concurrency scope.** The single-flight is per process and per
registry. Two nodes ensuring the same pair at once fall to the
`sys_packages` key; that was not measured. The plain two-call test is
green with or without the single-flight (B6 reddens only the
persist-failure race), which is why both tests exist.
- **Ablation evidence for the B legs.** `ablation-dist-preflight.mjs`
read exit 1 on the marker for every B leg, because esbuild folds the
planted `void "MARKER"` to `void 0`. The mutations did reach dist: every
B leg went red with the runtime source untouched, and the emitted guard
read `if (!base && (void 0, false))` for B13.
- **Outside the literal file surface.**
`packages/runtime/src/dispatcher-plugin.ts` (the mount),
`packages/runtime/src/route-ledger.ts` (row and census),
`packages/client/src/index.ts` and two client tests (the SDK the card
asks for), four door-family sweep tests, and
`content/docs/api/client-sdk.mdx`.
- **Serial with #28414.** `origin/main` had not moved from the base when
the gates ran, so there was nothing to rebase.
## Verification
<details><summary>Builds, lint and type checks</summary>
- `pnpm build` (full workspace, under the verify lock): `VERDICT
command-exit 0`, 116 tasks.
- `pnpm lint`: exit 0.
- Test-layer type checks (`scripts/check-test-typecheck.mts`): client 0,
runtime 0, spec 0. The runtime check first found one error in
`domains/packages.ts` (the predicate's lookup typed narrower than
`registry.getPackage`); the predicate now takes `(id) => unknown`.
</details>
<details><summary>Derived gates: 108 run, 108 exit 0</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 108 commands; each ran once with
stdin closed and was recorded as `COMMAND :: exit CODE`. `--ran` reports
`108 derived famil(ies) accounted for — 108 run, 0 NOT-MEASURED`. One
was red on first run, `check:route-ledger-census` (113 vs 114 rows), and
is fixed by its `--fix`. After the type fix, these were re-run on the
final source and exit 0: `check:dts-closure`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:type-check-debt`, `check:type-check-coverage`, `check:nul-bytes`,
`check:dispatcher-error-vocabulary`, `check:undeclared-dep-imports`,
`check:route-ledger-census`.
</details>
<details><summary>Dogfood battery</summary>
Once, on the final head `33590a408e75cead7e14e9d4ddcd7cdf38fcea97`,
after rebuilding the runtime dist: `bash scripts/pm/os-verify-lock.sh -c
"pnpm --filter @objectstack/dogfood test"` (no shard, no path).
- `os-verify-lock: VERDICT command-exit 0 · held the lock 1229s (20m29s)
· waited 0s`
- `Test Files 363 passed | 3 skipped (366)`
- `Tests 2926 passed | 28 skipped (2954)`
- No failures.
</details>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01QUs3BtJ6E3jM8dgVzLbpGi
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(metadata-protocol): an ensure door for a base's paired customization package (ADR-0146 D3)
9
+
10
+
Clause-②: yes (narrowing)
11
+
Metadata-protocol: added
12
+
13
+
The new route `POST /api/v1/packages/:id/ensure-customization`, with its request and response schemas (`EnsureCustomizationPackageRequestSchema`, `EnsureCustomizationPackageResponseSchema`), widens the surface, and `POST /api/v1/packages` and the duplicate door's target narrow theirs: a package id shaped like a pair (`<base>.custom`) is refused unless its manifest carries what the ensure door writes.
14
+
15
+
<!-- adr-0087: not-required (runtime-interface-only packages/metadata-protocol/src/protocol.ts#ObjectStackProtocolImplementation) The narrowing is a refusal added to the install primitive and the duplicate verb of this runtime class, and to the HTTP install door that fronts them: an install whose id is a paired customization package id must declare the explicit pair namespace and a dependency on the base. No Zod schema, spec contract or stored metadata shape changes, and no stored row is reread differently: a pair already in `sys_packages` replays at boot as stored. So `objectstack migrate meta` has nothing to rewrite. -->
16
+
17
+
**What it does.** The paired customization package of a read-only base holds the changes a customer makes to that base. `POST /api/v1/packages/:id/ensure-customization` answers that pair, `<base>.custom`, and creates it when it is absent. It is created through the same install primitive `POST /api/v1/packages` uses, with an explicit namespace (the base's namespace followed by `_custom`, so `crm` gives `crm_custom`), a dependency on the base at the major of the base's version (`^3` for `3.4.1`), version `0.1.0` and type `module`. The answer names the base, the pair, its namespace, its dependency range, whether this call created it (`201` when created, `200` when it was already there) and whether it is writable. Asking again changes nothing. Two calls at once leave one pair, and a call that arrives while another is creating the pair waits for it and answers from what it left.
18
+
19
+
**What it refuses, leaving nothing behind.** A base that is not installed (`404`). A base that is writable, because it is customized directly, a base that declares no namespace, a base whose namespace is too long to take the `_custom` suffix (nothing is truncated or hashed), and a base whose version is not SemVer (`409`). A base that is itself a pair (`400`). A pair namespace another package already owns (`422 NAMESPACE_CONFLICT`, the code the registry stamps for the same condition). An installed `<base>.custom` that lacks the namespace or the dependency (`409`): it is never adopted or rewritten. The caller is judged as at the install door: `manage_metadata` under `single`, and under `isolated` or `group` only the platform operator, so a tenant administrator gets `403`.
20
+
21
+
**The narrowing.**`POST /api/v1/packages`, the install primitive and the duplicate door now refuse, with `400`, a package whose id is a pair id but whose manifest lacks the explicit pair namespace or a dependency on the base. The refusal names the ensure door. An exported pair that declares both still installs, and the dependency check then judges its range against the base installed there.
What to do: nothing for an existing stack. A pair is created through the ensure door, or installed from an export that declares both its namespace and its dependency on the base.
const result =DiscardPackageDraftsResponseSchema.parse(data);
@@ -117,6 +119,33 @@ const result = DiscardPackageDraftsResponseSchema.parse(data);
117
119
|**error**|`string`| ✅ | Why copying it failed. |
118
120
119
121
122
+
---
123
+
124
+
## EnsureCustomizationPackageRequest
125
+
126
+
### Properties
127
+
128
+
| Property | Type | Required | Description |
129
+
| :--- | :--- | :--- | :--- |
130
+
|**basePackageId**|`string`| ✅ | Id of the installed base whose paired customization package is ensured (`com.acme.crm` → `com.acme.crm.custom`). The base must be installed and read-only — a writable base is customized directly — and must not itself be a customization package. |
131
+
132
+
133
+
---
134
+
135
+
## EnsureCustomizationPackageResponse
136
+
137
+
### Properties
138
+
139
+
| Property | Type | Required | Description |
140
+
| :--- | :--- | :--- | :--- |
141
+
|**basePackageId**|`string`| ✅ | The base the pair customizes. |
142
+
|**packageId**|`string`| ✅ | The paired customization package: `<basePackageId>.custom`. |
143
+
|**namespace**|`string`| ✅ | The pair's namespace: the base's namespace followed by `_custom` (`crm` → `crm_custom`), set explicitly and never derived from the pair's id. A new object authored into the pair is named `<namespace>_<short>`. |
144
+
|**dependencyRange**|`string`| ✅ | The range the pair declares on its base in `manifest.dependencies`. A created pair declares `^<major>` of the base's version at creation; an existing pair answers the range it declares, unchanged. |
145
+
|**created**|`boolean`| ✅ | True when this call created the pair; false when the pair was already installed and was returned unchanged. |
146
+
|**writable**|`boolean`| ✅ | Whether the pair is a writable package — the verdict the package read doors serve as `writable`. A created pair is writable; an existing pair delivered by code or an installed artifact is not, and its items are authored where it is built. |
|[Automation Protocol](/docs/references/automation)| 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
26
26
|[Data Protocol](/docs/references/data)| 31 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
0 commit comments