Repository navigation
Commit 8f2e808
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
- .changeset
- content/docs/references
- api
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- metadata-protocol/src
- objectql/src
- rest/src
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- src/api
- types/src
| 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 | + | |
0 commit comments