Skip to content

fix(fields): an avatar pick submits its sys_file id through the UploadProvider, or is refused by name (objectui#10785) - #10811

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-10785-avatar-file-id
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-10785-avatar-file-id

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #10785
Clause-②: yes (narrowing) — AvatarField, a public export of @object-ui/fields and the registered avatar widget, now refuses a pick whose upload adapter surfaces no id-shaped meta.fileId (the object-URL default with no UploadProvider mounted, S3/Azure-style adapters) where it used to hand every accepted pick to onChange as a data: URL; its props type is unchanged and no accept set widens. minor + BREAKING, per the objectui#7699 ruling (5856395366).

What changed

AvatarField no longer reads the pick with FileReader.readAsDataURL and hands the data: URL to onChange. It now takes the path FileField and ImageField take since objectui#7699 (PR objectui#10782): useUpload() → upload(file) → fileValueForSubmit(result, file.name). The bare sys_file id reaches onChange. A pick whose adapter surfaced no id throws UploadIncompleteError inside the try. The catch renders uploadErrorMessage (the shared "did not complete" / "Failed to upload" row), and the field is not changed.

Reading goes through readFileValues + withRecentUploads, the reader the file and image widgets use. The src it resolves is used in both the editable and the readonly face.

The pick is now asynchronous, so the widget also raises useUploadingSignal(uploading, onUploadingChange) and useUploadingScopeHold(), as both sibling widgets do. Three kinds of host now hold their submit until the upload settles:

  • the record forms, through plugin-form's uploadGate and the ambient uploading scope;
  • FormPage (/f/:slug, /forms/:name) and ActionParamDialog, whose onUploadingChange gates name avatar since round 2 (see Round 2).

The button shows a spinner and fields.image.uploading while the upload runs, and is disabled. The file input is reset up front, as in ImageField, so a refused pick can be re-picked under the same name. No i18n key is added: every string reused already exists in all ten packs.

Files:

  • packages/fields/src/widgets/AvatarField.tsx
  • new packages/fields/src/widgets/AvatarField.fileId-10785.test.tsx
  • packages/fields/src/widgets/types.ts: one JSDoc line; see Acceptance notes
  • apps/console/src/components/FormPage.tsx and new FormPage.avatarUpload-10785.test.tsx beside it
  • packages/app-shell/src/views/ActionParamDialog.tsx and new ActionParamDialog.avatarUpload-10785.test.tsx beside it
  • .changeset/10785-avatar-submits-file-id.md and .changeset/10785-avatar-upload-gates.md

Not touched: FileField, ImageField, file-value.ts, upload-error-message.ts and the upload provider.

H1 — reproduction on base, both halves

Widget half, run. The real AvatarField on base 6cf599985d, with a one-byte PNG picked under an id-minting UploadProvider (leg A below):

Expected: "f0e1d2c3b4a5968778695a4b3c2d1e0f"
Received: "data:image/png;base64,eA=="

The adapter's call count on base is 0: the widget never uploads.

Stock-deployment half: read, not run (no live server; objectstack origin/main 14ae40b0e1).

  • The check. validateRecord's value-shape arm (packages/objectql/src/validation/record-validator.ts, the branch taken when FILE_REFERENCE_TYPES.has(t)) parses the value with shapeSchemaFor(def). For avatar, a FILE_REFERENCE_TYPES member, that schema is FileReferenceIdValueSchema (packages/spec/src/data/field-value.zod.ts): word characters and -, 1 to 64 long. A data: URL fails it on :, /, ; and ,.
  • Refused with invalid_type (invalid_value_shape) when mediaStrictEffective(mediaStrict) answers true. OS_ALLOW_LAX_MEDIA_VALUES=1 answers false and wins over everything. Otherwise OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1 answers true. Otherwise the answer is the deployment's adr-0104-file-references flag (FILE_REFERENCES_MIGRATION_ID), which ObjectQL.mediaValueShapeStrictFor → isFileReferencesMigrationVerified() reads. Two things write that flag: os migrate files-to-references --apply, and attestFreshDatastore at creation, because the id is in CREATION_ATTESTED_MIGRATION_IDS.
  • Admitted warn-first otherwise, with the validator's line "[value-shape] … accepted for now (ADR-0104 warn-first; run os migrate files-to-references --apply …)". The admission is also reported through onAdmitted, the evidence that stops that deployment's flag from being recorded over it.
  • Verdict. A datastore born on a current version is attested at creation, so it refuses the avatar save unless the lax switch is set. Only an un-migrated older deployment admits it, warn-first, and there the value blocks the flag.
  • The column. ADR-0104's 2026-09-05 addendum rules the family's single-value column to a string column holding the bare id (the generator's VARCHAR(2048)). Reasoned from the ADR, not measured: a real image's data: URL is longer than that.

The premise holds.

H2 — the seam

  • Provider. The hook is useUpload() (@object-ui/providers). It returns { upload, adapter }, and with no UploadProvider above it returns a fresh createObjectUrlAdapter().
  • Id. fileValueForSubmit(result, originalName) returns meta.fileId when isFileIdToken accepts it. Otherwise it throws UploadIncompleteError. AvatarField calls it exactly where ImageField's picker does: inside the try, before anything is remembered or handed over.
  • No provider mounted. The object-URL default resolves with no meta. The pick is refused with the same row FileField renders. The pin renders both widgets without a provider, picks the same file, and asserts the two rows' text is equal. There is no silent data: fallback, and nothing reaches onChange.
  • Preview while uploading. The current value (or the initials fallback) stays, and the button shows the uploading state.
  • Preview after. uploadResultView(result) is kept in recent, keyed by the new id. withRecentUploads overlays it on the bare id the host hands back, so the image shows the adapter's own result.url (for createObjectStackUploadAdapter, its storage URL) until the next read expands the value. Pinned: after the host re-renders with the id, the avatar is drawn from https://cdn.example/me.png.
  • Preview on a refused pick. Unchanged.

H3 — legacy read

The reader is readFileValue (via readFileValues), unchanged:

  • a stored data: URL renders as itself;
  • an http(s) URL renders as itself;
  • a bare id renders from /api/v1/storage/files/ID (fileUrlFromId).

Each is pinned in the editable and in the readonly face. On base, a bare id was drawn as src="f0e1…", a relative URL. That is red on base (leg A), so the id read is part of the fix, not a control. The data: and http(s) reads are the controls: green in every leg.

Pins and ablation (round 1)

The file is AvatarField.fileId-10785.test.tsx. It drives the real widget under a real UploadProvider with a spy adapter; the no-provider arm stubs URL.createObjectURL. window.Image is replaced by the LoadedImage stand-in userCell.readGate-10535 uses, so Radix's AvatarImage draws its img under happy-dom. Each refusal arm asserts that the transport ran once, that onChange was never called, and that nothing handed over carries data: or blob:.

All three legs ran at 4605e9852c, under the verify lock, with --reporter=verbose:

leg tree result
H, green head 9 passed / 9
A, red on base AvatarField.tsx checked out at base 6cf599985d (disk blob 9024feb22a == base blob; markers readAsDataURL 1, fileValueForSubmit 0); tests at head 5 failed / 4 passed
B, ablation readAsDataURL → onChange put back ahead of the upload, via ablation-replace.mjs (anchor const result = await upload(file); 1 → 0; blob dfe12bc2cc → c181826f64); everything else at head 3 failed / 6 passed
  • Leg A, the 5 red: the three submit rows (a fileId, no fileId, no provider) and the bare-id read in both faces.
  • Leg B, the 3 red: the three submit rows only. Every read row stays green.
  • Restores, both proven: git diff HEAD is empty, and the disk blob equals the HEAD blob dfe12bc2cc. The ablation marker count is 0 afterwards.

The file is unchanged in round 2, and it is green in every round-2 leg below.

Round 2 — the host gates (contract review 5857847604, item 1)

Measured on the round-1 head 4605e9852c, before the fix. A /f/:slug FormPage with an avatar field rendered the real AvatarField under an UploadProvider whose adapter held its promise. With the upload confirmed in flight (the adapter's pending list at length 1), a throwaway probe (not committed) read the Submit button and clicked it:

PROBE button: {"text":"Submit","disabled":false}
PROBE submits while held: [{"url":"/api/v1/forms/join-us/submit","body":{}}]
PROBE toast.success calls: [["Submitted",{"id":"form-outcome:_r_0_"}]]

The record went out without the avatar, and the form reported success: the objectui#10167 class. The same held upload in ActionParamDialog left Confirm enabled (Received element is not disabled).

The fix.

  • FormPage.tsx: isUploadWidget answers true for 'avatar'. Its docblock ("The widgets that emit upload-in-progress …") and the uploading state's docblock ("Only the upload widgets (file, image, avatar) …") name it.
  • ActionParamDialog.tsx: the isUploadWidget gate names 'avatar', and so does the uploading state's comment. serializeParamValues is not touched: an avatar value is already a bare id string.
  • Shared list? @object-ui/fields exports no upload-widget set. It exports useUploadingScope / UploadingScopeProvider, the aggregation, not a key list. FILE_REFERENCE_TYPES (spec) names field types, not widget keys: video / audio resolve to the file widget. So the two literals gain 'avatar', and no export is added.

Measured after the fix, head 03eae19e77. The same held upload: Submit reads "Uploading…" and is disabled, nothing is POSTed, and once the upload settles Submit is enabled and the payload carries photo: "f0e1d2c3b4a5968778695a4b3c2d1e0f". ActionParamDialog: Confirm is disabled and reads actionDialog.uploading, a click resolves nothing, and after the upload settles Confirm resolves { photo: "f0e1…" }.

Pins. Both drive the REAL AvatarField through the host's own resolver, under a real UploadProvider whose adapter holds its promise. Nothing stubs the widget, so they also pin the widget's onUploadingChange claim the review found unpinned:

  • apps/console/src/components/FormPage.avatarUpload-10785.test.tsx
  • packages/app-shell/src/views/ActionParamDialog.avatarUpload-10785.test.tsx

Legs at head 03eae19e77, one verify-lock run, over the two host pins and the round-1 fields pin:

leg tree result
H, green head 3 files, 11 passed / 11
R1, red on the round-1 head FormPage.tsx and ActionParamDialog.tsx checked out at 4605e9852c (disk blobs e6e7fa6395 / 4ed9206c60 == their round-1 blobs; === 'avatar' markers 0 / 0); pins at head 2 failed / 9 passed: the FormPage row and the ActionParamDialog row
B, ablation only 'avatar' removed from FormPage's isUploadWidget, via ablation-replace.mjs (anchor 1 → 0; blob 5aa1fd3f92 → 9d146e1dc8) 1 failed / 10 passed: the FormPage row only

Restores, both proven. Leg R1: git diff HEAD 0 bytes, and both disk blobs equal HEAD (5aa1fd3f92, c091611940). Leg B: the tool's restore gives blob == HEAD 5aa1fd3f92 and an empty git diff HEAD, and the marker count is 0 afterwards.

Gates

  • Pins: as above, at 03eae19e77.
  • Host suites: pnpm exec vitest run --maxWorkers=2 apps/console/src/components/FormPage apps/console/src/__tests__/formPageRequiredMarker-10178.test.tsx packages/app-shell/src/views/ActionParamDialog at 03eae19e77 (every FormPage and ActionParamDialog test file, the two new pins included): 33 files, 363 passed (VERDICT command-exit 0).
  • Fields suite (round 1): pnpm exec vitest run --maxWorkers=2 packages/fields/ packages/plugin-detail/src/__tests__/inlineEditTypeCoverage.test.tsx at 02987baad6: 216 files passed, 1 skipped; 3528 tests passed, 7 skipped. No fields source changed since then except one JSDoc line.
  • Closure build: turbo run build --filter='@object-ui/console^...' --concurrency=2 under the verify lock: 34/34 tasks (11 cached).
  • Type-check, exit 0 at 03eae19e77:
    • pnpm --filter @object-ui/app-shell run type-check && pnpm --filter @object-ui/console run type-check, one && chain, VERDICT command-exit 0;
    • --listFiles shows each pin in its program once: the ActionParamDialog pin in app-shell's tsconfig.test.json, the FormPage pin in the console's tsconfig.json.
  • Changeset gates, exit 0 at 03eae19e77:
    • check-changeset-presence: 7 source files of 3 released packages, 2 changesets;
    • check-changeset-fixed, check-changeset-no-major, check-changeset-overwrite, check:pending-changeset-literals;
    • check:changeset-claims (report-only): it names 6 pending changesets that cite FormPage.tsx or ActionParamDialog.tsx. All were read; none speaks to the upload gate, and none is made false.
  • Other gates, exit 0 at 03eae19e77: check:control-bytes, check:new-line-citations (0 new), check:test-path-roots, check:vi-mock-specifiers, check:phantom-deps, check:esm-specifiers, check:unreferenced-sources, check:i18n-keys.
  • Lint: eslint --format json over the 7 touched TS/TSX files: 0 errors, 35 warnings. The two host files carry the same warning count as at the round-1 head (FormPage 20, ActionParamDialog 12, read by linting the round-1 blob through --stdin), and the three pin files carry 0. This is a targeted run, not a proven narrowing. The per-package eslint . is CI's.

Changeset

  • .changeset/10785-avatar-submits-file-id.md: '@object-ui/fields': minor with a BREAKING banner, as the objectui#7699 ruling (5856395366) graded the same change for the file and image widgets; never major. Round 2 changes:
    • it carries the Clause-②: yes (narrowing) line, as the 7699 changeset does;
    • "for every pick" now reads "for every accepted pick";
    • the strictness sentence now states mediaStrictEffective exactly: the flag or OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1 makes it strict, and OS_ALLOW_LAX_MEDIA_VALUES=1 wins over both;
    • the "waits for it" sentence now names the three kinds of host that gate: the record forms' upload gate, FormPage and ActionParamDialog.
  • New .changeset/10785-avatar-upload-gates.md: '@object-ui/app-shell': patch and '@object-ui/console': patch. apps/console is not private: its package.json has no private field, @object-ui/console is in .changeset/config.json's fixed group, and earlier FormPage fixes such as 10167-formpage-file-field-arm.md declare it patch.

Serial

git merge-tree --write-tree of head 03eae19e77 against origin/main 67d4ed9083: clean (tree f5e1c91472). None of the touched paths moved on main since base 6cf599985d. (Refreshed by the seat at landing, from the round-2 contract review.)

Acceptance notes

  • Surface beyond the original claim.
    • types.ts's onUploadingChange docblock said "Upload widgets (file/image) fire this … Other widgets ignore it". AvatarField fires it now, so the line names avatar.
    • FormPage.tsx and ActionParamDialog.tsx were added to the claim's surface by the seat in round 2.
  • Hosts with no provider. The docs site and @object-ui/runner mount no UploadProvider, so an avatar pick there is now refused, as the file and image picks already are since objectui#7699 (accepted there). The console mounts UploadProvider with the ObjectStack adapter above ConsoleShell.
  • InlineFieldInput (plugin-detail). It passes onUploadingChange to none of the three upload widgets and mounts no scope. It is unchanged, and at parity with file and image.
  • Unchanged here.
    • The widget's existing client-side checks (a non-image pick, a pick over 5MB) still only console.error and do nothing visible, and they do not use the translated file-size-guard the sibling widgets use.
    • The hard-coded English strings ("Upload Avatar", "Change Avatar", "PNG, JPG up to 5MB") are unchanged.
    • Removing the avatar still sends an empty string. validateRecord treats '' as missing (isMissing) — read, not run.
    • The avatar transport-throw arm is not pinned separately: uploadErrorMessage is shared and pinned under objectui#7699.
    • Observations only, not filed. Carrier: none named.
  • Not a finding. SignatureField still writes a base64 PNG, but signature is not a FILE_REFERENCE_TYPES member, so that is not this contract.

Generated by Claude Code

…dProvider, or is refused by name

AvatarField read the picked file with FileReader.readAsDataURL and handed
the data: URL to onChange. avatar is a file-reference type, so its stored
value is the bare sys_file id. The pick now goes through useUpload() and
fileValueForSubmit, the path FileField and ImageField use. A pick whose
adapter surfaced no id is refused with the same "did not complete" row.
Legacy data: and http(s) values still render through readFileValue, and a
bare id now renders from the storage endpoint.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
…dingChange; type the pin fixtures

The onUploadingChange docblock said only file and image fire it and that
other widgets ignore it; AvatarField fires it now. The avatar pins use a
typed FieldMetadata fixture instead of any casts.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3078.5 KB 3104.5 KB
Main entry chunk (gzip) 148.3 KB 350 KB
Entry file index-DYfgBhc-.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) 16.57KB 6.15KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
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.17KB 10.58KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
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.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.52KB 3.45KB
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.13KB 7.95KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 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) 556.38KB 133.09KB
core (index.js) 9.57KB 3.81KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 224.97KB 62.47KB
fields (index.js) 260.39KB 66.14KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
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.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.28KB 11.09KB
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.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
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.01KB 3.93KB
plugin-calendar (index.js) 51.67KB 14.70KB
plugin-charts (index.js) 83.58KB 22.75KB
plugin-chatbot (index.js) 198.40KB 47.22KB
plugin-dashboard (index.js) 134.11KB 35.62KB
plugin-designer (index.js) 216.25KB 44.39KB
plugin-detail (index.js) 233.23KB 61.74KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 158.35KB 40.47KB
plugin-gantt (index.js) 169.75KB 41.96KB
plugin-grid (index.js) 218.12KB 59.72KB
plugin-kanban (index.js) 48.43KB 15.11KB
plugin-list (index.js) 114.70KB 28.42KB
plugin-map (index.js) 22.90KB 7.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.55KB 11.99KB
plugin-timeline (index.js) 30.91KB 9.05KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 87.83KB 22.01KB
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) 119.16KB 39.05KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.03KB 1.86KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 7.50KB 3.05KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 6.16KB 2.71KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 18.27KB 6.20KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
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) 0.20KB 0.18KB
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) 4.74KB 2.25KB
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 (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) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 17.15KB 6.32KB
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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4605e9852c022d104d5a48818aff81b0eb050e96

① Derived judgments

Parity with FileField / ImageField after objectui#10782: it holds inside the widget.

  • The widget uses the same hook, useUpload() from @object-ui/providers, with the same upload(file).
  • It uses the same submit rule. fileValueForSubmit(result, file.name) runs inside the try and before anything is remembered, exactly where ImageField's picker calls it.
  • It uses the same refusal. uploadErrorMessage sends an UploadIncompleteError to fields.file.uploadIncomplete and any other error to fields.file.uploadFailed. Neither is a new literal.
  • It raises the same signals, useUploadingSignal(uploading, onUploadingChange) and useUploadingScopeHold(). The scope is released in finally after onChange, as in the sibling widgets.
  • The file input is reset up front, as ImageField does.
  • No provider behaves the same way. The object-URL default is invoked and refused. The pin compares the avatar row against FileField's row for the same pick, and the text is equal.

Parity breaks at two hosts. This blocks, as item 1 of the verdict.

  • The pick used to settle in milliseconds (FileReader). It now waits on a network upload. The widget reports that correctly, but two hosts only listen for file and image:
    • apps/console/src/components/FormPage.tsx: isUploadWidget returns true only for widgetKey === 'file' || widgetKey === 'image'. Only those widgets are handed onUploadingChange, and only they gate Submit (isUploading).
    • packages/app-shell/src/views/ActionParamDialog.tsx: the same file/image-only gate.
  • Neither host mounts an UploadingScopeProvider. In this repo only plugin-form's uploadGate does (DrawerForm, MasterDetailForm).
  • The result: on a FormPage form (/f/:slug) with an avatar field, a Submit clicked during the upload saves the record without the avatar and reports success. That is the objectui#10167 silent-empty class, and this PR opens its window for avatar.
  • Two sentences say otherwise and are false for those hosts:
    • the changeset: "…as the file and image widgets do, so a form save waits for it";
    • the PR body's "a form save waits for it".
  • FormPage.tsx's two docblocks ("The widgets that emit upload-in-progress — the only ones handed onUploadingChange", and "Only the upload widgets (file, image)") are made false by this PR. It changed types.ts to say that avatar fires the callback, and left these standing.
  • For plugin-form record forms the claim is true. They gate on the ambient scope that useUploadingSignal reports into. InlineFieldInput passes onUploadingChange to none of the three upload widgets, so that host is unchanged and at parity.

Read path.

  • Both faces use src = withRecentUploads(readFileValues(value, ''), recent)[0]?.url.
  • A bare id resolves through readFileValue, then fileUrlFromId (FILE_STORAGE_BASE_PATH). That is the same builder ImageField reads through. AvatarField holds no new URL literal; the literal appears only in the pin's expected value.
  • A legacy http(s) value that is not a token passes through as itself, so there is no regression. That row is pinned green on base in both faces.
  • An expanded object value now renders its url, where base rendered [object Object]. This is an improvement.
  • After an upload, recent overlays the adapter's result.url on the bare id. This is pinned.

Error UX.

  • A transport failure is translated (fields.file.uploadFailed, quoting the adapter's message) and shown in the destructive row under the button.
  • onChange is not called, so the controlled value is kept.
  • The button leaves uploading in finally, and the refusal arm pins that the button is re-enabled.
  • Not pinned for avatar: the transport-throw arm and a kept non-empty value. The shared helper is pinned under objectui#7699, so this does not block.
  • Pre-existing and unchanged: the non-image and over-5MB checks only console.error. They do not use the translated file-size-guard that the sibling widgets use. This is a parity gap, observation only.

Changeset .changeset/10785-avatar-submits-file-id.md, sentence by sentence.

  • The title is true.
  • The BREAKING banner rationale is true and mirrors .changeset/7699-file-submit-requires-id.md.
  • "Who breaks" is true: the object-URL default, S3 and Azure. Nit: "for every pick" should read "for every accepted pick", because image-type and 5MB checks come first.
  • "What they see now" is true.
  • "What to do" is true. The isFileIdToken rule and the /api/v1/storage/files/:id fallback match file-value.ts.
  • "avatar is a member of the file-reference family…" is true. objectstack field-value.zod.ts puts avatar in FILE_REFERENCE_TYPES.
  • "refuses (invalid_type) on every deployment whose adr-0104-file-references flag is recorded" slightly overstates. mediaStrictEffective (record-validator.ts) lets OS_ALLOW_LAX_MEDIA_VALUES=1 win over the flag, and the sentence also omits the env-forced strict case. This is a nit that mirrors the 7699 wording, and it does not block.
  • "…so a form save waits for it" is false for FormPage and ActionParamDialog. See the parity section above.
  • The reading paragraph is true.
  • No dated note is owed. The 7699 changeset's "the file and image widgets' one path" stays true. 10493-data-uri-no-file-name.md stays true. No doc page states that avatar stores a data: URL.

Pins (AvatarField.fileId-10785.test.tsx): real.

  • They drive the shipping AvatarField under a real UploadProvider with a spy adapter. Only window.Image (for Radix) and URL.createObjectURL are stubbed, both undone in afterEach.
  • Each refusal arm asserts that the transport ran once and that onChange was not called. The no-provider arm's FileField comparison is a real parity control.
  • The data: and http(s) read rows are real controls, green on base. The bare-id read row is correctly counted as part of the fix, red on base.
  • The dev's legs are consistent with the code:
    • Base: 5 red, the 3 submit rows plus the bare id in 2 faces.
    • Ablation: 3 red. Restoring readAsDataURL then onChange ahead of the upload turns the fileId arm into 2 calls and fails both refusal arms.
  • Gap: nothing pins onUploadingChange or the scope hold. Ablating useUploadingSignal would stay green, so the "save waits" claim is unpinned.
  • Harmless vacuity, as in 10782: everythingHandedOver(onChange) after not.toHaveBeenCalled() can never fail.

② Semver level

  • '@object-ui/fields': minor with a BREAKING banner is correct and matches the objectui#7699 ruling (5856395366). It is never major, and it is the only package touched.
  • Clause-② must read yes.
    • AvatarField is a public export of @object-ui/fields and the registered avatar widget.
    • It now refuses every pick under an adapter that surfaces no id, including no provider at all. It used to hand every accepted pick to onChange.
    • That narrows a published surface. The PR's own changeset calls it BREAKING, so no contradicts the grade.
    • The dev's argument, that the props type is unchanged and the behaviour was never declared, is the same argument the seat overruled for the widgets in objectui#7699.
  • Replacement for PR body line 2. Also amend the claim 5857296619's Clause-② line in place, and optionally add the line to the changeset body as the 7699 changeset carries it:

Clause-②: yes (narrowing) — AvatarField, a public export of @object-ui/fields and the registered avatar widget, now refuses a pick whose upload adapter surfaces no id-shaped meta.fileId (the object-URL default with no UploadProvider mounted, S3/Azure-style adapters) where it used to hand every accepted pick to onChange as a data: URL; its props type is unchanged and no accept set widens. minor + BREAKING, per the objectui#7699 ruling (5856395366).

③ Boundary flags

  • CI on the head: not final. 42 check runs, polled at 16:50Z: 31 success, 3 skipped, 0 failed, and 8 still in progress (Test shards 2, 3, 4, 6, 7 and 8 of 8, Type Check, Spec Main Shape Gate).
  • Merge: git merge-tree --write-tree of the head against main 6fa5f64a1d is clean, tree 2596889242. GitHub reports mergeable true with mergeable_state behind.
  • Open PRs: none of the 14 other open PRs touches AvatarField.tsx, types.ts, file-value.ts, FormPage.tsx, ActionParamDialog.tsx or InlineFieldInput.tsx.
  • check-governed-merges: 0 of 4 paths governed, so the NOT governed queue landing applies. 353 changed lines, under the human-merge threshold.
  • Draft and assignee: the PR is draft, assigned to os-elon-musk. Labels are tests and package: fields.

Implemented-by: claude/issue-10785-avatar-file-id
Reviewed-by: session_014mXUNuFomfj24w7s1pZzhN

VERDICT: FAIL

  1. Host gate (substance). The avatar upload is now asynchronous, but FormPage and ActionParamDialog gate Submit only for file and image, and neither mounts the uploading scope. A save during an avatar upload drops the value silently.
    • Preferred fix: add 'avatar' to isUploadWidget in apps/console/src/components/FormPage.tsx and to ActionParamDialog.tsx's gate. Update the two FormPage docblocks to name avatar, add an @object-ui/app-shell patch changeset line, and add one pin showing the FormPage Submit disabled during an avatar upload.
    • Minimum if the seat keeps the scope: replace the changeset clause "…as the file and image widgets do, so a form save waits for it" with "…as the file and image widgets do, so a record form that gates on the uploading scope waits for it" (and the same in the PR body), and file a carrier card for the FormPage and ActionParamDialog gates.
  2. Clause-② line: replace no with the yes line in ② above, in the PR body and in the claim 5857296619.
  3. Non-blocking: the "for every accepted pick" and OS_ALLOW_LAX_MEDIA_VALUES nits in the changeset, and an onUploadingChange pin, which item 1's pin would cover.

…hile an avatar upload is in flight

AvatarField now waits on a network upload and reports it through
onUploadingChange, but both hosts handed that callback to file and image
alone, so a Submit or Confirm pressed mid-upload went out without the
avatar and reported success. Both gates now name avatar. The avatar
changeset carries its Clause-② line, names the hosts that gate, and
states the media strictness switch exactly.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
@github-actions

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 6 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/6473-metadatatypeactions-param-dialog-close.md

  • names ActionParamDialog.tsx → packages/app-shell/src/views/ActionParamDialog.tsx — edited by this change

    The pre-reset paramState.resolve?.(null) is dropped as well, on an enumeration rather than on "resolving twice is a no-op": onOpenChange is reachable from exactly three places, all inside ActionParamDialog, and every one settles the promise before asking for the close — handleSubmit, handleCancel, and the Radix root handler that delegates to handleCancel (the single route Escape, an overlay click and the header close button all take). All four routes are driven in the new test, with a census over ActionParamDialog.tsx so a later call site that skipped the settle is red there instead of leaving a promise pending forever.

.changeset/console-form-container-specs-one-declaration-5596.md

  • names FormPage.tsx → apps/console/src/components/FormPage.tsx — edited by this change

    objectui#5542 converged the LEAF of this contract — the field spec — and left the two containers above it untouched, because converging them was a bigger call than a mechanical import. FormSectionSpec and FormViewSpec were each hand-declared twice under the same names, once in packages/app-shell's SchemaForm.tsx and once in apps/console's FormPage.tsx. Unlike the leaf — whose console copy was a clean subset — these two had already drifted, in both directions, so neither copy was a subset of the other and there were two live answers to "what may an author write":

.changeset/console-form-field-spec-one-declaration-5542.md

  • names FormPage.tsx → apps/console/src/components/FormPage.tsx — edited by this change

    objectui#5040 was not a missing key. It was that two hand-written descriptions of one contract drifted, and nothing could notice, because each was only ever checked against itself. PR metadata-admin: FormFieldSpec declares dependsOn, one declaration for both halves #5537 converged the two app-shell descriptions into views/metadata-admin/form-spec.ts. A third survived in apps/console: FormPage.tsx declared its own nine-key interface FormFieldSpec, under the same name, in a different package — so the same failure mode stayed fully available.

.changeset/console-formpage-runtime-default-seed-5727.md

  • names apps/console/src/components/FormPage.tsx → apps/console/src/components/FormPage.tsx — edited by this change

    readPrefill in apps/console/src/components/FormPage.tsx seeded every declared default unconditionally. A defaultValue may be a literal, or an instruction the server resolves per insert — a DEFAULT_VALUE_TOKENS token (NOW(), current_user) or a CEL Expression envelope. Seeding one of those literally put the text NOW() into a datetime input on both /forms/:name and the public /f/:slug route, and submitting it sent that string as the field's value — which is neither absent nor null, so ObjectQL.applyFieldDefaults never resolved the declared default and the column stored the token text instead of a timestamp.

.changeset/console-formpage-visible-predicates-5594.md

  • names apps/console/src/components/FormPage.tsx → apps/console/src/components/FormPage.tsx — edited by this change

    apps/console/src/components/FormPage.tsx is a second, independent form renderer — its own buildSections, its own JSX — and it serves both the public /f/:slug route and the internal /forms/:name route. It read neither spelling of the FormView field visibility predicate: a repo-wide grep for a visibleWhen / visibleOn read inside that file returned zero. So a field an author conditioned on record.priority == 'urgent' — legal, spec-strict metadata that @objectstack/spec normalises to visibleWhen (ADR-0089), and that the metadata-admin designer both authors and honours — rendered unconditionally on both routes. Fail-open and silent: the author saw the field always, with no diagnostic.

  • names FormPage.tsx → apps/console/src/components/FormPage.tsx — edited by this change

    objectui#2212 recorded this exact symptom and PR fix(form): evaluate view-level FormField.visibleOn with the canonical CEL engine #2214 fixed it — in a different chain: ModalForm → resolveFormViewLayout → @object-ui/plugin-form sectionFields.ts → @object-ui/components renderers/form/form.tsx. FormPage.tsx is on that chain at no point, and Form-view FormField.visibleOn (CEL) is never evaluated — conditional fields always render #2212's regression pin lives with the chain it fixed, so nothing in the suite could see this copy. One contract, two implementations, each only ever checked against itself.

.changeset/retire-components-action-param-dialog-5685.md

  • names src/views/ActionParamDialog.tsx → packages/app-shell/src/views/ActionParamDialog.tsx — edited by this change

    • import { ActionParamDialog } from '@object-ui/components' — no drop-in replacement is published. The surviving implementation is @object-ui/app-shell's ActionParamDialog (src/views/ActionParamDialog.tsx), rendered by app-shell's action runtime (useConsoleActionRuntime, RecordDetailView) rather than exported standalone. A host that needs its own param form builds on @object-ui/fields' shared field widgets (resolveFormWidgetType / getLazyFieldWidget, ADR-0059) — the same seam the surviving dialog renders through.

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with a14fb23b3 (merge-base with origin/main): 7 file(s) changed outside .changeset/, read against 1600 pending declaration(s) that publish a body (2189 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3078.5 KB 3104.5 KB
Main entry chunk (gzip) 148.3 KB 350 KB
Entry file index-TxJScWXe.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) 16.57KB 6.15KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
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.17KB 10.58KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
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.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.52KB 3.45KB
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.13KB 7.95KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 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) 556.38KB 133.09KB
core (index.js) 9.57KB 3.81KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 224.97KB 62.47KB
fields (index.js) 260.39KB 66.14KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
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.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.28KB 11.09KB
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.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
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.01KB 3.93KB
plugin-calendar (index.js) 51.67KB 14.70KB
plugin-charts (index.js) 83.58KB 22.75KB
plugin-chatbot (index.js) 198.40KB 47.22KB
plugin-dashboard (index.js) 134.11KB 35.62KB
plugin-designer (index.js) 216.25KB 44.39KB
plugin-detail (index.js) 233.23KB 61.74KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 158.35KB 40.47KB
plugin-gantt (index.js) 169.75KB 41.96KB
plugin-grid (index.js) 218.12KB 59.72KB
plugin-kanban (index.js) 48.43KB 15.11KB
plugin-list (index.js) 114.70KB 28.42KB
plugin-map (index.js) 22.90KB 7.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.55KB 11.99KB
plugin-timeline (index.js) 30.91KB 9.05KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 87.83KB 22.01KB
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) 119.16KB 39.05KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.03KB 1.86KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 7.50KB 3.05KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 6.16KB 2.71KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 18.27KB 6.20KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
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) 0.20KB 0.18KB
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) 4.74KB 2.25KB
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 (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) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 17.15KB 6.32KB
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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 03eae19e7776422dc2c6cb7b81820d161b908383

Round 2, against the round-1 FAIL record 5857847604. Round 1's other judgments stand.

① Derived judgments

Round-1 FAIL item 1 (host gates): resolved.

  • FormPage.tsx's isUploadWidget and ActionParamDialog.tsx's gate now answer 'avatar', so both hosts hand onUploadingChange to the real widget.
    • Both hosts already refuse the submit while any row is uploading. ActionParamDialog does it through anyUploading: Confirm is disabled, and handleSubmit returns early.
    • FormPage disables its only type="submit" button (disabled={submitting || isUploading}). A disabled default button also blocks implicit (Enter) submission.
  • The whole window, start to settle. AvatarField sets uploading to true synchronously before await upload(file). Its finally releases it on every outcome: success, a transport throw, and the fileValueForSubmit refusal (UploadIncompleteError, thrown inside the try). onChange(next) runs before that same finally, so the host never sees the upload settled while it still holds the old value.
    • The type check and the 5MB check return before any upload starts, so there is nothing to hold for them.
  • No other gating host is missed. I grepped apps/console, packages/app-shell, packages/plugin-form, plugin-detail and react.
    • Only these two hosts pass onUploadingChange by a type list. plugin-form gates through the ambient uploading scope (uploadGate.tsx), which the widget's useUploadingScopeHold and useUploadingSignal already feed.
    • ObjectForm.tsx's file/image branch only sets inputType/accept hints; it is not a gate.
    • ActionParamDialog's serializeParamValues only maps a file descriptor to its id, and the avatar widget already emits a bare id.
    • InlineFieldInput gates none of the three widgets, the same as for file/image.
  • Docblocks. The two in FormPage and the dialog's uploading comment are updated. Two remaining lines are now stale; neither blocks:
    • ActionParamDialog.tsx's "UploadProvider (file/image uploads)" is now incomplete, not false.
    • packages/app-shell/src/utils/paramValueShape.ts's avatar: { … note: 'Data-URL / base64 image string.' } is now false. It is not exported from the app-shell index, not tested, has no effect at runtime, and its string/scalar shape is still right.
    • Suggested replacement: note: 'Bare sys_file id string once the upload settles (objectui#10785); no descriptor serialization needed.' Fold it into a fixup before landing, or put it on a follow-up card.
  • Row-seeded avatar values (observation, not made worse). serializeParamValues leaves out avatar, so an expanded { id, name, url } avatar value seeded from a row would be sent unchanged, where file/image are reduced to the id. This behaviour was there before this PR.

Pins: sound. Both drive the real AvatarField, through the host's own resolver or paramToField, under a real UploadProvider whose adapter holds its promise. Nothing is stubbed on the widget.

  • Controls.
    • The dialog pin asserts Confirm is enabled before the pick and that a click during the hold resolves nothing.
    • Both pins assert the control is enabled after the upload settles, and that the submitted payload is exactly the minted id.
  • The R1 leg is a genuine failure. At round 1, neither host passed onUploadingChange to avatar. Neither mounts an UploadingScopeProvider either. So the "disabled while held" assertion can only fail there. That matches the dev's quoted R1 result (2 failed, "Received element is not disabled") and ablation B (only the FormPage row fails).
  • This also pins the widget's onUploadingChange from true back to false, closing the round-1 nit. The failure path's release is not pinned, but reading the finally shows it is correct.

Changesets, read sentence by sentence.

  • 10785-avatar-submits-file-id.md (frontmatter unchanged, '@object-ui/fields': minor).
    • "every accepted pick" is correct.
    • The Clause-② line is correct: yes (narrowing). It names a public export and registered widget, the props type is unchanged (types.ts changes one JSDoc line only), and no accept set widens.
    • The mediaStrictEffective sentence matches objectstack record-validator.ts exactly: OS_ALLOW_LAX_MEDIA_VALUES=1 returns false first, then OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1 returns true, and otherwise the deployment flag decides. The flag is written by os migrate files-to-references --apply, and also at creation, since FILE_REFERENCES_MIGRATION_ID is in CREATION_ATTESTED_MIGRATION_IDS. avatar is in FILE_REFERENCE_TYPES.
    • The sentence naming the gating hosts is true: the routes are /f/:slug and /forms/:name.
  • 10785-avatar-upload-gates.md (app-shell patch, console patch). Every sentence matches the diff, and the English label is Uploading… for both hosts.
    • "went out without the avatar and reported success" was measured for FormPage. For the dialog only "Confirm enabled" was measured. This is acceptable as a description of the objectui#10167 class.
  • Console declaration: correct. apps/console/package.json has no private field, @object-ui/console is in .changeset/config.json's fixed group, and 10167-formpage-file-field-arm.md / 10178-… set the precedent ('@object-ui/console': patch).
  • There are no dated notes in either changeset.

Claim amendment: a correct deviation. The claim said "one @object-ui/app-shell changeset". The delta ships one changeset that also declares @object-ui/console, because FormPage lives in a released package of the fixed group. The seat should record it as: "one changeset, .changeset/10785-avatar-upload-gates.md, declaring @object-ui/app-shell patch and @object-ui/console patch".

PR body replacement (pr_body_replacement in comment 5858071727): true at head. Checked: the types.ts one-line JSDoc, the attestFreshDatastore / CREATION_ATTESTED_MIGRATION_IDS claim, video/audio mapping to field:file, the gate lines, the pin paths, the changeset account, and the "7 source files of 3 released packages" count. One sentence is dated and should be refreshed at landing. It is still true, not false. Replace the "Serial" paragraph with: "git merge-tree --write-tree of head 03eae19e77 against origin/main 67d4ed9083: clean (tree f5e1c91472). None of the touched paths moved on main since base 6cf599985d."

② Semver level

  • @object-ui/fields: minor with a BREAKING banner and Clause-② yes (narrowing). Round 1's grade stands, per ruling 5856395366.
  • @object-ui/app-shell: patch. @object-ui/console: patch. The fix restores a submit gate and narrows no published or authorable surface.
  • Nothing is major.
  • CI Changeset Bump Policy, Changeset Declaration and Changeset Fixed Group Check all succeeded.

③ Boundary flags

  • CI on head 03eae19e77: all 43 check-runs completed. 40 succeeded and 3 were skipped (Test (coverage), the coverage shard matrix placeholder, dependabot). None failed or is pending. The commit status (Vercel) succeeded.
  • Merge: git merge-tree --write-tree against main 67d4ed9083 is clean (tree f5e1c91472). Main is 10 commits past base 6cf599985d, and none of them touch the 9 PR paths. GitHub reports mergeable, clean.
  • Open PRs: none of the 15 other open PRs touch the 9 paths.
  • check-governed-merges.mjs: NOT governed (0 of 9 paths on the register). 628 changed lines, under the human-merge threshold.
  • Draft: yes. The seat flips it at landing.
  • Assignee: os-elon-musk, on both the PR and card finding(fields): AvatarField writes a data: URL into an avatar field, a value a verified ADR-0104 deployment refuses (avatar is a file-reference type) #10785.
  • Labels: the card carries needs:contract-review. The PR carries apps, tests, package: app-shell, package: fields.

Implemented-by: claude/issue-10785-avatar-file-id
Reviewed-by: session_014mXUNuFomfj24w7s1pZzhN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 17:45
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit ff94a12 Sep 27, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-10785-avatar-file-id branch September 27, 2026 17:59
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.

finding(fields): AvatarField writes a data: URL into an avatar field, a value a verified ADR-0104 deployment refuses (avatar is a file-reference type)

2 participants