Skip to content

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 into
mainfrom
claude/issue-11773-studio-draft-save-if-match
Oct 7, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-11773-studio-draft-save-if-match

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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-Match with the version its editor's previous save received, and holds the new receipt's version. When the /meta draft door refuses a stale version with 409 METADATA_CONFLICT, the editor opens a conflict dialog with three choices:

  • Reload saved version. The editor re-runs its own load. Its unsaved edits on screen are dropped.
  • Overwrite… A second, explicit confirmation follows. The same body is then sent again without If-Match, and it wins.
  • Keep editing. Nothing is saved, and the refusal is shown in the editor's error strip. The stale version is kept, so the next save is refused again and cannot slip through.

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, useDraftSaveGuard in the new views/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:

  1. A draft save sends the version this guard holds for the same item (type, name, package), and nothing otherwise.
  2. A save that lands holds the receipt's version.
  3. A buffer installed from a server read (a load, an item switch, a reload after a publish or a discard) holds no version, because the read serves none (measured below). The caller says so with forget(). The read-back of the guard's OWN save does not forget.
  4. A METADATA_CONFLICT opens the dialog (reload / overwrite / keep editing, as above).
  5. Saves through one guard run one at a time, so each one sends the version the previous one received. An autosave never conflicts with the explicit save (column reorder, enable switch) the same buffer sent a moment earlier. This is the self-conflict risk named in Zone 2 item 2.

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/cli 17.7.0 was installed into a scratch directory. rest, metadata-protocol, runtime and spec all read 17.7.0, the release this repo's lockfile resolves. It booted a one-object probe app with objectstack dev --seed-admin --fresh --no-watch -p 4773, signed in as the seeded admin, and created a writable authoring package com.probe.studio. Then it drove the /api/v1/meta door with curl. Readings, 2026-10-07T16:38Z to 16:41Z:

Step Request Answer
create PUT /meta/object/pst_ticket?mode=draft&package=com.probe.studio, no If-Match 200 {"success":true,"version":"hmac-sha256:7102ff…f950","seq":1,"state":"draft","message":"Saved object 'pst_ticket' (env-wide, state=draft) [seq=1]"}. No ETag header.
draft read GET /meta/object/pst_ticket?state=draft&package=… 200 {type, name, sortability, item}. No version key in the envelope or the item, and no ETag header.
active read (cached path) GET /meta/object/occprobe_ticket ETag: "2d68dba9". That is the cache validator, not a version token (8 hex digits).
editor A, fresh token PUT draft, If-Match: the create's version 200, new version (seq 2)
editor B, stale token PUT draft, If-Match: the create's version again 409 {"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's pluralLabel.
quoted token If-Match: "TOKEN" 200. The quotes are stripped.
garbage or empty token If-Match: nope, or an empty If-Match 409 METADATA_CONFLICT
after a publish POST …/publish, then a PUT draft with the last draft version or with the publish receipt's version 409 METADATA_CONFLICT, "current is null". The publish dropped the draft row, so any token is refused.
first draft after a publish PUT draft, no If-Match 200
destructive, with any token PUT draft dropping a field that holds data 409 {"error":"… would drop or transform existing data …","code":"DESTRUCTIVE_CHANGE","issues":[…]}, both with a stale token and with a fresh one
destructive plus force, stale token ?force=true, stale If-Match 409 METADATA_CONFLICT. Destructive is judged first and the version second.
data door GET /api/v1/data/sys_metadata The draft row's checksum column is served keyed, and equals the last receipt's version. Not used: it would mean re-deriving the door's served-row resolution in the client.

So:

  • The server does enforce If-Match on mode=draft writes. Zone 2's stop condition did not fire.
  • The token is the save receipt's version, a keyed digest, and only the receipt serves it. The draft read serves none. The client docblock on MetadataClientSaveOptions.ifMatch ("the checksum returned by the last read") names a token no /meta read serves. That is reported as a finding, and packages/data-objectstack is untouched here.
  • The two 409s are told apart by code. isDraftVersionConflict reads status plus code off the client's parsed error and never reads the prose.
  • Overwrite does not re-send "the server's current token". The 409 body carries it only inside the error sentence, and the door serializes no structured field for it. The guard does not parse prose, so overwrite re-sends without If-Match after 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 9990f9e by git grep "\.save(" over packages/app-shell/src, tests excluded. Sites are named by function, not by line.

File and function Decision
StudioDesignSurface Data pillar doSave (object autosave) OCC-guarded. Its load forgets. Reload re-runs the load for the open object.
StudioDesignSurface Data pillar doReorderFields (grid column drag) OCC-guarded, on the same guard as the autosave, so the two are serialized
StudioDesignSurface Data pillar doCreateObject Create. No If-Match.
StudioDesignSurface Automations pillar doSave (flow autosave) OCC-guarded. Its load forgets.
StudioDesignSurface Automations pillar toggleEnabled OCC-guarded, same guard as the flow autosave. On a refusal the existing rollback of the optimistic flip still runs.
StudioDesignSurface Automations pillar doCreateFlow Create.
StudioDesignSurface Interfaces pillar doSave (page, dashboard and other leaves) OCC-guarded. The leaf load forgets, including the empty-buffer branch.
StudioDesignSurface Interfaces pillar doNavSave (app navigation) OCC-guarded. The app load re-reads after every draft save in the package. The re-read that follows this pillar's own nav save keeps the version. Any other install forgets it, and so does an install after a publish.
StudioDesignSurface shell doCreateApp Create.
StudioDesignSurface Access pillar permission create (buildPermissionSkeleton) Create.
ResourceEditPage doSave OCC-guarded in edit mode, and a create in create mode. Its load effect forgets, and so do doPublish, doDiscardDraft and doReset. 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.
PermissionMatrixEditor doSave OCC-guarded at the package door (mode: 'draft'). The environment door's live write passes through unpinned, as before. That is a non-draft write, outside this card.
ObjectHooksPanel save OCC-guarded. A package publish forgets (the panel now receives publishNonce). Its list re-read after its own save keeps the version.
ObjectHooksPanel addHook Create.
PackageOwdOverviewPanel doSave Not guarded. It reads each object fresh, inside the same click, immediately before patching only the two OWD keys. It holds no long-lived buffer of the document, and the read serves no version to pin. The lost-update window is that one read-to-write round trip. A long-lived editor of the same object (the Data pillar) is protected by its own version: its next save is refused after this panel moved the draft.
EmbeddedItemEditor doSave Not a draft save. It passes no mode, so it is a live write of the parent after a fresh layered read in the same click.
DatasourceResourcePage (external object import) Not a draft save. It passes no mode: a live create of the imported object.
runtime-metadata-persistence createRuntimeMetadata Create.
runtime-metadata-persistence persistRuntimeMetadata Not guarded here. Its callers hold the buffer: the console's runtime view editor (ObjectView's view-config Save) and ReportView'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) and metadata-admin/external/api importObjectDraft (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:

  • serve the version on the /meta item read (a body field declared in GetMetaItemResponseSchema, or an ETag equal to the token) for state=draft and stored-row reads;
  • let a client pin "no draft yet" over HTTP (for example If-None-Match: *), for the first draft after a publish and for creates;
  • name the current version structurally on the METADATA_CONFLICT body, 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 MetadataClient over 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_CHANGE passthrough 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 no If-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-check at 16c92cf: echoed tsc --noEmit && tsc -p tsconfig.test.json, TYPECHECK_EXIT=0.
  • pnpm exec vitest run packages/app-shell/ --maxWorkers=3 at 16c92cf: Test Files 1061 passed | 1 skipped (1062), Tests 10383 passed | 9 skipped (10392), VITEST_EXIT=0.
  • The three new files: Test Files 3 passed (3), Tests 20 passed (20).
  • pnpm exec eslint over the 9 touched .ts/.tsx files: 0 errors. Per-file warnings equal the base or lower: StudioDesignSurface.tsx drops from 17 to 14, because three useCallbacks gained their missing packageId. The new DraftConflictDialog.tsx carries 3 react-refresh/only-export-components warnings, 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 (no parserOptions.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.
  • Gates, each run on the tree at 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-inherit and check: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-Match

The fix was committed first (16c92cf). Then node ../objectstack/scripts/ablation-replace.mjs replaced pinned ? { ...options, ifMatch: pinned } : options in DraftVersionGuard.run with { ...options } /* ABLATED-11773: ifMatch never sent */. The tool's own evidence: anchor x1 -> x0, replacement x0 -> x1, blob 866eec3fa710 -> 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 at HEAD (866eec3fa710), and git diff HEAD empty. 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

  • The dialog offers reload and overwrite, which is the triage direction. The card's "review" (a diff of theirs against mine) is not built.
  • A dev server restart re-keys versions when no crypto provider is registered (the server's ephemeral key). The first save after a restart is then refused once with the dialog, by the server's design.
  • A draft dropped by a path that does not reload this editor (a discard from the Packages page, a publish from another tab) leaves the editor holding a version of a row that no longer exists. Its next save is refused with the dialog, and reload resolves it. The refusal was measured after a publish; after a discard it follows from the same rule and was not measured separately.
  • The pillars read the draft with getDraft(type, name) and no packageId, 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#3 seat, session https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8.


Generated by Claude Code

claude added 2 commits October 7, 2026 16:58
…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>
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 336 chunks) 3517.5 KB 3551.8 KB
Main entry chunk (gzip) 156.7 KB 350 KB
Entry file index-CCqhI9Ah.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 17.82KB 6.58KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.11KB 7.97KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 578.94KB 139.21KB
core (index.js) 10.00KB 3.96KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 235.41KB 65.46KB
fields (index.js) 266.88KB 67.46KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 38.37KB 10.31KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 41.50KB 11.82KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.86KB 5.00KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.52KB 2.26KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 53.39KB 15.52KB
plugin-charts (index.js) 84.26KB 23.05KB
plugin-chatbot (index.js) 199.63KB 47.46KB
plugin-dashboard (index.js) 144.22KB 38.99KB
plugin-designer (index.js) 231.46KB 48.87KB
plugin-detail (index.js) 247.86KB 65.27KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 176.62KB 45.75KB
plugin-gantt (index.js) 179.17KB 45.07KB
plugin-grid (index.js) 238.51KB 65.53KB
plugin-kanban (index.js) 52.17KB 16.37KB
plugin-list (index.js) 116.85KB 29.12KB
plugin-map (index.js) 25.60KB 8.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.12KB 12.29KB
plugin-timeline (index.js) 39.10KB 11.81KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 90.64KB 22.85KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.25KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

…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>
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 336 chunks) 3517.5 KB 3551.8 KB
Main entry chunk (gzip) 156.7 KB 350 KB
Entry file index-CCqhI9Ah.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 17.82KB 6.58KB
app-shell (runtime-config.js) 22.59KB 7.89KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.11KB 7.97KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 578.94KB 139.21KB
core (index.js) 10.00KB 3.96KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 235.41KB 65.46KB
fields (index.js) 266.88KB 67.46KB
i18n (LocalizationContext.js) 2.92KB 1.42KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.52KB 2.39KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 38.37KB 10.31KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 41.50KB 11.82KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.86KB 5.00KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.52KB 2.26KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 53.39KB 15.52KB
plugin-charts (index.js) 84.26KB 23.05KB
plugin-chatbot (index.js) 199.63KB 47.46KB
plugin-dashboard (index.js) 144.22KB 38.99KB
plugin-designer (index.js) 231.46KB 48.87KB
plugin-detail (index.js) 247.86KB 65.27KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 176.62KB 45.75KB
plugin-gantt (index.js) 179.17KB 45.07KB
plugin-grid (index.js) 238.51KB 65.53KB
plugin-kanban (index.js) 52.17KB 16.37KB
plugin-list (index.js) 116.85KB 29.12KB
plugin-map (index.js) 25.60KB 8.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.12KB 12.29KB
plugin-timeline (index.js) 39.10KB 11.81KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 90.64KB 22.85KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.25KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 7, 2026 19:23
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 7, 2026 19:23
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 7, 2026
Merged via the queue into main with commit 2dec305 Oct 7, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-11773-studio-draft-save-if-match branch October 7, 2026 19:41
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants