Repository navigation
fix(app-shell): Studio draft saves send the version they were built on, and a stale save opens a reload / overwrite dialog (objectui#11773) - #11826
Merged
objectstack-fleet[bot] merged 3 commits intoOct 7, 2026
Conversation
…saved at (objectui#11773) Every draft save of an existing metadata item now goes through one guard per editing buffer (`useDraftSaveGuard`): it sends the `version` the buffer's last save receipt carried as `If-Match`, holds the next receipt's version, and turns a `409 METADATA_CONFLICT` into a conflict dialog (reload the saved version, or overwrite it after a confirmation). A buffer installed from a read holds no version, because the 17.7.0 draft read serves none; creates stay unpinned. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
…d 17.7.0 door (objectui#11773) The guard's unit suite runs the real MetadataClient over a door double that answers as the 17.7.0 `/meta` draft door was measured to; the Data pillar suite races two mounted editors; the designer suite keeps the destructive-change 409 apart from the version conflict. Adds the patch changeset. The guard's inputs are re-bound after each commit instead of read through refs in its initializer. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
This was referenced Oct 7, 2026
…ectui#11773 changeset The CHANGELOG paragraph no longer opens with the PM loop's claim label. Three sentences now say only what the diff does: protection starts after an editor's first save, only the guarded saves send `If-Match`, and the Interfaces autosave guards whichever item is open, not only pages. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
This was referenced Oct 7, 2026
objectstack-fleet
Bot
deleted the
claude/issue-11773-studio-draft-save-if-match
branch
October 7, 2026 19:41
This was referenced Oct 7, 2026
akarma-synetal
pushed a commit
to akarma-synetal/objectui
that referenced
this pull request
Oct 9, 2026
…ystem fields out of the grid, Form preview and designer (objectui#11780) (objectstack-ai#11828) Fixes objectstack-ai#11780 Clause-②: no ## What changed Studio's Data pillar kept framework fields out of the records grid (`gridColumns`), the Form preview (`formFields`) and the form designer by one fixed name list, `STUDIO_SYSTEM_FIELD_NAMES`. The platform's search companion `__search` and `owning_business_unit_id` are not on that list, so every author saw "Search Index" and "Owning Business Unit" in all three views, while the runtime list hides both. - New module-private predicate `isStudioHiddenSystemField(def)` in `studio-design/studioHiddenSystemField.ts` (not on the package entry): `system === true && hidden === true` on the served field definition, per the seat's ruling. An author field with `hidden: true` and no `system` stays visible; so does `owner_id` (`system`, not hidden). - `gridColumns` and `formFields` drop entries that match it, beside the unchanged name-list check. Memo keys are unchanged: `objDraft.fields`, plus `publishedFieldNames` for the grid. - `ObjectFormDesigner`: one module-level test, `isKeptOffLayout(entry, systemFieldNames)` (the name list OR the predicate), now serves all three of its readers: the density count, the containers, and the commit's write-back. A field kept off the canvas is therefore always written back. No prop change; the mount still passes `STUDIO_SYSTEM_FIELD_NAMES`. - `STUDIO_SYSTEM_FIELD_NAMES` keeps its job, measured below: the platform's audit columns are `system` without `hidden`, so the marks alone would put them back. Its doc block now says so. - Patch changeset for `@object-ui/app-shell`. Fence held: no `MetadataClient.save` call site, no version-token read, no change to the object-draft load effect, no edit to the metadata-admin `i18n.ts`, no package-entry export, no type member, no locale key. `ObjectFormDesigner` is not re-exported from the app-shell `index.ts` (grep: zero hits; the positive control `registerMetadataPreview` hits). ## Measurements against the PM hypotheses **H1, measured live.** objectstack `a543e244`, `examples/app-showcase` booted with `objectstack dev --seed-admin --fresh` on an isolated port, read through the same endpoints `MetadataClient.layered` and `getDraft` call. - No pending draft, `showcase_account`, `GET /api/v1/meta/object/showcase_account/layers`. `effective.fields` is the record shape. `__search`: hidden true, system true, readonly true. `owning_business_unit_id`: hidden true, system true, readonly true. `organization_id`: the same. `owner_id`: system true, readonly false, no hidden. `created_at`: system true, readonly true, no hidden. Author field `name`: hidden false, readonly false, no system. (`code.fields` has no `__search`; it is provisioned into `effective`.) - Served draft: an object created the way `doCreateObject` creates one (the skeleton with one `name` field, saved with mode=draft into a Studio-created package), then `GET ...?state=draft`. `item.fields` is organization_id, created_at, created_by, updated_at, updated_by, owner_id, owning_business_unit_id, name, __search. `__search` and `owning_business_unit_id`: hidden true, system true, readonly true. Author field `name`: label only. An object of a code-provided package takes no draft (the save answers 403, read-only package), so the served-draft case is a Studio-authored object. - So the marks sit on the entries `readFields(objDraft.fields)` returns in both cases. No second source was needed. **H2, confirmed.** The three readers above are the leak. No separate seed carries the injected fields: `buildObjectSkeleton` seeds one `name` text field, the server adds the injected columns to the served draft, and the designer's ungrouped bucket rendered them. That bucket is the filer's "default layout seed". **H3, confirmed and kept.** The designer's commit writes the kept-off entries followed by the canvas entries. With the shared test, the injected hidden fields ride the same path as the audit columns. Pinned through the real pillar: a designer drop is auto-saved with both fields present and their definitions deep-equal to the served ones. **H4, not touched.** `ObjectFormCanvas` (the `object` metadata preview, mounted through `ObjectPreview` in the metadata-admin editor) renders every entry through `groupEntries(view, ...)` with no system filter of any kind; it shows the audit columns too. It is a full field-inventory editor, and the Data pillar does not mount it (the pillar mounts `ObjectFormDesigner` and the grid). Hiding fields there would be a new design decision, not this leak. **H5, kept.** Both memo keys are unchanged. A new pin holds the grid columns array at the same identity across an object-label edit made through the real `onPatch`. ## Live check in the browser The console dev server from this branch, proxied to that backend, headless Chromium at 1440x900, signed in as the seeded admin. - This branch: the `showcase_account` Records grid headers run Account Name, Industry, ..., Owner, Loyalty Tier, LinkedIn URL, CSAT Score, Actions. Neither "Search Index" nor "Owning Business Unit" appears. Both designers (`showcase_account`, and the Studio-created object) show neither label. The new object's designer shows Owner and Name. - The same session with both source files set to the base commit (restored afterwards; blob hashes equal HEAD, `git diff HEAD` empty): the headers include "Owning Business Unit" and "Search Index", and both designers show both labels. ## Pins `DataPillar.hiddenSystemFields-11780.test.tsx` drives the real `DataPillar`. The fixture copies the platform's own literals (`provisionSearchCompanion`, `OWNING_BUSINESS_UNIT_FIELD_DEF`, `TENANT_SCOPE_FIELD_DEF`, the `created_at` row of `AUDIT_FIELD_DEFS`). Each case runs twice: once on an object with no pending draft, and once with a served draft. - Records grid, Form preview, designer: `__search`, `owning_business_unit_id` and `organization_id` are absent. The author fields are present. - Controls: `internal_note` (author `hidden: true`, no `system`) and `owner_id` (`system`, not hidden) stay. `created_at` stays out, by the name list. - Write-back: a designer drop (the captured `onDragEnd`, with the real `DndContext` rendered) auto-saves `fields`. In those fields `industry` now leads `name`, every hidden system field's definition is deep-equal to the served one, and the key set is unchanged. - Identity: an unrelated draft edit (the object label) leaves the grid columns array at the same identity, and the save carries the new label. - The predicate's truth table: both marks, booleans only. Reverse checks. The fix was committed first. Each leg is restored with `git checkout HEAD --`, then proven by a blob hash equal to HEAD and an empty `git diff HEAD`. Predicted direction first: 1. Both source files at the base commit (marker counts 4 and 4 fell to 0 and 0): 6 failed, 5 passed. The failures are grid, Form preview and designer, each twice; the first reads "expected [ 'name', 'industry', ... ] to not include '__search'". Write-back, identity and the predicate stay green, as predicted. 2. The designer commit's write-back test changed back to the name list alone: 2 failed (write-back, twice), "expected undefined to deeply equal { type: 'text', ... }". This leg proves the hidden fields would be dropped. 3. The `gridColumns` key loosened to `objDraft`: 2 failed (identity, twice). The first attempt at leg 3 was a no-op: the script hit a syntax error before the mutation landed (anchor count 1/0 unchanged, nothing written). It was rerun with a corrected script, and the counts after the mutation read 0/1. ## Gates (local HEAD 7343b3a) - `pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build`: exit 0 - `pnpm exec vitest run` on `StudioDesignSurface.gridColumns`, `StudioDesignSurface.formFields`, `ObjectFormDesigner`, every `DataPillar.*` test and the new pins: exit 0, 13 files, 56 tests passed - `pnpm exec vitest run packages/app-shell/src/views/studio-design/` (at 9feaf8b; 7343b3a changes only the new test file's mock typing): exit 0, 93 files, 548 tests passed - `pnpm --filter @object-ui/app-shell type-check` (echoes `tsc --noEmit && tsc -p tsconfig.test.json`): exit 0 - `pnpm check:control-bytes` OK · `check:new-line-citations` VERDICT 0 new citation(s) · `check:changeset-claims` OK · `check:pending-changeset-literals` OK: all exit 0 - Added for this diff: `check:vi-mock-specifiers` · `check:vi-mock-inherit` · `check:vi-mock-override-shape` (new `vi.mock` doubles) · `check:test-path-roots` · `check:unreferenced-sources` (new source file) · `check:metadata-write-doors`: all exit 0. Also `node scripts/check-changeset-presence.mjs`, `node scripts/check-changeset-no-major.mjs` and `node scripts/check-governed-queue-guard.mjs --test` on the changed paths (NOT GOVERNED): all exit 0. - Targeted eslint, as the package's `lint` runs it, on the four touched source files: 0 errors. On the two modified files the warnings are the same set as at the base commit (ObjectFormDesigner 1, StudioDesignSurface 17). The new files have none. Type-aware linting is not enabled in `eslint.config.js`, so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` belongs to CI. ## Acceptance notes - The designer's write-back puts the kept-off fields first in `fields`. That was already true for the audit columns, and is now true for the injected hidden ones. Their definitions are unchanged. Before this change these fields sat in the ungrouped bucket and were rewritten at that bucket's position, so a designer edit moved them before too. - The object header's "N fields" count still counts every served field, the system ones included. It did so before this change, and it is not on this card's surface. - One line outside the claim's listed regions of `StudioDesignSurface.tsx`: the import of the new helper, in the module's import block. In-flight draft PR objectstack-ai#11826 also adds an import there, in an earlier part of the block near `useMetadataClient`. The two hunks do not overlap, and neither do its `DataPillar` hunks with this branch's. Session: `https://claude.ai/code/session_01DrKzdPdyLLBW3qpZ4vtk7z` (dispatching seat domain:ui#1). --- _Generated by [Claude Code](https://claude.ai/code/session_01DrKzdPdyLLBW3qpZ4vtk7z)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Oct 9, 2026
…ch: * pins a first write, and the 409 carries currentVersion (objectstack-ai#22126) Fixes objectstack-ai#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. - **objectstack-ai#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 objectstack-ai#22128 lands, `?state=draft` serves `version: null` while a draft exists. The describe stays as written: the change objectstack-ai#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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #11773 — the client half. The card stays open for the server half, named in the section on it below.
Clause-②: no
What changes
Every Studio draft save of an existing metadata item now sends
If-Matchwith theversionits editor's previous save received, and holds the new receipt'sversion. When the/metadraft door refuses a stale version with409 METADATA_CONFLICT, the editor opens a conflict dialog with three choices:If-Match, and it wins.The door's other 409,
DESTRUCTIVE_CHANGE, is judged before the version and passes through the guard untouched to each editor's existing confirmation flow.One guard per editing buffer,
useDraftSaveGuardin the newviews/metadata-admin/DraftConflictDialog.tsx, with the dialog beside it. A guard belongs to one buffer, not to an item: two surfaces in one tab that each hold their own copy of an item are two editors, and sharing one version between them would let the second one's stale copy through. The rules, in that file's header:version.forget(). The read-back of the guard's OWN save does not forget.METADATA_CONFLICTopens the dialog (reload / overwrite / keep editing, as above).Creates send no
If-Match. The door cannot express "expect no row" over HTTP (below), so a create keeps calling the client directly.Measured first (Zone 2 item 1)
Published
@objectstack/cli17.7.0 was installed into a scratch directory.rest,metadata-protocol,runtimeandspecall read 17.7.0, the release this repo's lockfile resolves. It booted a one-object probe app withobjectstack dev --seed-admin --fresh --no-watch -p 4773, signed in as the seeded admin, and created a writable authoring packagecom.probe.studio. Then it drove the/api/v1/metadoor with curl. Readings, 2026-10-07T16:38Z to 16:41Z:PUT /meta/object/pst_ticket?mode=draft&package=com.probe.studio, noIf-Match{"success":true,"version":"hmac-sha256:7102ff…f950","seq":1,"state":"draft","message":"Saved object 'pst_ticket' (env-wide, state=draft) [seq=1]"}. NoETagheader.GET /meta/object/pst_ticket?state=draft&package=…{type, name, sortability, item}. No version key in the envelope or the item, and noETagheader.GET /meta/object/occprobe_ticketETag: "2d68dba9". That is the cache validator, not a version token (8 hex digits).If-Match:the create's versionseq2)If-Match:the create's version again{"error":"object/pst_ticket has been modified since you loaded it. The version token sent is not the current version (current is hmac-sha256:b375…2f14).","code":"METADATA_CONFLICT"}. The draft still held A'spluralLabel.If-Match: "TOKEN"If-Match: nope, or an emptyIf-MatchMETADATA_CONFLICTPOST …/publish, then a PUT draft with the last draft version or with the publish receipt's versionMETADATA_CONFLICT, "current is null". The publish dropped the draft row, so any token is refused.If-Match{"error":"… would drop or transform existing data …","code":"DESTRUCTIVE_CHANGE","issues":[…]}, both with a stale token and with a fresh one?force=true, staleIf-MatchMETADATA_CONFLICT. Destructive is judged first and the version second.GET /api/v1/data/sys_metadatachecksumcolumn is served keyed, and equals the last receipt'sversion. Not used: it would mean re-deriving the door's served-row resolution in the client.So:
If-Matchonmode=draftwrites. Zone 2's stop condition did not fire.version, a keyed digest, and only the receipt serves it. The draft read serves none. The client docblock onMetadataClientSaveOptions.ifMatch("thechecksumreturned by the last read") names a token no/metaread serves. That is reported as a finding, andpackages/data-objectstackis untouched here.code.isDraftVersionConflictreadsstatuspluscodeoff the client's parsed error and never reads the prose.If-Matchafter the confirmation. That is still a last-writer-wins write: a third writer landing between the refusal and the confirmed overwrite is overwritten. The author chose that write knowing the draft had moved. A structured current version on the 409 is part of the server finding below.Every save call site (Zone 2 item 6)
Read on
9990f9ebygit grep "\.save("overpackages/app-shell/src, tests excluded. Sites are named by function, not by line.StudioDesignSurfaceData pillardoSave(object autosave)StudioDesignSurfaceData pillardoReorderFields(grid column drag)StudioDesignSurfaceData pillardoCreateObjectIf-Match.StudioDesignSurfaceAutomations pillardoSave(flow autosave)StudioDesignSurfaceAutomations pillartoggleEnabledStudioDesignSurfaceAutomations pillardoCreateFlowStudioDesignSurfaceInterfaces pillardoSave(page, dashboard and other leaves)StudioDesignSurfaceInterfaces pillardoNavSave(app navigation)StudioDesignSurfaceshelldoCreateAppStudioDesignSurfaceAccess pillar permission create (buildPermissionSkeleton)ResourceEditPagedoSavedoPublish,doDiscardDraftanddoReset. Its post-save read-back is the echo of its own save and keeps the version. The destructive-change dialog is unchanged and is a separate dialog.PermissionMatrixEditordoSavemode: 'draft'). The environment door's live write passes through unpinned, as before. That is a non-draft write, outside this card.ObjectHooksPanelsavepublishNonce). Its list re-read after its own save keeps the version.ObjectHooksPaneladdHookPackageOwdOverviewPaneldoSaveEmbeddedItemEditordoSavemode, so it is a live write of the parent after a freshlayeredread in the same click.DatasourceResourcePage(external object import)mode: a live create of the imported object.runtime-metadata-persistencecreateRuntimeMetadataruntime-metadata-persistencepersistRuntimeMetadataObjectView's view-config Save) andReportView's Save. Both are explicit-Save editors outside Studio and outside this card's file surface. Pinning them means giving those callers a guard. That is named as the remaining client follow-up on this card, not done here.Outside the claimed surface and not draft saves, recorded for completeness:
preview/UnpublishedAppBar(a live PUT of the app, the ADR-0045 visibility flip) andmetadata-admin/external/apiimportObjectDraft(a live PUT create).What this does not fix: the server half
The card's own reproduction is not fixed by this PR. Tabs A and B both open the item, then each saves once. B's first save has no version to send, because the draft read serves none on 17.7.0, so it is still last-writer-wins. What changes is that the loss is no longer permanent and silent. A's next save is refused with the dialog (A holds a version B's write moved), so A sees it and chooses. After each editor's first save, every later save is protected.
Closing the first-save window needs the server to:
/metaitem read (a body field declared inGetMetaItemResponseSchema, or anETagequal to the token) forstate=draftand stored-row reads;If-None-Match: *), for the first draft after a publish and for creates;METADATA_CONFLICTbody, not only in the sentence.That is reported to the seat as a cross-repo finding. With the server half, the guard also records the version from each read. That is a one-line change at each load that today calls
forget().Tests
Server double modelled on the measured door. The unit suite runs the real
MetadataClientover a fetch double. The Studio and designer suites throw refusals parsed by the real client's error parser.views/metadata-admin/DraftConflictDialog.test.tsx(12 tests). Token advance; serialized back-to-back saves;forget(); one version per item and package; non-draft passthrough; reload (plus a queued save dropped on reload); overwrite; keep editing (refused again); after-publish control;DESTRUCTIVE_CHANGEpassthrough with the forced retry keeping the version; the code-not-prose predicate.views/studio-design/StudioDesignSurface.draftVersionConflict-11773.test.tsx(5 tests, two mounted Data pillars over one server). One editor's consecutive autosaves all succeed. Two editors: the stale save gets the dialog and the other editor's change survives, then reload, then overwrite. A create sends noIf-Match.views/metadata-admin/ResourceEditPage.draftVersionConflict-11773.test.tsx(3 tests). Token advance. The conflict dialog, not the destructive one. Control: a destructive change still opens its own confirmation, and its forced retry keeps the version.Runs (all through the shared verify lock; seconds are shared-box readings):
pnpm --filter @object-ui/app-shell type-checkat16c92cf: echoedtsc --noEmit && tsc -p tsconfig.test.json,TYPECHECK_EXIT=0.pnpm exec vitest run packages/app-shell/ --maxWorkers=3at16c92cf:Test Files 1061 passed | 1 skipped (1062),Tests 10383 passed | 9 skipped (10392),VITEST_EXIT=0.Test Files 3 passed (3),Tests 20 passed (20).pnpm exec eslintover the 9 touched.ts/.tsxfiles: 0 errors. Per-file warnings equal the base or lower:StudioDesignSurface.tsxdrops from 17 to 14, because threeuseCallbacks gained their missingpackageId. The newDraftConflictDialog.tsxcarries 3react-refresh/only-export-componentswarnings, from exporting the guard and its hook beside the component (the provider-plus-hook shape several app-shell files already use). This narrowing is a measurement, not a skipped run. The population is the 9 files, read from--format json. The config is not type-aware (noparserOptions.project) and no repo rule reads other files, so this diff cannot move a verdict on an untouched file. The repo-wide lint is CI's.16c92cf, each exit 0, with the gate's own line quoted:check:control-bytes"OK (scanned 7783 tracked text file(s)…)".check:test-path-roots"OK".check:changeset-claims"No pending changeset names a file this change touches."check:pending-changeset-literals"No test source names a pending changeset."check:i18n-keys"Every in-scope call-site key resolves…".check:i18n-drift"No designer-table en value changed in this range." (10 keys added).check:i18n-designer-parity"Every en row has a zh row, and every shared row carries the same placeholders."check:new-line-citations"VERDICT new-cross-file-line-citations: 0 new citation(s)".check:vi-mock-specifiers,check:vi-mock-inheritandcheck:vi-mock-override-shape"OK".check:metadata-write-doors"OK 17 metadata write door(s) derived…".check:unreferenced-sources"OK Every shipped source file in every covered package is reachable."check-changeset-presence.mjs"9 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)".check:i18n-dead-keys(a report) lists none of the new keys.Ablation: stop sending
If-MatchThe fix was committed first (
16c92cf). Thennode ../objectstack/scripts/ablation-replace.mjsreplacedpinned ? { ...options, ifMatch: pinned } : optionsinDraftVersionGuard.runwith{ ...options } /* ABLATED-11773: ifMatch never sent */. The tool's own evidence: anchorx1 -> x0, replacementx0 -> x1, blob866eec3fa710 -> 5e0c9f9dfa9e. Inside the locked run,MARKER_COUNT=1 ANCHOR_COUNT=0. Result:Tests 16 failed | 4 passed (20). The two-editor pin times out waiting for the dialog. The 4 that stayed green never depend on a pin: one version per item,forget(), non-draft passthrough, and the code-not-prose predicate. The restore was proven by the tool: blob after restore == blob atHEAD(866eec3fa710), andgit diff HEADempty. The direction was red, as expected. A first attempt was refused by the tool before it ran anything, because its replacement (options) was a substring of the anchor and the count could not move. That attempt was a no-op, restored and proven, and is not a reading.Acceptance notes
getDraft(type, name)and nopackageId, while they save with the package. That is unchanged here and is not measured.Implemented by the dispatched os-dev subagent of the
domain:ui#3seat, sessionhttps://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8.Generated by Claude Code