Skip to content

Commit 8f2e808

Browse files
feat(meta): the /meta item read serves the version token, If-None-Match: * pins a first write, and the 409 carries currentVersion (#22126)
Fixes #22114 The client half, objectstack-ai/objectui#11773 (PR objectstack-ai/objectui#11826), is unlocked by the published release that carries this change, not by this merge. Clause-②: yes (narrowing) `yes` is for the widened read member, request header and conflict field. The `(narrowing)` arm is for two request shapes on `PUT /meta/:type/:name` that were answered `200` and are now refused `400 VALIDATION_ERROR`: an `If-None-Match` value other than `*`, and `If-None-Match` beside `If-Match`. The changeset carries the **BREAKING** line with the remedy (send `*` alone, never beside `If-Match`), and the ADR-0087 disposition `not-required (no-migration-prescription)`: no key, export, response field or stored shape moves, no first-party sender puts `If-None-Match` on a `PUT`, and which precondition the client meant is a choice no conversion entry can derive. The level stays `minor`, because `.changeset/pre.json` is absent on `origin/main` (`51290bca2`). ## What changes ADR-0008 says clients pass `If-Match` with the version they read, and a client can send only a token it was served. Before this PR, only a save receipt served one. So the first save after a load could not be pinned, and two editors who each loaded and saved once overwrote each other. This PR closes the three gaps the card names, at the `/meta` door. Spec end and runtime end land together. 1. **The read serves the token.** `GET /meta/:type/:name` now carries a `version` member, declared on `GetMetaItemResponseSchema`. It is the keyed token of the stored row that a save to this item compares against, at the read's scope (the caller's organization partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). It comes from the same producer as the save receipt's `version` and covers the same row, so a read after a save serves the receipt's token byte for byte. `null` means no stored row is there, so the next save is a create. 2. **"Expect no row" can be said.** `If-None-Match: *` on `PUT /meta/:type/:name` saves only where no row of the target lifecycle exists. Once a row exists it is refused `409 METADATA_CONFLICT`. This is the `parentVersion: null` pin that `SaveMetaItemRequestSchema` already declared, now reachable over HTTP. A save with neither header is last-writer-wins, as before. 3. **The conflict body carries the current version as data.** The 409 of the `/meta` item write doors now carries `currentVersion` beside today's unchanged sentence: the token the sentence names, or `null` when no row of the target lifecycle exists. The body shape is declared as `MetadataConflictErrorSchema`, and the SDK exposes the field as `err.details.currentVersion` with no client change. ## Where each half lands | Package | Change | |---|---| | `@objectstack/spec` | `GetMetaItemResponseSchema.version` (string, null or absent); `MetadataConflictErrorSchema` plus its two type aliases; the `parentVersion` describe now names `If-None-Match: *`; the receipts' `version` describes are corrected (below). Generated artifacts regenerated (authorable surface, json-schema manifest, api-surface, export origins, declaration map, reference docs, strictness-ledger counts). | | `@objectstack/metadata-protocol` | One head read, `storedHeadAt`, is shared by the save door's token check and the read's `version` (`readVersionToken`), so no second token derivation exists. `getMetaItem` serves `version` on the draft branch and on the active branch. `metadataConflictRefusal`, the single builder all four item doors (save, publish, rollback, reset) answer through, now also sets `currentVersion`. | | `@objectstack/rest` | `PUT /meta/:type/:name` reads its pin through `metaSavePreconditionPin`: `If-Match` as before, `If-None-Match: *` as the expect-no-row pin. A comment on the cached arm records why its ETag is not the token. | | `@objectstack/types` | One `METADATA_CONFLICT` arm in `structuredCodeAnswer` serializes `currentVersion` into the flat ADR-0112 body. This is **outside the claim's declared file surface**, and the reason is the repo's own rule: `error-response.ts` says a new bespoke code arm "belongs in `structuredCodeAnswer`, where both doors read it", and that function moved to `@objectstack/types`. The arm keys on the code and on a stated `currentVersion`, so a `METADATA_CONFLICT` that states none keeps today's body byte for byte. | No objectui change. No change to `packages/client` either: the read type is the spec's own `GetMetaItemResponse`, and the flat conflict body already reaches `err.details`. ## Decisions taken, with the measurement behind each - **What `version` names: the address of the save, not the row whose content was served.** A read falls back from the caller's organization to the environment-wide row (ADR-0005), and from a package's own row to the package-less one (ADR-0048). A save does not fall back; it writes its own partition. A token for a row the save would not overwrite would be refused by that save every time. So such a read serves `null`, which is the honest create pin. This is pinned at the protocol: an env-wide row read under an organization serves `null`, and `parentVersion: null` is then honoured once and refused once. - **Where `version` is absent:** - On the cached published-value branch, which is the default plain read. That branch already publishes no `lock`, and the schema's existing contract sends OCC readers to the uncached path. - On `?preview=draft`, a render path that mixes two lifecycles. - Both cases are declared in the describe, where absence means "not published here" and never "no row". - **The ETag (H3).** The cached arm's `ETag` stays the cache validator and is not the token. That validator is `simpleHash` over the scope (organization, locale) plus the served bytes, folded with the caller's field-visibility fingerprint (ADR-0106 D3) and the public-form intake fingerprint. The version token moves with none of locale, visibility or served-but-unstored bytes. An item served from code has a validator but no token. `getMetaItemCached`'s own comment forbids hashing "a version marker" instead of the content, with `get-meta-item-cached-etag-scope.test.ts` §3 as its pin. So triage's "an `ETag` equal to it where the door sets one" cannot hold on the one branch that sets one without breaking that validator. The body member satisfies the card's "a body member, an `ETag`, or both". No new ETag is minted on the uncached arms: a strong validator shared across locale and mask variants would be false, and the runtime dispatcher serves the same envelope with no such header. The pin is `meta-item-version-token-occ.test.ts`: the cached arm has no `version` and its ETag is not token-shaped. - **`If-None-Match` takes `*` alone, and never beside `If-Match`.** - Measured readers and senders before this PR. On this route, `rest-server.ts` read `if-none-match` only on the cached `GET`. The adapters and `plugin-hono-server` read it nowhere. The SDK sends it only from `getCached` (a `GET`). objectui's `useETagCache` has zero in-repo callers. - A non-`*` value is refused `400 VALIDATION_ERROR`, because a write-unless-the-head-is-one-of-these condition is one this door does not evaluate. Silently dropping a precondition the caller asked for would write that caller unguarded. - The pair `If-Match` plus `If-None-Match: *` is refused `400`. Under RFC 9110 §13.2.2 both are evaluated, so the pair can never hold, and a `409` would send the caller round a re-read that serves a token it would pair with `*` again. - **The receipts' `version` describes are corrected (seat round 1).** `SaveMetaItemResponseSchema.version`, `PublishMetaItemResponseSchema.version` and the package publish door's `published[].version` (`PublishPackageDraftsResponseSchema`, the same `receiptVersion` token, so the same falsehood in the same file) said "Content hash … currently emitted as `sha256:`" and "409 `metadata_conflict`". They now say the token is the keyed `hmac-sha256:` digest of the stored content hash, never the hash itself, and that the conflict is `409 METADATA_CONFLICT`. Only description text moves. The reference docs are regenerated; no spec test reads these describes, so none is pinned. - **`currentVersion` is relayed as `null`, not dropped.** `null` is the answer "no row". The arm therefore reads presence, not truthiness. The record-level `CONCURRENT_UPDATE` arm reads truthiness, and its producer never states `null`. ## Tests All pins are at the HTTP door on the real stack (better-sqlite3 `:memory:`, the real `sys_metadata*` objects, the real `ObjectStackProtocolImplementation`, the real routes). Every refusal pin asserts the ADR-0112 `code` and the HTTP status, and reads the store to show the refused write did not land. `packages/rest/src/meta-item-version-token-occ.test.ts`: - `?state=draft` serves the draft receipt's `version`, which survives `GetMetaItemResponseSchema.parse`. - Read, then save with the served token: 200. The next read serves the new receipt's token. - Two readers save in turn: the second gets 409. Its `currentVersion` equals the winner's receipt and the next read's `version`, the sentence is unchanged, and `MetadataConflictErrorSchema` parses the body. The loser re-pins from `currentVersion` and is accepted. - `If-None-Match: *`: a first draft gets 200. Once a draft exists it gets 409, with `currentVersion` equal to the draft's token. - After a publish (no draft row): a held `If-Match` gets 409 with `currentVersion: null`, and `If-None-Match: *` writes the next draft. - Control: with neither header, the second writer wins and both saves get 200. - Active row, uncached read: it serves the active receipt's token, and `If-None-Match: *` pins the create. An item registered in code serves `version: null`. - The reset door's 409 carries `currentVersion` too. - The cached arm publishes no `version`. - `If-None-Match` beside `If-Match`, or set to an entity-tag, a weak wildcard, a list, or empty: 400 `VALIDATION_ERROR`, nothing written. A wildcard with surrounding whitespace is still the wildcard. Other suites: - `packages/metadata-protocol/src/protocol.served-content-hash.test.ts`: read token equals receipt token, keyed, never the stored hash; `null` at an empty save address; no `version` on `previewDrafts`; the refusal states `currentVersion`. - `packages/rest/src/error-response-structured-arm-door-parity.test.ts`: two parity cases (a token, and `null`) plus a control with no `currentVersion`, which keeps the passthrough body. - `packages/rest/src/error-response-sandbox-arm-message.test.ts`: the arm joins the derived census. - `packages/spec/src/api/meta-item-response-shapes.test.ts`: both schemas at runtime and at the type level. ## Readings (repository `objectstack-ai/objectstack`) **Round 1, on the merged head.** `origin/main` `51290bca2` was merged through `bash scripts/pm/os-regen-merge.sh` as merge commit `0bb0202bc`, with parents `8f04870ef` (this branch) and `51290bca2` (main). It was a clean merge: main touched none of this diff's files, and step 2 kept the branch's bytes of the 8 routed artifacts main did not move. After it came the describe fix (`3950502c6`) and the docs regeneration (`54ae811d4`). `gen:schema` did not run in MERGE state. - **Gates on the merged head `54ae811d4`.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 113 families against merge base `51290bca2`. All 113 ran, each exit code captured before any pipe. - Two first readings were exit 3, `PREREQUISITE NOT MET` (nothing measured): `check:skill-examples` (no `client` / `client-react` dist) and `check:dual-build-cjs-loads` (37 packages with no dist). The recreated worktree had built only this diff's closure. Both read **exit 0** once the battery's own full build had run. - `--ran` reconciliation: `113 derived, 113 run, 0 NOT-MEASURED, 0 UNRUN`, a derived zero. - `check:adr-0087-registration` reads `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING+clause-②-narrowing] not-required (no-migration-prescription)`. `check:changeset-no-major` reads no `major` bump. `check:generated` reports all 15 artifacts up to date. - **Suites on `54ae811d4`, under the verify lock, `--maxWorkers=2`, after rebuilding the closure on the merged tree:** - `@objectstack/spec` `meta-item-response-shapes.test.ts` + `protocol.test.ts`: 197 tests passed. - `@objectstack/metadata-protocol` `protocol.served-content-hash.test.ts`: 27 passed. - `@objectstack/rest` `meta-item-version-token-occ.test.ts`: 15 passed; `--project repo`: 5 files, 191 passed and 1 skipped. - `@objectstack/spec` and `@objectstack/rest` typecheck: exit 0. - **`.changeset/pre.json`** is absent at `origin/main` `51290bca2`, read at this push. The contents API answers 404, against a 200 for `.changeset/config.json` at the same ref as the control. **Round 0, on the pre-merge head (base `54ace18c6`).** - **Gates.** Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at head `8f04870ef`: 113 families, all run there, each exit code captured before any pipe, **113 of 113 exit 0**. The `--ran` reconciliation reads `113 derived, 113 run, 0 NOT-MEASURED, 0 UNRUN` ("a DERIVED zero — all 113 recorded an exit code"). The list includes `check:dispatcher-error-vocabulary`, `check:route-envelope`, `check:durability-log-level`, `check:engine-double-contract`, `check:query-options-erasure`, `check:spec-parsed-alias`, `check:adr-0087-registration`, `check:changeset-no-major`, `check:nul-bytes`, `check:type-check-coverage` / `-debt`, and the spec `check:*` set including `check:generated` (all 15 artifacts up to date). - **Package suites, real output.** All runs used `--maxWorkers=2` under the shared verify lock, with each package's built dependency closure. - At `12a0d8525`: - `@objectstack/types`: test and typecheck exit 0. - `@objectstack/metadata-protocol`: typecheck exit 0; test 221 files passed and 3 skipped, 28243 tests passed. - `@objectstack/rest`: typecheck exit 0, including `check:test-typecheck` (0 debt); `--project local` 261 files and 4929 tests passed. - At `e5e7817b8`: - `@objectstack/spec`: typecheck exit 0; test 622 files and 18583 tests passed. - `@objectstack/client`: typecheck exit 0. - `@objectstack/runtime`: the 7 `/meta` item-read and parity suites, 812 tests passed. The dispatcher's item read serves the same protocol envelope, so it carries `version` too. - At `8f04870ef`: `@objectstack/rest` `--project repo` 5 files passed. Its first run reddened the sandbox-arm census on `METADATA_CONFLICT`, which is the census working as designed; the arm now has its row. - **Lint (a proven narrowing; the repo-wide `pnpm lint` is CI's).** `eslint --no-inline-config --format json` over the 9 changed `.ts` files: 9 files, 0 errors, 0 warnings, with no ignore-pattern warning, so all 9 are inside the config's population. `eslint --print-config` shows no `parserOptions.project` or `projectService`, and `eslint.config.mjs` states it never enables type-aware linting. So this diff cannot move a verdict on an untouched file. - **Ablations (one-off, not kept).** Each leg went through `scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob changed), and the dist legs through `scripts/ablation-dist-preflight.mjs` (marker present in 2 built files, then absent after the restore and rebuild). The tree reads `git diff HEAD` empty afterwards. - R, the REST door ignoring `If-None-Match: *` (source): `meta-item-version-token-occ.test.ts` 2 failed of 15. - P, the draft read serving no `version` (rebuilt `metadata-protocol` dist): the REST door test 5 failed of 15; `protocol.served-content-hash.test.ts` 1 failed of 27. - T, the `@objectstack/types` arm never firing (rebuilt `types` dist): 9 failed of 73 across the door test and the parity test. - **`.changeset/pre.json`**: absent at `origin/main` `15ec50e52`, read before opening this PR. The changeset is a plain `minor` for `@objectstack/spec`, `@objectstack/metadata-protocol`, `@objectstack/rest` and `@objectstack/types`. - The branch is 2 commits behind `origin/main` (`15ec50e52`). Neither commit touches a file this diff touches, generated shards included. It was not merged; the queue rebuilds on `main`. ## Acceptance notes - **Cost.** A read that serves a stored row now makes one extra keyed `findOne` for the save address's head. This includes the cached arm's internal `getMetaItem` call, whose envelope discards it. A read that served no stored row makes no extra call. - **Not covered by this PR.** - `GET /meta/:type/:name/layers` does not serve `version`. It is the diagnostic view, and Studio's designers edit from `?state=draft`. - **The runtime dispatcher's `PUT /meta` reads neither `If-Match` nor `If-None-Match`, and no host in this repository routes `/meta` writes to it (measured).** - `handleMetadataRequest(deps, path, _context, method, body, query)` (`packages/runtime/src/domains/meta.ts:874`) takes no header argument. - The `PUT` branch (`:1274`) calls `protocol.saveMetaItem({ type, name, item, organizationId, writeFace: 'meta-dispatch', ...packageId })` (`:1430`–`:1434`), with no `parentVersion` and no `mode`. - The file has 0 occurrences of `if-match`, `if-none-match` or `parentVersion`. - The shipped hosts here, `objectstack serve` and `os dev`, mount `createRestApiPlugin` (`packages/cli/src/commands/serve.ts:4508`–`:4511`) and then `createDispatcherPlugin` (`:4519`–`:4535`). - The dispatcher plugin mounts explicit routes only, and mounts no `${prefix}/meta` route (0 matches). Its own comment says "the standalone / `os dev` server mounts ONLY the explicit routes here" (`packages/runtime/src/dispatcher-plugin.ts:1283`–`:1285`). So `/meta` writes on those hosts are the REST door's. - The `${prefix}/*` catch-all that does reach `domains/meta.ts` is `@objectstack/hono`'s `createHonoApp` (`packages/adapters/hono/src/index.ts:725`, `:739`). No app, example or package in this repository calls it. - The dispatcher plugin's comments name it as what "the cloud hosts mount underneath" (`dispatcher-plugin.ts:1321`). The cloud runtime is not in this session, so whether a cloud host's `PUT /meta` reaches the catch-all before `RestServer` is NOT MEASURED. This is for the seat to file separately if it is reached. - `GET /meta/:type/:name?package=all` hands the protocol the literal `all`, while the save door drops it. So such a read serves `version: null` for a package-less row the save would write. This is a read-only observation. - **#22128, the package-less draft path.** The seat filed it from this PR's out-of-scope finding. A second package-less `PUT /meta/view/NAME?mode=draft`, sent with no `If-Match`, over an item whose active row is package-bound, answers `409 METADATA_CONFLICT`. The save door's head read asks for the package-unbound draft, while the repository bound the draft to the active row's package. This PR's read token follows the same head read (`storedHeadAt`). So on that path, until #22128 lands, `?state=draft` serves `version: null` while a draft exists. The describe stays as written: the change #22128 carries makes it true there too, at the one shared head read. --- _Generated by [Claude Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6c17a50 commit 8f2e808

20 files changed

Lines changed: 899 additions & 43 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/metadata-protocol": minor
4+
"@objectstack/rest": minor
5+
"@objectstack/types": minor
6+
---
7+
8+
feat(meta): the `/meta` item read serves the ADR-0008 version token, `If-None-Match: *` pins a save that expects no row, and the 409 `METADATA_CONFLICT` body carries the current version as data
9+
10+
Clause-②: yes (narrowing)
11+
12+
**BREAKING** for two request shapes on `PUT /api/v1/meta/:type/:name` that were answered `200` before this release and are now refused `400 VALIDATION_ERROR`, with nothing written: an `If-None-Match` header whose value is anything but `*` (an entity-tag, a list, a weak `W/"*"`, an empty value), and an `If-None-Match` header sent beside `If-Match`. Before, the door did not read `If-None-Match` at all and wrote the body, unguarded or under the `If-Match` alone. The remedy: send `If-None-Match: *` alone to save only where no row exists, or `If-Match` with the version you read alone to save only over that version. No first-party client sends `If-None-Match` on a `PUT`: the SDK sends it only on its cached `GET`, and objectui's ETag hook has no caller.
13+
14+
ADR-0008's optimistic lock on `PUT /api/v1/meta/:type/:name` takes the version a client read as `If-Match`, but a client can only send a token it was served, and only a save receipt served one. So the first save after a load could not be pinned: two editors who each loaded an item and saved once overwrote each other. Three additions close that.
15+
16+
- **The read serves the token.** `GET /meta/:type/:name` now carries `version` (declared on `GetMetaItemResponseSchema`): the keyed token of the stored row a save to this item would compare against, at the read's scope (the caller's organization partition and `?package=`) and lifecycle (`?state=draft` for the draft row, the plain read for the active row). It is the same producer and the same row as the save receipt's `version`, so a read after a save serves the receipt's token byte for byte. Send it back as `If-Match`. `null` means no stored row is there (an item served from code or a package artifact, or from a row in a scope the save does not write), so the next save is a create. The cached published-value branch (the default plain read) and `?preview=draft` publish no `version`, as they publish no `lock`; their `ETag` stays the cache validator and is not the token.
17+
- **"Expect no row" can be said.** `If-None-Match: *` on `PUT /meta/:type/:name` (with `?mode=draft` for a draft) saves only where no row of that lifecycle exists, and is refused `409 METADATA_CONFLICT` once one does. This is the `parentVersion: null` pin `SaveMetaItemRequestSchema` already declared, now reachable over HTTP. The header takes `*` alone: any other value, or `If-None-Match` beside `If-Match`, is refused `400 VALIDATION_ERROR` and nothing is written. No first-party client sends `If-None-Match` on a `PUT`. A save with neither header is last-writer-wins, as before.
18+
- **The conflict body names the current version as data.** The 409 of the `/meta` item write doors (save, publish, rollback, reset) now carries `currentVersion` beside the unchanged `error` sentence: the token the sentence names, or `null` when no row of the target lifecycle exists (declared as `MetadataConflictErrorSchema`). On the save door it equals the token the next read at the same address serves, so a client offering "reload" or "overwrite" re-pins without parsing prose. The SDK already exposes it as `err.details.currentVersion`.
19+
- **The receipts' `version` describes are corrected.** `SaveMetaItemResponseSchema.version`, `PublishMetaItemResponseSchema.version` and the package publish door's `published[].version` said "Content hash … currently emitted as `sha256:`" and "409 `metadata_conflict`". The token has been the keyed `hmac-sha256:` digest of the stored content hash since the doors stopped serving the raw hash, and the conflict code on the wire is `METADATA_CONFLICT`. Only the description text moves.
20+
21+
<!-- adr-0087: not-required (no-migration-prescription) A refusal at one runtime write door of two request-header shapes that door never read, not a metadata change: no spec key, export, response field or stored shape is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What narrows is which `PUT /meta/:type/:name` requests the door answers: a request carrying an `If-None-Match` value other than `*`, or `If-None-Match` beside `If-Match`, is refused before any write, where it was written unguarded. The remedy is a choice of precondition only the client can make (which of the two it meant), so no conversion entry can derive it. Census: measured on this repo at the merge base, no first-party sender puts `If-None-Match` on a `PUT` (`@objectstack/client` sends it only from `meta.getCached`, a `GET`); on the objectui checkout, `useETagCache` sets it and has zero in-repo callers. Not measured: third-party and hosted-tenant HTTP clients. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers this door and this diff adds none (not registered / already-registered); and the change is a door verdict over request headers, not a published runtime interface or a type surface alone (not runtime-interface-only / type-surface-only). -->

0 commit comments

Comments
 (0)