Skip to content

fix(app-shell,plugin-detail,react): offer approval recall to the submitter only - #6485

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-6464-withdraw-submitter-gate
Aug 26, 2026
Merged

fix(app-shell,plugin-detail,react): offer approval recall to the submitter only#6485
os-support-ai merged 1 commit into
mainfrom
claude/issue-6464-withdraw-submitter-gate

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#6464

All evidence below was produced on 977b301f1, which is this branch's final commit — the tree every run measured.

The defect, and where it actually lives

The card says withdraw; the code says cancelApproval / Recall. Censusing the shape rather than the word also moved the site: the button is not in RecordApprovalsPanel.tsx (that file has only the Remind lever). It is the approval band's recall button in packages/plugin-detail/src/DetailView.tsx, and its only gate was

{dataSource?.cancelPendingApproval&&(

— a question about the adapter ("can this DataSource recall at all"), never about the viewer. So every reader of a pending record got a lit button, which matches the field report exactly. The English text the reporter saw concatenated after a localized prefix is thrown by packages/data-objectstack/src/index.ts (NO_PENDING_REQUEST, 404) when the request list comes back empty for a non-submitter.

RecordApprovalsPanel.tsx:266-274 was still the right precedent — its Remind gate states the source order — but it is twenty lines from a different control in a different package.

The fix

Identity now joins the adapter gate, threaded the way every other band signal already travels: the host resolves it, the renderer stays DataSource-agnostic.

packagechange
@object-ui/reactInlineEditProvider accepts approvalIsSubmitter, surfaced on InlineEditContextValue
@object-ui/plugin-detailthe band's recall button is withdrawn on a resolved false
@object-ui/app-shellRecordDetailView resolves the verdict from its existing approvals read and threads it

Tri-state, and the third state is load-bearing.true offers recall, false withdraws it, and undefined — a host that resolves no approval identity — renders exactly as before. Omission preserving prior behaviour mirrors how approvalPending falls back to locked. Defaulting the unknown case to hide would have traded a cosmetic defect for a functional loss: every host whose band runs off the record's approval_status mirror alone would silently lose its submitter's only way to unlock their own record.

Hide, not disable-with-reason. The card offered either; this picks hide, on three grounds. For a non-submitter this control is never actionable on any pending record, so a permanently disabled button is standing clutter rather than a lesson. Both sibling submitter levers already hide — the approvals panel's Remind button, and the declared approval_recall action's visible predicate — so disabling here would make one of three identical levers behave differently. And nothing is withheld by hiding it: the band, its quorum tally and the approvals timeline still tell a non-submitter exactly what state the record is in.

This changes no permission.canEdit and the approval lock are untouched, the recall endpoint remains the sole authority, and nothing downstream reads approvalIsSubmitter as an authorization verdict — it decides who is shown the entry, not who may act. There is deliberately no admin carve-out (the reporter ruled that case out). The refusal-message i18n half is not addressed here; out of scope: it stays on objectstack#11993.

One derivation, not two

isSubmitterOf is now the single answer to "did this viewer submit this approval" — server-resolved viewer.is_submitter first, id comparison as the pre-framework#3310 fallback, joined with ?? so a server that resolved false is believed rather than re-litigated client-side. The Remind gate, which carried that expression inline, now reads the same function; its behaviour is unchanged and its existing suites are run below as controls. Two copies would have been two definitions of who submitted — the hazard utils/approverIdentity already exists to prevent on the display side.

Reverse verification — both halves, prediction stated before each run

Direction predicted before each run, and printed into the log ahead of it. Restore proven by blob-hash equality against HEAD plus an empty git diff HEAD, under an EXIT/INT/TERM trap holding absolute paths; mutation proven by grepping the injected and the deleted text, never a diffstat.

No rebuild is needed for either leg, and that is a property of this repo, not an omission.vitest.config.mts aliases @object-ui/react and @object-ui/plugin-detail to their own src/, and both suites import the mutated modules relatively (../DetailView, ./RecordDetailView). Nothing here resolves a dependency through its dist/ exports, so a rebuild would change nothing these runs read.

Leg A — ablate the band's gate (DetailView.tsx → base a672ae917). On-disk proof: deleted text inline?.approvalIsSubmitter !== false count 0, restored bare gate count 1, guard comment count 0.

Test Files 1 failed | 4 passed (5)
Tests 3 failed | 44 passed (47)

The 3 red are exactly the 3 predicted discriminating cells (non-submitter pending, non-submitter locked, the re-render walk). The 4 control cells in the same file stayed green — submitter keeps it, undefined unchanged, adapter-cannot-recall — as did all of RecordDetailView.approvalRecallGate, isSubmitterOf, DetailView.approvalBand and RecordApprovalsPanel.

Leg B — ablate the host threading (RecordDetailView.tsx → base). On-disk proof: deleted prop 0, deleted isSubmitterOf(...) call 0, deleted comment 0, injected base import 1.

Test Files 1 failed | 2 passed (3)
Tests 4 failed | 16 passed (20)

The 4 red are the four resolved-verdict cells. The fifth test in that same file — "threads undefined — not false — when there is no request to consult"stayed green, which is the cell that proves this leg isolated the seam rather than flipping everything.

Both restores: worktree blob == HEAD blob, git diff HEAD empty, git status --porcelain empty.

Tests

Every absence assertion is paired with a positive one in the same query (role="status" band + role="progressbar" tally), so "the button is not in the document" is a measurement rather than a blank page. The re-render walk (false → true → false → undefined) doubles as the isolation pin: each assertion has already seen the previous verdict on the same mount.

Test Files 10 passed (10)
Tests 102 passed (102)

New: DetailView.approvalRecallGate.test.tsx (7), useRecordApprovals.isSubmitterOf.test.ts (9), RecordDetailView.approvalRecallGate.test.tsx (5). Controls run alongside: DetailView.approvalBand, InlineEditContext, RecordApprovalsPanel ×3, RecordDetailView.approvalDeclaredActions, approvalOverride.

type-check green in all three packages (TYPECHECK_EXIT captured directly, not through a pipe; script names echoed, so no zero-match false green). Both pin files confirmed in the tsc program via tsc -p tsconfig.test.json --listFiles — plugin-detail 1, app-shell 2.

Clause ② — is the published surface widened? Yes, in one package. Stated in as many words:

The entry-file diff (git diff --stat -- '**/src/index.ts') is empty, and that does not settle it.

  • @object-ui/react — WIDENED.src/index.ts does export * from './context/index.js'export * from './InlineEditContext.js', so InlineEditContextValue and InlineEditProviderProps are already exported by name, and this PR adds an optional member to each. Confirmed in the built artefact: packages/react/dist/context/InlineEditContext.d.ts carries approvalIsSubmitter?: boolean; at both sites, reachable from dist/index.d.ts. Additive and optional — no existing consumer stops compiling — but it is a widened surface, and the changeset marks this package minor for exactly that reason.
  • @object-ui/app-shell — NOT widened.isSubmitterOf is internal. Two named-only hops (src/index.tshooks/index.jsuseRecordApprovals.js), neither naming it; zeroexport * in either barrel; package.json#exports publishes only .. This is settled from source, not from dist/packages/app-shell/dist does not exist in this worktree, so a dist grep here would have proven nothing.
  • @object-ui/plugin-detail — NOT widened. A JSX condition and comments; no type declaration changed, DetailViewProps untouched.

Gates

Verdict lines as each gate printed them; exit codes captured before any pipe.

gateexitverdict
check:control-bytes0✅ check-control-bytes: OK (scanned 5360 tracked text file(s); skipped 85 binary).
check:vi-mock-specifiers0✅ check-vi-mock-specifiers: OK (… 451 carry a mock; 701 relative specifier(s) resolved …)
check:phantom-deps0✅ Every in-scope import is declared by the package that publishes it.
check:self-import0✅ No package names itself inside its own src/.
check:entry-guard0✓ check:entry-guard: 50 scripts/ file(s) — no entry guard outside the baseline
check:i18n-keys0Every in-scope call-site key resolves against the en pack (2856 keys) …
changeset presence0✅ 5 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)
changeset no-major0✅ No changeset declares a 'major' bump.

Two gates are NOT MEASURED locally — a missing prerequisite, read as neither green nor red:

  • check:readme-exports exits 1 with ❌ 69 self-import(s) could not be judged, every entry carrying the gate's own remedy "run pnpm build first". Only dependency closures were built here, so seven packages have no dist/index.d.ts. None of the 69 names plugin-detail — the one README this PR touches — whose dist/index.d.tsdoes exist (built as a dependency of app-shell), so its self-import at README line 58 was judged and passed. The added bullet contributes 0 imports and 0 code fences, so it cannot move this gate either way.
  • check:eager-closure exits 2 with ❌ No eager-closure report at apps/console/dist/eager-closure.json … This is a broken gauge, not 3 budgets that all passed. It needs a console build. Both are CI's to run against a full build.

A raw control-character scan was also run outside the gate over all ten changed files (grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'): no hits.

Lint — narrowed, and the narrowing is measured

Not a repo-wide pnpm lint; eslint was run over the changed .ts/.tsx set. Three pieces of evidence, so this is a measurement rather than a skip:

  1. Population from eslint's own config, not a guess.eslint.config.js scopes rules to files: ['**/*.{ts,tsx}'], so the two Markdown files in this diff are outside the linted population entirely.
  2. File count from --format json:8 files linted, ESLINT_EXIT=0, 0 errors, 191 warnings.
  3. Why nothing outside the set can move. eslint here is not type-awaretseslint.configs.recommended (not recommendedTypeChecked), and zero occurrences of project / projectService anywhere in eslint.config.js. Every file's verdict is computed from that file plus static config, so a diff confined to these files cannot change the verdict of any file it did not touch.

Intersecting eslint's reported line numbers with the lines this diff adds: on the 5 modified files, 183 pre-existing warnings, 0 of them on a line this diff added. The remaining 8 are on the new test files, all @typescript-eslint/no-explicit-any on fetch / metadata / DataSource stub casts — the house pattern of the sibling harness (RecordDetailView.approvalDeclaredActions.test.tsx, 7 such casts).

Notes for review

  • Not landing this myself — draft, no auto-merge, per dispatch.
  • Worth relaying to objectstack#11993: the mixed-language refusal text may not be server-emitted at all. "No pending approval request found for this record" is thrown client-side by packages/data-objectstack/src/index.ts when the request lookup returns no visible pending row — the shape a non-submitter gets. That card is scoped domain:services on the premise the server emits it; the string is at least also present on this side. Not touched here.

Generated by Claude Code

…itter only
The record page's approval band lit its Recall button for every reader of a
pending record. Recall is authorized server-side on submitter identity and
refused to everyone else, so a non-submitter was handed a lever whose click
could only produce a failure toast — a writability-feedback mismatch, not a
permission question. Record state was never at risk.
The button's only gate was `dataSource.cancelPendingApproval`, which asks
whether the ADAPTER can recall, not whether the VIEWER may. Identity now joins
it, threaded the way the rest of the band's signals already travel: the host
resolves it and passes `InlineEditProvider.approvalIsSubmitter`, so the
renderer stays DataSource-agnostic.
The signal is tri-state and the third state is load-bearing: `undefined` — a
host that resolves no approval identity — renders exactly as before, so no
host silently loses its submitter's only unlock lever. Withdrawn rather than
disabled-with-reason, matching the sibling submitter levers (the approvals
panel's Remind, the declared `approval_recall` action's `visible` predicate).
`isSubmitterOf` is now the one derivation of who submitted — server-resolved
`viewer.is_submitter` first, id comparison as the pre-framework#3310 fallback,
joined with `??` so a server `false` is believed rather than re-litigated. The
Remind gate reads the same answer; its behaviour is unchanged.
No permission changes: `canEdit` and the approval lock are untouched, nothing
downstream reads this as authorization, and there is deliberately no admin
carve-out. The untranslated refusal text seen in the same report is tracked
separately on objectstack#11993 and is out of scope here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation package: react plugin tests labels Aug 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3234.4 KB3266.6 KB
Main entry chunk (gzip)157.4 KB350 KB
Entry fileindex-uuulaHp2.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.30KB4.28KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.99KB114.64KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.66KB12.84KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.60KB44.82KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)211.90KB42.74KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)164.14KB39.87KB
plugin-grid (index.js)201.66KB54.57KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.74KB27.50KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.82KB20.79KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)56.69KB19.03KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.05KB1.04KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationpackage: reactplugintests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[approvals] The "withdraw approval" button renders for non-submitters on a pending record — a lit button whose click always fails

2 participants

@os-support-ai@claude