Skip to content

fix(app-shell): Studio's first draft save after a load is pinned to the read, and a create sends If-None-Match (objectui#11773) - #12351

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-11773-first-save-pinned
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-11773-first-save-pinned

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #11773
Clause-②: yes — @object-ui/data-objectstack's MetadataClientSaveOptions gains the published option ifNoneMatch?: '*'. A contract review is owed before the queue.

What changes

PR objectui#11826 pinned every Studio draft save AFTER an editor's first one: a save sent the version the previous save's receipt carried. The first save after a load stayed unpinned, because the /meta draft read served no version on 17.7.0. So two editors who each opened an item and saved it once still overwrote each other's draft in silence, which is the card's own reproduction. objectstack-ai/objectstack#22126 gave the read its version, and objectui main now resolves @objectstack/* 18.0.0-next.0, which carries it.

  1. The first save after a load is pinned to that load's draft read. Each load that called forget() now reads the draft through readDraftForSave(client, type, name, packageId) and records what it served with the guard's new recordRead(type, name, packageId, pin), where it installs the buffer:
    • a pending draft pins If-Match with the version its read served;
    • no pending draft (the drafts ledger lists none, or the read answers 404 NO_DRAFT), or a served version: null, pins If-None-Match: *;
    • a read that failed, or an envelope with no version key (a server from before the read token), pins nothing, and the save goes unpinned as before.
  2. Loads read the draft at the address their saves write. The read's version names the row a save to the SAME address compares against. Measured below: a draft read that names no package serves a package-bound draft's body with version: null. A first save at ?package=P pinned with that null would be refused against the package's own draft. So the Studio pillars, the list-view panel and the hooks panel, which read with no package and saved with one, now read at ?package= the package they save to. ResourceEditPage reads at readActivePackageBinding(), the folded ?package= its doSave sends. Before, it read at ownerPackageId, the same URL value with all unfolded, which the server folds the same way.
  3. A create sends If-None-Match: *. That covers every create in the guard's caller files: ResourceEditPage create mode, Studio's object, flow, app, page, dashboard, report and permission-set creates, and "+ New" hook. The door refuses it 409 METADATA_CONFLICT where the name already holds a draft, instead of writing the skeleton over it. It also reads the pin as a create, so a sealed name gets the create remedy (metadata-protocol: a refused save of a package-held position name tells the author to "Edit the source artifact and redeploy", which is wrong for a create, where the remedy is a name no package or built-in holds objectstack#22591, relayed on the card). ResourceEditPage.doSave's comment, "a create sends no If-Match (the door cannot pin 'no row yet')", is gone with it.
  4. "Overwrite" re-sends pinned to the refusal's currentVersion. The version is parsed by the spec's MetadataConflictErrorSchema from the body the client kept on the error, with no hand-written reader. A string pins If-Match, and null (a publish drained the draft) pins If-None-Match: *. A save that lands while the dialog is open is refused again and asked about, not overwritten. A refusal that carries no currentVersion (a server from before the field) is overwritten unpinned, as before.
  5. The client (@object-ui/data-objectstack). MetadataClientSaveOptions.ifNoneMatch?: '*' is sent as If-None-Match: *. save() throws before sending a request that sets both ifMatch and ifNoneMatch. The door also refuses that pair with a 400, measured below. The ifMatch docblock no longer calls the token "the checksum returned by the last read". It names the token's three real sources: the item read's version, a save receipt's version, and a conflict's currentVersion. The package README gains a section, "Saving Only Over the Version You Read".

The guard holds one pin per item (type, name, package) instead of a single one. Every editor but one holds a single item at a time, so this changes nothing for them. The hooks panel installs its buffer from a list. Its list load records a pin per listed hook: the draft's version, "no draft row" when this package's drafts ledger does not list the hook, or nothing when the ledger could not be read. A hook that is saved, left and re-opened stays pinned to its last receipt. A list load that a package publish overtook records nothing.

Measured first (Zone 2)

Published @objectstack/cli 18.0.0-next.0 was installed with npm into a scratch directory. rest, metadata-protocol, runtime and spec all read 18.0.0-next.0. It booted a one-object probe app with objectstack dev --seed-admin --fresh --no-watch -p 4773, signed in as the seeded admin, and drove /api/v1/meta with curl. com.probe.studio is a bare authoring-workspace package id. Readings, 2026-10-14 from 02:56Z:

Step Request Answer
create PUT /meta/object/occ_note?mode=draft&package=com.probe.studio, If-None-Match: * 200 {success, version: "hmac-sha256:a687…", seq: 1, state: "draft"}
draft read at the save's package GET …/occ_note?state=draft&package=com.probe.studio 200, version equal to the create's
draft read naming no package GET …/occ_note?state=draft 200, the same body, version: null
pinned save If-Match: the create's version 200, seq 2
stale pin If-Match: the create's version again 409 {error, code: "METADATA_CONFLICT", currentVersion}, currentVersion equal to seq 2's version
first-write pin over a draft If-None-Match: * 409, same three keys, currentVersion the draft's; the sentence reads "Expected parent null but current is …"
both headers If-Match and If-None-Match: * 400 {error: {code: "VALIDATION_ERROR", message: "Send If-Match or If-None-Match, not both. …"}}
overwrite pinned to currentVersion If-Match: that version 200
publish, then the draft read POST …/publish?package=…, then GET …?state=draft&package=… 404 {code: "NO_DRAFT"}; GET /meta/_drafts → {drafts: []}
a draft save pinned with the ACTIVE read's token If-Match: the uncached active read's version 409, currentVersion: null
first draft after the publish If-None-Match: * 200
a second editor who also loaded with no draft If-None-Match: * 409, currentVersion the first editor's draft
a sealed (code-package) object, draft save, unpinned PUT /meta/object/occ_ticket?mode=draft 403 NOT_OVERRIDABLE "… Edit the source artifact and redeploy."
the same, If-None-Match: * 403 NOT_OVERRIDABLE "… so its name is taken. To author your own object, choose a name …"

The probe server was stopped by its recorded PID. The shapes match the installed spec's declarations: GetMetaItemResponseSchema.version is optional, nullable string; MetadataConflictErrorSchema is {error, code: 'METADATA_CONFLICT', currentVersion: string | null}.

Every load and save site of the guard's callers

Sites are named by function, not by line.

File and function Decision
ResourceEditPage load effect Pinned to the read. The draft is read at readActivePackageBinding(), the address doSave writes.
ResourceEditPage doPublish Pinned to the re-read at the active package (it read with no package before); normally "no draft row" after a publish.
ResourceEditPage doDiscardDraft, doReset forget(), unpinned as before. No draft read follows, and the discard names no package, so "no draft row" is not known at the save's address.
ResourceEditPage doSave, create mode If-None-Match: *.
PermissionMatrixEditor load, package door Pinned to the read (it already read at the package).
PermissionMatrixEditor, environment door Unpinned, as before: no draft read, and a live write.
StudioDesignSurface Data pillar object load Pinned to the read, now at ?package=.
StudioDesignSurface Automations pillar flow load Pinned to the read, now at ?package=.
StudioDesignSurface Interfaces pillar leaf load Pinned to the read, now at ?package=. A leaf with no designer installs {} and forget()s, as before.
StudioDesignSurface Interfaces pillar app (nav) load Pinned to the read, now at ?package=. The read-back of its own nav save keeps the receipt's pin, as before.
StudioDesignSurface creates: object, flow, app, page / dashboard / report, permission set If-None-Match: *.
ObjectListViewInspector (useObjectListViewDraft) load Pinned to the read, now at ?package=.
ObjectHooksPanel list load A pin per listed hook (see above), each draft now read at ?package=.
ObjectHooksPanel "+ New" If-None-Match: *.
ObjectHooksPanel, a package publish forget(), every hook's pin, as before.

Not on this card, unchanged: PackageOwdOverviewPanel, EmbeddedItemEditor and DatasourceResourcePage (round 1's reasons stand), and runtime-metadata-persistence (persistRuntimeMetadata's callers stay triage's, per the release note on the card).

Pins

Each pin runs on a real render or the real hook. The doors are doubles answering as measured above, and refusals are parsed by the real MetadataClient.

  • A load then save sends If-Match with the read's version:
    • ResourceEditPage.draftVersionConflict-11773: a pending draft at load; the read named the package;
    • StudioDesignSurface.draftVersionConflict-11773: the Data pillar on a pending draft;
    • PermissionMatrixEditor.firstSavePinned-11773, ObjectListViewInspector.firstSavePinned-11773, ObjectHooksPanel.firstSavePinned-11773.
  • No pending draft at load ⇒ If-None-Match: *, and the card's race: two editors who each load and save once. The later first save is refused with the dialog, and the earlier editor's field survives. Pinned in the ResourceEditPage and Data pillar suites and in the guard's unit suite.
  • A create sends If-None-Match: *:
    • ResourceEditPage.createFirstWritePin-11773: create mode over a real client. A create over a name that already holds a draft is refused, and the draft is not written over;
    • the Data pillar's new object, the hooks panel's "+ New";
    • the Interfaces and app creates (existing suites, moved to the pin).
  • An overwrite after a 409 re-sends with currentVersion: the ResourceEditPage and Data pillar suites. The guard's unit suite also pins currentVersion: null → If-None-Match: *, a third save during the dialog being asked about again, and a refusal without the field overwriting unpinned (the control).
  • The client: metadata-client.firstWritePin-11773 covers the header, the pair refused before any request, and getDraft surfacing version.
  • Controls, PR objectui#11826's guarded sites: every existing suite of the six files runs as before. Receipt pinning, the two 409s kept apart, reload, keep editing and the forced destructive retry all stay pinned. The existing suites that asserted a create's or a first save's exact options now carry the pin. Those suites are createAppOpensInterfaces-11794, interfacesCreate-11823, interfacesCreatePage-11823, listViewInspector-11823, sharedSelect-11865 and ObjectHooksPanel.designerRegistryMissing.

Ablation (Zone 3)

The fix was committed first (cff84f170). Then node ../objectstack/scripts/ablation-replace.mjs replaced the Data pillar load's recordObjRead('object', current.name, packageId, draftRead.pin); with recordObjRead('object', current.name, packageId, null); /* ABLATED-11773 … */, which is what forget() did. The tool's evidence:

  • anchor x1 -> x0;
  • blob 188311f94248 -> 5b81c98db7b2.

Result: Tests 5 failed | 2 passed (7), the red direction expected:

  • red: one editor's first save, the card's race, the pending-draft load, the next save after "reload", and the create's first edit after its load;
  • green: the two pins that never depend on the load's pin (a stale second save, and overwrite).

The tool proved the restore: blob after restore == blob at HEAD (188311f94248), and git diff HEAD is empty. No dist rebuild was needed, because the suite imports app-shell source by relative path.

Local verification

At head 47e75737e unless a line names another commit. Heavy runs went through the shared verify lock, slot ui1-11773-dev. The lock prints its own verdict line, and the exit code was captured before any pipe.

  • Build. pnpm exec turbo run build --filter=@object-ui/app-shell^... --filter=@object-ui/data-objectstack^... --concurrency=2: exit 0, Tasks: 28 successful, 28 total. The rebuilt data-objectstack dist/index.d.ts carries ifNoneMatch.
  • Type-check.
    • pnpm --filter @object-ui/data-objectstack type-check echoed tsc --noEmit: exit 0.
    • pnpm --filter @object-ui/app-shell type-check echoed tsc --noEmit && tsc -p tsconfig.test.json: exit 0.
  • Tests.
    • pnpm exec vitest run --maxWorkers=2 packages/app-shell/: Test Files 1332 passed | 1 skipped (1333), Tests 12814 passed | 9 skipped (12823), exit 0. It held the lock for 50m37s.
    • The app-shell test files this branch adds or edits: Test Files 13 passed (13), Tests 158 passed (158).
    • pnpm exec vitest run --maxWorkers=2 packages/data-objectstack/ at cff84f170: Test Files 89 passed (89), Tests 1141 passed (1141). The package has not changed since that commit.
  • Lint. eslint ran on the 21 touched .ts/.tsx files; --format json counts 21 files linted, none ignored. Result: 0 errors and 105 warnings. Each file's warning count equals its base count read with git show c5406c2:FILE | eslint --stdin, except DraftConflictDialog.tsx, 3 → 5 (see the notes below). The config is not type-aware (no parserOptions.project or projectService), so the diff cannot move a verdict in an untouched file.
  • Repo checks, each exit 0.
    • check-changeset-presence.mjs: "21 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)".
    • check:changeset-claims: "No pending changeset names a file this change touches."
    • check:pending-changeset-literals: "No test source names a pending changeset."
    • check:new-line-citations: "VERDICT new-cross-file-line-citations: 0 new citation(s)".
    • check:control-bytes, check:test-path-roots, check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape: OK.
    • check:metadata-write-doors: "OK 15 metadata write door(s) derived …".
    • check:spec-symbols, check:unreferenced-sources, check:doc-fences, check:doc-types, check:doc-json, check:doc-example-readers: OK.
  • NOT MEASURED.
    • check:doc-snippets and check:readme-exports stopped on their precondition: packages outside this build's closure have no dist/. In their place, the new README block was type-checked with tsc --ignoreConfig --strict against the rebuilt .d.ts: exit 0. A control that sets ifNoneMatch: 'nope' exits 2 (TS2345).
    • check:spec-floors collapses on main as well. Its range reader refuses the prerelease range ^18.0.0-next.0, which every manifest has carried since objectui#12030, so it counts 0 floored packages and exits 1. The control ^17.7.0 reads. This branch touches no manifest. Reported, not fixed here.
  • The check:i18n-* family: not applicable, because no string was added.

Acceptance notes

  • The hooks panel's pins come from its list load. The panel installs a hook's buffer from a list, not from one item read. A hook with an unsent edit keeps the pin that edit was built on when the list reloads.
  • A create refused by the first-write pin shows the door's sentence, "… has been modified since you loaded it. Expected parent null but current is …". That only happens when the name already holds a draft. Before this, such a create wrote over that draft in silence. No new string was added: the conflict dialog's strings are unchanged, and the create flows show the server's message as they did.
  • createRuntimeMetadata (in runtime-metadata-persistence, beside persistRuntimeMetadata) is a create outside this claim's surface. It still sends no precondition.
  • Discard and reset save unpinned once. doDiscardDraft and doReset in ResourceEditPage leave the next save unpinned (see the table).
  • The server reads If-None-Match: * as a create for the sealed-item remedy. The spec declares the same pin as "no row of this lifecycle", including a first draft after a publish. The readings' two sealed-object rows show the remedy change. The Studio editors gate sealed items read-only (editable: false), so none of them sends it. Reported, not filed here.
  • react-refresh/only-export-components warns twice more on DraftConflictDialog.tsx, for the two new exports draftPinOf and readDraftForSave. That is the same class round 1 accepted: the claim's surface has no second module for them.

Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z (the dispatching seat's id; this implementation ran as its subagent).


Generated by Claude Code

…he read, and a create sends If-None-Match (objectui#11773)

Each Studio editor's load reads the draft at the address its saves write
(`?package=`) and records what that read served: the draft's `version`
(If-Match), or "no draft row" (If-None-Match: *). A create sends
If-None-Match: *. The conflict dialog's Overwrite re-sends pinned to the
409's `currentVersion`, parsed by the spec's MetadataConflictErrorSchema.

`@object-ui/data-objectstack`: MetadataClientSaveOptions gains
`ifNoneMatch?: '*'`, never sent beside `ifMatch`; the `ifMatch` docblock
names the token's real sources.

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
…list-view panel, and move create/first-save expectations to the pins (objectui#11773)

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
…and ifNoneMatch (objectui#11773)

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
…o pins (objectui#11773)

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
…draft read is sent (objectui#11773)

Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 72 chunks) 3051.9 KB 3119.4 KB
Main entry chunk (gzip) 73.8 KB 350 KB
Entry file index-skMMYgv1.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) 20.69KB 7.62KB
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) 41.19KB 11.12KB
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.26KB 8.03KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (commentAuthorship.js) 1.39KB 0.78KB
collaboration (index.js) 1.93KB 0.85KB
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) 588.00KB 141.49KB
core (index.js) 10.18KB 4.04KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 248.41KB 69.48KB
fields (index.js) 279.74KB 71.33KB
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) 36.87KB 9.88KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 40.26KB 11.71KB
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) 15.35KB 5.51KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.82KB 2.38KB
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) 55.05KB 16.09KB
plugin-charts (index.js) 84.72KB 23.27KB
plugin-chatbot (index.js) 201.72KB 48.07KB
plugin-dashboard (index.js) 143.63KB 38.79KB
plugin-designer (index.js) 233.77KB 50.02KB
plugin-detail (index.js) 259.74KB 68.60KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 178.42KB 46.26KB
plugin-gantt (index.js) 178.63KB 45.19KB
plugin-grid (index.js) 258.68KB 72.10KB
plugin-kanban (index.js) 53.23KB 16.71KB
plugin-list (index.js) 123.39KB 31.55KB
plugin-map (index.js) 27.24KB 9.03KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 46.66KB 13.13KB
plugin-timeline (index.js) 39.21KB 11.85KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 91.99KB 23.23KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 12.07KB 3.68KB
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.07KB 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.81KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.29KB 0.24KB
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.26KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.48KB 3.50KB
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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: opus — maintainer waiver 6160996883, in place of CONTRACT_REVIEW_TIER
Head-sha: 47e75737ec0b6bbc6a04074b73d2b0340dbcfb92
Local-runs: none

Read: card objectui#11773 (body and all 11 comments), PR objectui#12351 (body, 23 files, the net diff c5406c2...47e7573; the merge-base is c5406c2), and the check-runs on the head. Nothing was built, run or re-run.

① Derived judgments

  1. @object-ui/data-objectstack: MetadataClientSaveOptions.ifNoneMatch?: '*'. It is a published member: the type is re-exported from src/index.ts, and the exports map is . only. The member is additive and optional. Its literal type '*' is the only value the door reads. It is sent as If-None-Match: *. Right.

  2. MetadataClient.save throws on ifMatch + ifNoneMatch together, before any request. This is a new refusal. It narrows only a pair the type could not state before this diff, and the door refuses the same pair 400 (measured in the PR's table). Pinned in metadata-client.firstWritePin-11773 (no request is sent). Right.

  3. The ifMatch docblock no longer says "the checksum returned by the last read". It now names the three real token sources: the item read's version, a receipt's version, and a conflict's currentVersion. This closes the release-text item 2 of 6045522210. Right.

  4. What app-shell sends over HTTP. The accept-set narrows to the card's Expected:

    • Each guarded load's first draft save carries the pin its draft read served: If-Match with the version, If-None-Match: * for no draft row or a served null, and nothing for a failed read or an envelope with no version.
    • Every create in the caller files sends If-None-Match: *: ResourceEditPage create mode; Studio's object, flow, app, page / dashboard / report and permission-set creates; and hook "+ New".
    • Overwrite re-sends pinned to the currentVersion that MetadataConflictErrorSchema parses. A body without that field is overwritten unpinned, as before.
    • Pinned by the guard suite, the Data-pillar suite (which includes the card's two-editor race) and the five *-11773 files. The ablation turned 5 of 7 red.

    Right.

  5. The read address moved. The Studio pillars, the list-view panel and the hooks panel now read the draft at the ?package= they save to. ResourceEditPage now reads at readActivePackageBinding(), which is the value doSave and doPublish send. The display changes only for a draft bound to a third package. The move is required by the measured version: null of a read that names no package. Right.

    • Not measured: a read at a package can serve the package-less draft row (getDraft's own docblock says so). If the version it serves does not match the save's address, the save is refused 409 and the dialog opens. A mismatch therefore fails toward a dialog, never a silent overwrite. Not blocking.
  6. A new runtime import in app-shell. GetMetaItemResponseSchema and MetadataConflictErrorSchema are now imported as values from @objectstack/spec/api.

    • app-shell already imports that subpath at base.
    • It declares @objectstack/spec ^18.0.0-next.0 in dependencies, and the lockfile resolves 18.0.0-next.0. That is the tarball in which the card's restart probe found MetadataConflictErrorSchema (6159689386).
    • The floor already carries both symbols, so no manifest change is needed. The hold's item 3 (6052303321) was met by objectui#12030.

    Right.

  7. app-shell's published surface is unchanged. DraftPin, draftPinOf, readDraftForSave and recordRead live in views/metadata-admin/DraftConflictDialog.tsx. No entry re-exports that module, and the exports map is . plus ./styles.css, so none of them is addressable. The exported MetadataResourceEditPage's props are unchanged. No language-pack key or i18n row is added. Right.

  8. The data-objectstack README gains a section (prose, not a contract face). Its example agrees with the head's getDraft (the envelope with its version) and with draftPinOf's mapping. Right.

② Semver level

  • .changeset/11773-first-save-pinned.md declares:
    • @object-ui/data-objectstack: minor, for the new published option and the new pre-request refusal of the pair;
    • @object-ui/app-shell: patch, for the behaviour fix, with no export, prop or key change.
  • These levels match what the diff publishes. No changeset declares major (AGENTS.md §版本号策略), and no manifest changes.
  • The changeset's sentences match the diff:
    • the covered editors match the PR's call-site table;
    • the create list matches the create sites;
    • Overwrite with currentVersion, and the unpinned fallback;
    • the client paragraph (ifNoneMatch, the throw, the docblock).
  • On the head, Changeset Bump Policy, Changeset Declaration, Changeset Fixed Group Check and Changeset Claim Re-read are success.
  • Clause-②: yes is right. ifNoneMatch is a new member of an entry-exported type of @object-ui/data-objectstack. The claim (6159689386) and PR line 2 agree.

③ Boundary flags

  • open_questions is [] in dev report 6161169286, so there is nothing to answer.

  • The report's deviations:

    • The README is off the claim's list. Accepted: it is the same package, docs ship with the feature (AGENTS.md Add automated testing infrastructure and CI/CD workflows #2), and it is not a contract face.
    • The reads moved to ?package=. Accepted; see ①5.
    • The guard holds a pin per item. Accepted: single-item editors behave the same, which the guard suite's "a pin belongs to one item" pins.
    • The hooks list load records pins. Accepted under triage 6041654008, "every autosave surface takes it".
    • Overwrite goes unpinned when there is no currentVersion. Accepted: that is a server from before the field, the same behaviour as before; 18 serves the field.
    • doDiscardDraft and doReset keep forget(). Accepted as a residual: the first save after a discard or reset is unpinned. Its finding carries "承接者:无". Escalated to the seat: give it a carrier, or rule it out, at ACCEPT.
    • Two more react-refresh warnings. Accepted: the same class round 1 accepted, and Lint is success on the head.
    • check:spec-floors, check:doc-snippets and check:readme-exports were NOT MEASURED locally. Repo Gates and Build Docs are success on the head. The spec-floors collapse is on main, not this diff.
  • Out-of-scope findings:

    • class a, check:spec-floors collapses on prerelease ranges: on main, and the seat files it.
    • class b, the door reads If-None-Match: * as a create when it picks the sealed-item remedy, while the spec reads it as "no row of this lifecycle": objectstack's. The seat files it or relays it. No Studio editor reaches it today, because they gate sealed items read-only.
    • createRuntimeMetadata sends no precondition: it names this card's triage item 3 as its carrier.
  • Escalated to the seat: the carrier closes with the PR. PR line 1 Fixes #11773 closes the card on merge. The card still holds:

    • triage item 3, persistRuntimeMetadata's callers (6045522210; "still triage's" in 6052303321);
    • the createRuntimeMetadata finding, which names this card as its carrier.

    Re-home both, to their own card or a triage ruling, before the merge. This is not a contract defect.

  • Check-runs on the head, read 2026-10-14T05:03Z with none pending: 33 runs, 29 success, 3 skipped (Test (coverage), its shard template, dependabot) and 1 failure.

    • Type Check, Lint and Test shards 1–12 are success.
    • The failure is Spec Main Shape Gate, job 115686734974. It fails at the step "Report which objectui file fails against which objectstack commit". The first error is packages/core/src/adapters/resolveDataSource.ts:59 TS2678: Type '"api"' is not comparable…, with four more errors there and in packages/react/src/hooks/useViewData.ts.
    • The merge-base c5406c2 carries the same signature: job 115671845822 fails at the same step with the same five errors. Neither file is in this diff, and the gate compiles against objectstack main's spec, not the resolved one.
    • A red the merge-base shares does not count against this PR. Escalated: the seat confirms that a card for this red on main exists.

Implemented-by: claude/issue-11773-first-save-pinned
Reviewed-by: session_01CsyMRsFJBKNEMa4oPY1b5z

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat ACCEPT: objectui PR #12351 at 47e7573 · domain:ui seat 1 · session_01CsyMRsFJBKNEMa4oPY1b5z · 2026-10-14T05:06Z

Verdict: ACCEPT. Reviewed on GitHub against the claim 6159689386 and the dev's report 6161169286. The contract review of record on this head is 6161321398, PASS, served at opus under the maintainer's waiver 6160996883 (objectui#5560). The PR lands in this act.

  • PR form.
    • Draft, base main, assignee zhuangjianguo.
    • Line 1 is Fixes #11773.
    • Line 2 is Clause-②: yes: MetadataClientSaveOptions gains the published ifNoneMatch?: '*'.
  • The card's remainder, done.
    • The first save is pinned to what the load read. Each editor's draft read records a pin, If-Match with the draft's version, or If-None-Match: * when no draft is pending.
    • Each read goes to the address its saves write to (?package=). This was measured as needed on the published 18.0.0-next.0 server: a package-less read of a package-bound draft serves version: null, which would make every first save a false conflict.
    • A read that failed pins nothing. The save then goes unpinned, as before.
    • Creates send If-None-Match: *.
    • Overwrite re-sends pinned to the 409's currentVersion, parsed by the spec's MetadataConflictErrorSchema. There is no prose parser.
  • Client.
    • ifNoneMatch never travels with ifMatch: save() throws before sending that pair, which the server refuses anyway.
    • The ifMatch docblock no longer names a checksum that no read serves. That was item 2 of the release 6045522210.
  • Deviations, all accepted:
    • the ?package= reads;
    • one pin per item, so the hooks panel keeps a pin per hook;
    • the hooks panel's list load records pins too, under triage's "every autosave surface";
    • the unpinned Overwrite fallback when a 409 carries no currentVersion;
    • the data-objectstack README section;
    • +2 react-refresh/only-export-components warnings.
  • Changeset, checked sentence by sentence. @object-ui/data-objectstack is minor (the new option) and @object-ui/app-shell is patch; the new helpers are not exported from its entry. Each sentence matches the diff.
  • Tests.
    • New pins: eight suites, including the card's race on StudioDesignSurface. Six existing suites now assert the pin.
    • One ablation (the Data pillar load's pin set to null) gave Tests 5 failed | 2 passed (7). Its restore is blob-proven.
    • Under the lock at 47e75737e: packages/app-shell/ gave 1332 passed | 1 skipped files and 12814 passed | 9 skipped tests. packages/data-objectstack/ gave 89 files and 1141 tests. Both type-checks exit 0.
  • CI on this head: 33 check-runs, every one completed: 29 success, 3 expected skips and 1 red by design. Lint, Type Check, Repo Gates, Test and all 12 shards are success, read off check-runs whose head_sha is 47e75737ec0b6bbc6a04074b73d2b0340dbcfb92.
    • Red by design: Spec Main Shape Gate. It is advisory and not required, and it runs no merge_group. Its cause is objectstack PR #23160, and its consumer card is objectui#12325.
    • Its 21 annotations on this head (check-run 115686734974) are identical to main's run on 72594f29ec (115425173959).
  • check-governed-merges --pr: NOT governed. 1927 changed lines, under 3000.
  • The contract review's escalations, answered:
    • Fixes closes the card. That is right. The release 6045522210 left persistRuntimeMetadata's callers as item 3, "for triage, not yet scope … whether they join this card's remainder is triage's call", with no lost update measured. The claim kept them off this card. They are not this card's remainder, so closing it does not drop scope. This seat's clock-out ledger hands item 3 to triage, together with createRuntimeMetadata, which sends no precondition.
    • Discard and reset leave the next save unpinned. This is noted in the PR's Acceptance notes and has no carrier. It is a narrow window: no draft read follows a discard. It is also in the clock-out ledger.
    • check:spec-floors collapses on main's prerelease ranges. This is carried by objectui#12334, which edits that gate for the 18.x release.
    • The server's sealed-item answer to If-None-Match: * (an objectstack seam, measured on the published 18.0.0-next.0) is not filed in this act. The clock-out ledger hands it to the next seat, with the dev's evidence (6161169286).
    • The red Shape Gate has its card: objectui#12325.
    • The guard reading Served-tier: opus: this PR touches no governed surface, so no queue guard requires a tier record. objectui PR feat(empty-state): draw the declared empty-state link through the action runner, and let a list page's own emptyState win (#12270) #12350 landed through the queue on a record of the same form.
  • Landing: pr_ready, then automerge_enable (the merge queue), in this act.

Implemented-by: claude/issue-11773-first-save-pinned (mode:subagent dev of this seat)
Reviewed-by: session_01CsyMRsFJBKNEMa4oPY1b5z


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 05:06
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 14, 2026 05:06
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit b9814b3 Oct 14, 2026
33 of 34 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-11773-first-save-pinned branch October 14, 2026 05:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

data-adapter documentation Improvements or additions to documentation package: app-shell tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

studio: no draft save sends If-Match, so two editors (or two tabs) silently overwrite each other's metadata edits

2 participants