Repository navigation
Commit 038d4c9
Fixes #22877
Clause-②: yes
Widens: the accept set of the five by-name `/meta` doors (GET, DELETE,
`/history`, `/publish`, `/rollback` on `/meta/:type/:name`) by one query
parameter, `member`, and the four protocol requests behind them
(`GetMetaItemRequest`, `DeleteMetaItemRequest`,
`PublishMetaItemRequest`, `HistoryMetaItemRequest`) by one optional
field, `member`; and three exported surfaces, each by one optional
`member`: the last argument of `SchemaRegistry.getItem`,
`getArtifactItem`, `removeRuntimeShadow` and `removeOverlayEntry`
(`@objectstack/objectql`), the `MetadataMutationEvent` field
(`@objectstack/metadata-protocol`, also carried on the
`ClusterMetadataMutationPayload` a peer accepts), and the header on each
`SysMetadataRepository.listDrafts` entry.
Stage 2 of 2 of ruling 6116165299 (letter A, Q2 i). Stage 1 (PR #22954,
`5677add93f`) stores two locales of one `email_template` as two
`sys_metadata` rows (`bundle_member`); every by-name door was still
canonical-only. This PR lets each by-name door address one member, and
carries the four items stage 1 left (seat answers 6118224899, landing
record 6121513183).
The ruling, quoted:
> **Q2 → i:** `PUT` stays body-addressed. GET, DELETE, publish, history
and revert by name accept one member selector in their closed
query-parameter set (AGENTS.md route rule 5), named after the
discriminator so it cannot be read as the UI translation locale
(`?locale=` on `/meta` already means that); absent, it means the
canonical member, which is what the registry's bare-name read answers; a
wrong value is a located 400. This widens the doors' accept set:
`Clause-②: yes`, a contract-tier review before landing.
## What changed
### 1. The selector (`metadata-protocol`, `rest`, `spec`)
- **The name is `member`.** It is what the stored column holds
(`sys_metadata.bundle_member`), and it is type-neutral. It is not
`locale`, which on `/meta` already names the response translation locale
(`extractLocale`). The seat suggested `member`; nothing measured argued
for another name.
- **One reading, shared by every door:** `resolveBundleMemberSelector`
in `protocol.ts`, beside stage 1's member plumbing. Each of the five
protocol doors calls it once, right after its type fold: `getMetaItem`,
`deleteMetaItem`, `publishMetaItem`, `historyMetaItem`,
`rollbackMetaItem`.
- Absent means the canonical member. Naming the canonical value
(`en-US`) is the same request. The value is trimmed.
- Refused with a located 400 `VALIDATION_ERROR` (`param: 'member'`, the
message names the selector):
- a selector on a type with no discriminator (every type except
`email_template`);
- a value that is not a string;
- a blank value (never read as absent);
- a value longer than 100 characters, the declared `maxLength` of
`bundle_member` (pinned equal to `SysMetadataObject`).
- A well-formed member with nothing stored or shipped, measured per
door:
- GET answers it as absent (protocol `item: undefined`, REST 404);
- DELETE answers `reset: false`;
- publish answers `404 NO_DRAFT`;
- history answers `{ events: [] }`;
- revert answers `404 VERSION_NOT_FOUND`.
- **The requests carry it.** The REST door builds each typed request
literal against the spec request shapes (`TransportScopedMetaRequest`).
So `GetMetaItemRequestSchema`, `DeleteMetaItemRequestSchema`,
`PublishMetaItemRequestSchema` and `HistoryMetaItemRequestSchema` gain
one optional `member` with one shared describe (cross-lane
`domain:spec`). `rollbackMetaItem` has no declared request (the door
calls it through a cast), so none was added.
`authorable-surface/api.json` and
`content/docs/references/api/protocol.mdx` are regenerated.
### 2. The five routes' closed query sets (`rest`, AGENTS.md route rule
5)
None of the five routes declared a closed set before. Each set below is
measured from the handler's own read points and from the helpers it
calls, and each route now opens with `refuseUnknownQueryParams`, ahead
of its arity gate:
| route | closed set | read points |
|:---|:---|:---|
| `GET /meta/:type/:name` | `layers`, `state`, `preview`, `package`,
`locale`, `member` | `wantsMetaItemLayers`; the two admitted draft
switches; `metaItemPackageBinding` and `serveMetaItemLayered`; `locale`
a frame down (`extractLocale` behind the cached validator and every
translation) |
| `DELETE /meta/:type/:name` | `state`, `dropStorage`, `member` | the
draft discard and the opt-in teardown |
| `GET …/history` | `sinceSeq`, `limit`, `member` | the declared paging
pair |
| `POST …/publish` | `package`, `member` | `metaItemPackageBinding` |
| `POST …/rollback` | `toVersion`, `member` | the query fallback after
the body |
- `member` joins each route's `refuseRepeatedQueryParams` list too.
- One reading of the parameter, `metaMemberSelector`, is shared by the
five handlers. It passes a non-string value through, so the protocol
refuses it; it is never dropped.
- A GET naming a member bypasses the cached arm, because
`getMetaItemCached` takes no member.
- `?layers=` together with `?member=` is refused. The layered view reads
no member, and answering with the canonical member's layers would be the
silent wrong answer.
- In-repo callers were measured, and none sends a parameter outside
these sets: `@objectstack/client` and `objectui` at the pinned
`9646e2ed2b` (`DraftChangesPanel`, `ResourceEditPage`, `client.meta.*`).
### 3. The registry's bare-name read (`objectql`
`SchemaRegistry.getItem`)
- A bare-name read of a discriminated type now answers the **canonical**
member, wherever it sits in the tier order (overlay, then prefer-local,
then the first composite).
- Before, the first tier that held any member won, and canonical-first
applied only inside that tier. So a `zh-CN`-only overlay answered a read
that named no member. This was the half-false premise recorded in
6118224899.
- A bundle with no canonical member still answers its first member, as
before.
- `getItem` and `getArtifactItem` take an optional `member` (a
discriminator value), which narrows every tier to that member.
### 4. `removeRuntimeShadow` on a by-name reset (stage 1 finding 1)
- `removeRuntimeShadow(type, name, member?)` and
`removeOverlayEntry(type, name, member?)` address one member when one is
named. With no member, they keep the group sweep, the shape
`unregisterItem` takes for a withdrawal by name.
- `restoreArtifactRegistryView` names the member on every tier. A
MetadataService baseline of another member is not this member's
baseline: that service keys a type's items by name alone.
- **Found by the dogfood, fixed here:** a member's shadow is lifted when
the bundle ships *any* artifact, not only an artifact of that member.
- A hydrated overlay of a member the package does not ship wears the
bundle's artifact envelope (`_packageId`, `_provenance` grafted by
`mergeArtifactProtection`). So `removeOverlayEntry` refuses it as code.
- On the showcase, a `zh-CN` reset therefore kept sending the reset
wording until the next boot.
- It is pinned in the objectql harness against a versioned package, so
artifacts carry the envelope.
### 5. Package-batch doors and the cluster fan-out (stage 1 finding 2)
- `SysMetadataRepository.listDrafts` headers carry the stored `member`
when it is non-canonical. The protocol's public `listDrafts` serves its
declared header without it.
- **`publishPackageDrafts`:**
- each draft is promoted as its member;
- the pre-publish capture reads that member's active row;
- the revert plan item records `member`;
- a failed batch names the causal draft by member.
- **`discardPackageDrafts`** (and `revertStoredPackage`, through
`discardDraftInItsScope`) discards each draft as its member.
- **`revertCommit`** reads, deletes, restores, re-binds and heals each
plan item as its member. The revert's own plan carries the member too.
`listCommits` serves the declared four keys.
- **The cluster fan-out:**
- `MetadataMutationEvent` gains an optional `member` (present only for a
non-canonical member);
- save, publish, delete, rollback and revert stamp it;
- `applyRemoteMetadataMutation` re-reads, re-binds and heals that
member's row.
## Pins (real code)
`packages/objectql/src/environment-overlay-email-locale-restart.test.ts`,
stage 1's harness (real `ObjectQL` and protocol; a restart is a new
engine over the same stores). It is now a versioned package that also
ships an `en-US`-only template:
- GET: each member by name; absent and `en-US` both answer the canonical
member; trimmed.
- GET: a member nobody stored answers the code layer's member; one that
no layer holds answers nothing.
- DELETE by member: `zh-CN` reset → its row alone goes, and the `en-US`
overlay stays the registry's entry, at once and after a restart.
- DELETE of a member no artifact ships: its overlay leaves the registry
and the layered list, and the canonical overlay stays.
- DELETE with no member (the reverse): the canonical member is reset,
and the `zh-CN` overlay stays registered.
- publish by member: a `zh-CN` draft goes live. CONTROL: the bare
publish answers `NO_DRAFT`.
- history and revert by member:
- one shared counter, three distinct versions;
- each member sees its own events;
- a revert to another member's version answers `404 VERSION_NOT_FOUND`.
- CONTROL: a bare-name revert reverts the canonical member to its own
version and leaves `zh-CN`.
- A wrong selector is a located 400 on all five doors (a `view` type,
blank, 101 characters), and the store bytes are unchanged.
- `publishPackageDrafts`, `revertCommit` and `discardPackageDrafts` with
a two-member bundle where `zh-CN` alone has a draft. The commit timeline
and the draft list keep their declared keys.
- The cluster fan-out (two control-plane replicas over one store, a
delivering bus): the peer converges on the member that changed, and a
member reset heals only that member.
- The six stage 1 pins, unchanged.
`packages/objectql/src/registry-i18n-bundle-key.test.ts`:
- the bare-name read answers the canonical member with a `zh-CN`-only
overlay;
- named-member reads, artifact included;
- the member-addressed heal, including the shipped-bundle and
unshipped-bundle cases; naming no member keeps the sweep.
`packages/metadata-protocol/src/protocol.bundle-member-selector.test.ts`:
the one reading — every refusal, the trimmed pass, and the length bound
pinned equal to the declared column.
`packages/rest/src/rest-server-meta-member-selector.test.ts`, 30 cases.
Per route:
- **refusal:** a misspelled selector is answered with status, nested
`error.code` and the exact located message, and the protocol is never
called; a repeated selector is refused;
- **preservation:** the exact request the protocol received, the member
and every measured name included, `locale` too; GET bypasses the cache
with a member and keeps it without one;
- the layered refusal;
- a CONTROL that no selector sends no `member` key;
- the value refusals from a **real** protocol over an engine that is
never reached.
`packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts`,
stage 1's showcase case extended, on a real SQLite file and real
`sendTemplate`:
- `?member=zh-CN` reads the `zh-CN` wording;
- `DELETE ?member=zh-CN` resets `zh-CN` alone;
- after a cold boot, `en-US` keeps the admin's wording and `zh-CN` falls
to the `en-US` floor (the showcase ships `en-US` only);
- `?member=zh-CN` is then 404;
- a misspelled selector, and a selector on a type with no members, are
located 400s.
This case also measures that the showcase's `/meta` item read is served
by `RestServer`'s route, which reads the selector.
## Evidence
Every heavy run went through `scripts/pm/os-verify-lock.sh`, and each
read its `VERDICT command-exit 0` line. Exit codes were captured before
any pipe. The final head is `2b882e6774`, which merges `origin/main`
`df7a137923`; the incoming commits touch none of this PR's files.
**Gates.**
- The derived union is `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at `2b882e6774`: **118 commands, 118
exit 0**.
- Reconciled with `--ran` (exit codes recorded): `118 derived, 118 run,
0 NOT-MEASURED, 0 UNRUN`.
- The lead list of 86 commands at `0ede0f8ab3` is a subset of it.
- An earlier round at `12b4b5047f` had `check:skill-examples` and
`check:dual-build-cjs-loads` at exit 3 (PREREQUISITE NOT MET). After a
full packages build (`turbo`, 72 tasks, exit 0) both pass, and the final
round has them green.
**Suites.**
- `objectql`, full (`vitest run`): `Test Files 407 passed (407)`, `Tests
7955 passed`. This ran before the last registry commit (`2b882e6774`).
- After that commit:
- every `objectql` test file that names `removeRuntimeShadow`,
`removeOverlayEntry` or `email_template`: 13 files, `246 passed`;
- the restart harness and the registry bundle file: 2 files, `40
passed`.
- `metadata-protocol`, full, at `12b4b5047f`: `Test Files 228 passed | 3
skipped (231)`, `Tests 28196 passed | 19 skipped`.
- `rest`, full, at `00886e17d7` (the merge touched no `rest` file):
`Test Files 279 passed (279)`, `Tests 5960 passed | 327 skipped`. After
the merge, 6 targeted files (the new pins, the closed-set and
multiplicity files, absent-404, history/diff door, publish package
scope): `141 passed`.
- `spec`: `protocol.test.ts` and `type-alias-convention.pin.test.ts`,
`187 passed`.
- Typecheck exit 0 for `spec`, `metadata-protocol`, `objectql` (with
`check:test-typecheck`), `rest` (with `check:test-typecheck`) and
`dogfood`.
- `dogfood`: `email-template-two-locales-survive-boot.dogfood.test.ts`,
`4 passed (4)`, after `objectql` was rebuilt at `2b882e6774`.
- `pnpm --filter @objectstack/spec check:generated` named `check:docs`
stale; `gen:docs` regenerated one page. `check:authorable-surface` wrote
the 4 new request keys into `authorable-surface/api.json`.
**ESLint, a proven narrowing.**
- `pnpm exec eslint --no-inline-config --format json` over the 11
changed `.ts` files: 11 files linted, 0 errors, 0 warnings.
- The population is `eslint.config.mjs`'s `files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`. No file
was reported ignored.
- That config never enables type-aware linting (no
`parserOptions.project`, as its own header records), so this diff cannot
move the verdict on any file it does not touch.
- The repo-wide `pnpm lint` is CI's.
## Ablations
Each ablation went through `scripts/ablation-replace.mjs` (wrap mode, on
committed HEAD; anchor count 1 then 0, blob changed, restored blob equal
to HEAD, `git diff HEAD` empty). A `metadata-protocol` mutation was
rebuilt, because `objectql` resolves it through `dist`, a
`KNOWN_UNALIASED_TEST_IMPORTS` pair. `ablation-dist-preflight` was run
present on the mutation leg and `--absent` on the restore leg.
1. **`rollbackMetaItem` without the member** (the `restoreVersion`
member option replaced by an expression that is `undefined` at runtime,
with the marker `__ABLATION_22877_ROLLBACK_MEMBER_DROPPED`). The restart
harness went `1 failed | 17 passed`. Only the member-revert pin was red
(`No history row at version 2 … (its canonical member)`); the bare-name
revert CONTROL stayed green. Restored: `18 passed`.
2. **The registry tier-order change** (`getItem`'s bare-name branch back
to the first tier's first member, with the marker
`__ABLATION_22877_REGISTRY_TIER_ORDER`; `objectql` source, so no build).
The registry bundle file and the harness went `1 failed | 38 passed`.
Only `a bare-name read answers the canonical member when only a second
locale is overlaid` was red. The harness's bare-name pins stay green
under this ablation, because `getMetaItem` answers the canonical
artifact before it asks the registry, which is a second layer by design.
Restored: `39 passed`.
3. **The registry heal without the member**
(`removeRuntimeShadow(singular, name, memberKey)` with `memberKey`
replaced). The harness went `3 failed | 15 passed`: DELETE by member,
DELETE with no member (the reverse), and the cluster member reset were
red, while stage 1's store-level reset pin stayed green. Restored: `18
passed`.
4. **The package-batch doors without the member.**
- With the publish promotion's member dropped, the batch answered
`success: false`, `outcome: refused`, `failed: [{ code: NO_DRAFT, error:
No pending draft exists for email_template/acme.welcome — nothing to
publish. }]`. That is the measured shape behind the changeset's
sentence; both the publish and the revert pins were red.
- With the discard's member dropped, the discard answered success while
the `zh-CN` draft row stayed.
- Both were restored, and the dist was proven absent.
These four ablations ran at `fdf01eb9e9`, the head before `2b882e6774`
(the versioned harness package, the unshipped-member pin and the
bundle-level shadow test), when the restart harness had 18 cases. The
code each one removed is unchanged since. At `2b882e6774` the harness
has 19 cases, all green.
## Deviations, stated
- **The value refusal's envelope.**
- The selector's value is judged by the protocol, so the REST door
renders that refusal through its one error door, `handleRouteError`. It
comes out flat: `body.code` is `VALIDATION_ERROR`, and `body.error` is
the sentence.
- The route's own recognition refusal of a misspelled name is nested:
`body.error.code`.
- This is the existing split for every refusal the protocol raises on
these routes (`check:route-envelope`, the repo-wide ratchet). The GET
handler's own note says why converting part of it here would mint a new
divergence.
- The code is the same in both: `VALIDATION_ERROR`.
- The `?layers=` plus `?member=` refusal is raised by the GET handler
itself, not by the protocol. It is thrown into the same
`handleRouteError`, so it is flat too; that is the handler's only
pre-existing shape for a 400 of its own, so it mints no new dialect.
- **Response contracts are unchanged.**
- `GET /meta/_drafts` and `GET /packages/:id/commits` do not serve
`member`. The ruling widens the doors' accept set, not these responses.
- Two drafts or plan items of one name in two languages therefore read
as two entries with the same `type` and `name` there.
- **`removeRuntimeShadow` semantics, named member.** The artifact test
stays at the level of the name: a packaged artifact of the bundle
remains, as the method's contract always said, so it does not have to be
an artifact of that member. Section 4 says why.
- **File surface.** Every edit is inside the claim's listed files, plus:
- the four test files and the dogfood case the claim names;
- `protocol.lookup-artifact-item-call-sites.test.ts`, whose census
records the re-scoped artifact-lookup site and the definition's new
parameter;
- two regenerated artifacts: `authorable-surface/api.json` and the
protocol reference page.
## Acceptance notes
- **The runtime dispatcher's `/meta` item read**
(`packages/runtime/src/domains/meta.ts`) does not read `?member=`. A
host served by the dispatcher alone would answer `GET …?member=zh-CN`
with the canonical member, because that transport has no closed query
set at all. The showcase is served by `RestServer` (measured by the
dogfood case above). This is outside the claim's surface
(`packages/runtime`), and is not measured on a dispatcher-only host.
- **By-name doors not in the ruling's list** stay canonical-only:
`/diff`, `/layers`, `/audit`, `/published` and `/references`.
- **The MetadataService** (`MetadataManager`) keys a type's items by
name alone, so for a bundle it holds one member. `getMetaItem` now takes
its answer only when it is the addressed member; the service itself is
unchanged.
- **The environment kernel's write-through.** On an environment kernel,
a save and a peer's convergence do not write a non-object overlay
through to the registry (#6602, by design). The cluster pin therefore
boots control-plane replicas.
- **The untranslated `bundle_member` keys** stay owed to `area:i18n`, as
the claim says.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 275ad42 commit 038d4c9
14 files changed
Lines changed: 1385 additions & 105 deletions
File tree
- .changeset
- content/docs/references/api
- packages
- metadata-protocol/src
- objectql/src
- qa/dogfood/test
- rest/src
- spec
- authorable-surface
- src/api
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
728 | 728 | | |
729 | 729 | | |
730 | 730 | | |
| 731 | + | |
731 | 732 | | |
732 | 733 | | |
733 | 734 | | |
| |||
1345 | 1346 | | |
1346 | 1347 | | |
1347 | 1348 | | |
| 1349 | + | |
1348 | 1350 | | |
1349 | 1351 | | |
1350 | 1352 | | |
| |||
1875 | 1877 | | |
1876 | 1878 | | |
1877 | 1879 | | |
| 1880 | + | |
1878 | 1881 | | |
1879 | 1882 | | |
1880 | 1883 | | |
| |||
2319 | 2322 | | |
2320 | 2323 | | |
2321 | 2324 | | |
| 2325 | + | |
2322 | 2326 | | |
2323 | 2327 | | |
2324 | 2328 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
68 | 68 | | |
69 | 69 | | |
70 | 70 | | |
71 | | - | |
| 71 | + | |
72 | 72 | | |
73 | 73 | | |
74 | 74 | | |
75 | 75 | | |
76 | 76 | | |
77 | 77 | | |
78 | 78 | | |
79 | | - | |
80 | | - | |
| 79 | + | |
| 80 | + | |
81 | 81 | | |
82 | 82 | | |
83 | 83 | | |
| |||
0 commit comments