Skip to content

feat(app-shell): the suggested-bindings panel shows each suggestion's state and revokes an acceptance (part of objectui#7611) - #12148

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-7611-suggested-bindings-states
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-7611-suggested-bindings-states

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #7611
Clause-②: yes (widening)

Draft by dispatch (objectui AGENTS.md §9 ②). This is C9 part 2: the SuggestedBindingsPanel follow-up recorded in 6110524912 and claimed by the amendment 6110601135, after objectstack#22854 (stage U2, d112087b8980). The epic PM of objectstack#15194 moves this PR. The rest of #7611 stays open; part 1 landed as 0df67f237c5a.

What objectui now shows

On a framework at objectstack main d112087b8980 or later, a manage_metadata holder's accept takes effect for the whole deployment, and a dismiss on an accepted row revokes it (objectstack#22801 → A). The panel on the marketplace package page and in the Studio Access pillar used to list pending rows only and dropped bindingCreated. It now lists every suggestion of the package (GET /api/v1/security/suggested-bindings?packageId=…, no status filter) and draws each in the state the server records:

Row on the wire The panel shows Actions
status: pending, anchor everyone the existing prompt Accept, Dismiss
status: pending, anchor guest the prompt, and "Cannot be granted here: the Guest position takes no sets from accepted suggestions." Dismiss only
status: confirmed, deployment_decision: true "SET was accepted for all signed-in users (the Everyone position)." Revoke
the same row, after this session's accept answered 200 with bindingCreated: false "SET was accepted but is not in force yet: the Everyone position did not change on this request. The server log says why." Revoke
status: dismissed "SET is not granted: the suggestion was dismissed or revoked." none
status: confirmed, deployment_decision: false (the baseline names the set) not drawn none
  • Revoke is the existing dismiss call (POST …/:id/dismiss) on an accepted row. The success toast says the acceptance was revoked.
  • Accept shows the existing success toast only when bindingCreated is true. On false it shows the not-in-force line as a warning toast, and the row keeps Revoke, because the acceptance is recorded. confirmSuggestedBinding now returns { suggestion, bindingCreated } instead of dropping the flag.
  • Refusals. A 403 or 409 on Accept, Dismiss or Revoke shows the server's own error.message, never a silent no-op. The panel then reads the list again, so the row shows what the server holds.
  • Kept: a 403 on the list (a caller without manage_metadata) still renders nothing. The list's .catch in the panel's mount effect still swallows the refusal.
  • The accepted line claims the decision, not that the set is in force. The list does not say whether everyone carries an accepted set, so "not yet in force" lasts until the panel re-reads. See the acceptance notes.
  • Styling. The panel keeps its amber look while a row awaits a decision. With only decided rows it uses the muted look. Each state has its own Lucide shield icon.

Why a guest row shows the reason instead of rendering nothing

The server refuses a guest accept with 409, and still accepts a dismiss of a pending guest row. The panel therefore draws no Accept, says why, and keeps Dismiss. This follows the convention part 1 of this card set (0df67f237c5a): a caller the server would refuse gets no affordance, and the page says why. It also follows objectui#12109's rule that an affordance the server refuses is not drawn. Rendering nothing would leave a pending row that no surface can show or dismiss. No producer emits a guest suggestion today, so this branch is dormant until one does.

Why a baseline row is not drawn

Its dismiss answers 409 ("this surface does not decide the baseline"), and its confirm answers 409 too. No action on it can succeed, and nothing is pending. Before this change the panel did not draw it either.

Measured against objectstack main

The measurement booted a showcase from a detached objectstack worktree at 55382dc02a (its origin/main, which contains d112087b8980). It ran through a throwaway, untracked dogfood-style file, deleted after the run, so git status in that worktree is clean. The driver was sqlite-wasm. Readings, in order:

  • With a vanilla SecurityPlugin (so showcase_member_default is a package suggestion), the list row is pending, with deployment_decision: false, resolved_by: null and anchor: "everyone".
  • The accept answers 200, { data: { suggestion, bindingCreated: true } }. The suggestion is confirmed with deployment_decision: true and resolved_by set.
  • The list after the accept returns the same row: status: "confirmed", deployment_decision: true (a JSON boolean) and resolved_by set. It has no field that says whether the set is in force.
  • A second accept answers 409 SUGGESTION_STATE: "… is already confirmed — only pending suggestions can be accepted."
  • The dismiss on the accepted row answers 200, and the suggestion is dismissed with deployment_decision: true. The list then shows the same. A revoked row and a dismissed pending row are identical on the wire.
  • A second dismiss answers 409: "… is already dismissed — only a pending or accepted suggestion can be dismissed." An accept on that row answers 409 ("… only pending suggestions can be accepted").
  • A signed-up member's list answers 403 PERMISSION_DENIED: "[Security] Access denied: listing audience-binding suggestions requires the manage_metadata capability. …"
  • On the stock showcase, the same set is a baseline row: confirmed, deployment_decision: false, resolved_by: null. Its dismiss answers 409: "… is observed, not accepted: the deployment's baseline gives the set to everyone, and this surface does not decide the baseline."

bindingCreated: false on a 200 could not be produced through the public door on that boot: the confirm and the derivation ask the same gates. It is NOT MEASURED live here. objectstack pins it in its unit suite, and #22854's ablation showed the flag turning false. The panel's handling is pinned below against the documented envelope.

Tests

The new packages/app-shell/src/components/SuggestedBindingsPanel.test.tsx has 15 cases. It renders the real panel and the real suggestedBindingsApi client over a stubbed fetch that answers the server's envelopes, with row shapes and messages taken from the measurement above. There was no test of this component before. The file follows app-shell's convention for a component with a stubbed transport: vi.stubGlobal('fetch', …), sonner mocked, and @object-ui/components imported at module scope.

  • One test per state: pending, accepted, dismissed or revoked, baseline not drawn (with a control that a baseline-only panel renders nothing), and guest (no Accept, the reason shown, Dismiss kept). One test pins that a 403 on the list renders nothing, and one that the list asks for every status.
  • One test per action: Accept with bindingCreated: true; Accept with bindingCreated: false (not in force, a warning and no success toast); Dismiss; Revoke (the dismiss route, never confirm); Revoke refused 409 (the message is shown, the list is re-read, and the row takes the server's state); Accept refused 403; and Revoke refused 403 followed by a list 403 (the message is shown, then the panel renders nothing).

Ablation (one-time, not kept). Each mutation went through objectstack's scripts/ablation-replace.mjs, which checks that the anchor hit and that the restore holds. Each restore read blob == HEAD with git diff HEAD empty.

  • bindingCreated: data?.bindingCreated === true → bindingCreated: true: exactly the not-in-force test went red (1 failed, 14 passed).
  • return s.anchor === 'guest' ? 'guest' : 'pending'; → return 'pending';: exactly the guest test went red (1 failed, 14 passed).
  • s.status === 'confirmed' && s.deployment_decision === true → s.status === 'confirmed': exactly the two baseline tests went red (2 failed, 13 passed).

At head 8d3b3a0eb (all runs under the shared verify lock):

  • pnpm exec vitest run over the new file, packages/app-shell/src/console/marketplace/ (its 29 test files mock the panel), the three Studio Access-pillar suites, studio-locale.i18n, select-placeholder-literal-11252, all of packages/i18n/, and the three i18n script tests: 120 files passed, 1812 tests passed, 13 skipped. The skips are pre-existing and none is in the new file.
  • pnpm --filter @object-ui/app-shell --filter @object-ui/i18n run type-check, which is tsc --noEmit plus tsc -p tsconfig.test.json: both Done. --listFiles on app-shell's test project lists the new test file once.
  • packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts went red on the first run. It pins the de census, and the five new de values add seven matched „…“ spans. The census is moved 77 → 84 with rdq 0, and a history line is added like those before it.

Narrowed, and declared. The whole of packages/app-shell/ (1,271 test files) was started at --maxWorkers=2. After 19 minutes it had not finished, and a sibling's build was queued behind it. I stopped it before it printed a result, so that run is NOT MEASURED. CI's pnpm test runs the whole package. The diff changes no export and no shared module outside the panel, its client, the two mounts' string objects and the locale tables.

Gates

At 8d3b3a0eb, each exit 0: check-i18n-call-site-keys, check-i18n-en-drift, check-i18n-designer-table-parity, check-control-bytes, check-new-cross-file-line-citations (0 new), check-changeset-presence (17 source files of 2 released packages, 1 changeset), check-changeset-no-major, check-vi-mock-override-shape, check-test-path-roots. check-i18n-dead-keys is report-only and names none of the new keys. A control-byte self-scan over the diff found nothing.

ESLint (--no-inline-config, --format json) over the 17 touched TS files reports 1 error and 29 warnings. The base file under the same flags reads the same: StudioDesignSurface.tsx 1 error and 14 warnings, MarketplacePackagePage.tsx 0 and 11, suggestedBindingsApi.ts 0 and 4. The error is the pre-existing react-hooks/static-components finding in that file, far from this diff. The panel goes from 1 warning to 0. The population is each package's own eslint . under the root eslint.config.js. That config enables no type-aware linting, so this diff cannot change a verdict on an untouched file. The repo-wide pnpm lint is CI's.

NOT MEASURED locally: check-eager-closure-budget and check-eager-locale-catalogues, which need a full console build and are CI's (performance-budget.yml). The en pack grows by seven short values. A browser drive of the panel is also not measured: a stock showcase has no pending suggestion for the panel to draw, and a vanilla-plugin boot needs an objectstack configuration change this card does not make. The wire was measured instead, above.

Changeset

.changeset/7611-suggested-binding-states.md: @object-ui/app-shell minor (a new user-visible action) and @object-ui/i18n minor. Clause-②: yes (widening): seven locale keys are added to the published packs (marketplace.suggestedBindings.guestUnsupported, acceptedState, notInForceState, dismissedState, revoke, revoking, revokedToast), which widens TranslationKeys. No export, prop or accept set moves. The panel and its client are not exported from @object-ui/app-shell. The Studio Access pillar's own table gains the matching seven engine.studio.access.suggest… keys in English and Chinese.

No doc names this panel: a grep of content/ and every *.md outside changesets and changelogs finds nothing, so no doc is updated.

Acceptance notes (out of scope; carrier: none named)

  • The list cannot say whether an accepted set is in force. An accepted row reads the same whether everyone carries the set or the derivation withholds it. A withhold happens when a later publish adds a forbidden bit, or the catalog stops serving the set. So the panel's accepted line claims the decision only, and "not yet in force" survives only until the panel re-reads. Closing that would take a server field on the list row. That is an objectstack contract question, not an objectui fix.
  • A revoke is final on this surface. A revoked row is dismissed with a deployment decision. The reconcile never re-opens such a row, and confirm takes pending only. Re-accepting a revoked set therefore has no route here, which the measurement above shows (409 on confirm after a revoke). Dismissing a pending row was already final before #22854.
  • A revoked row and a declined row are the same on the wire, so after a re-read the panel says "dismissed or revoked" for both.

Size

Against base 158c75d (git diff --shortstat 158c75d 8d3b3a0eb): 18 files, 667 additions and 79 deletions, so 746 changed lines. The new test file is 288 of them, and the ten locale packs are 70.


Generated by Claude Code

…and revokes an acceptance

After objectstack#22854 (#22801 -> A) an accept by a manage_metadata holder
takes effect for the whole deployment and a dismiss on an accepted row
revokes it. The panel listed pending rows only and dropped bindingCreated.

It now lists every row of the package and draws pending (Accept / Dismiss),
a guest row (no Accept, the reason instead, Dismiss), accepted (Revoke,
through the dismiss call), accepted but not yet in force (a 200 answered
bindingCreated: false) and dismissed or revoked. A baseline row is not
drawn. A 403 or 409 on an action shows the server's message and re-reads
the list; a 403 on the list still renders nothing.

Part of objectui#7611.

Claude-Session: https://claude.ai/code/session_01Rerax7QTjKMPCUZxQUtPFR
Co-authored-by: Claude <noreply@anthropic.com>
…ell, i18n minor)

Also types the panel's catch as unknown and keys the not-in-force set on
the accept response's id.

Part of objectui#7611.

Claude-Session: https://claude.ai/code/session_01Rerax7QTjKMPCUZxQUtPFR
Co-authored-by: Claude <noreply@anthropic.com>
…pans

The five new de values quote the set and the position with matched „…“
pairs, seven spans between them; the census moves 77 -> 84 with rdq 0.

Part of objectui#7611.

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 291 chunks) 3176.8 KB 3204.6 KB
Main entry chunk (gzip) 74.1 KB 350 KB
Entry file index-CsXULeoz.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.05KB 7.41KB
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.11KB 7.97KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 587.31KB 141.42KB
core (index.js) 10.18KB 4.04KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 240.39KB 67.09KB
fields (index.js) 275.07KB 70.00KB
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) 54.31KB 15.86KB
plugin-charts (index.js) 84.72KB 23.27KB
plugin-chatbot (index.js) 201.52KB 47.99KB
plugin-dashboard (index.js) 144.20KB 38.95KB
plugin-designer (index.js) 233.53KB 49.80KB
plugin-detail (index.js) 250.21KB 66.02KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 177.80KB 46.04KB
plugin-gantt (index.js) 179.37KB 45.17KB
plugin-grid (index.js) 255.15KB 70.92KB
plugin-kanban (index.js) 53.23KB 16.71KB
plugin-list (index.js) 122.77KB 31.19KB
plugin-map (index.js) 27.24KB 9.03KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.12KB 12.29KB
plugin-timeline (index.js) 39.21KB 11.85KB
plugin-tree (index.js) 15.07KB 5.33KB
plugin-view (index.js) 92.11KB 23.27KB
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.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.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: CONTRACT_REVIEW_TIER
Head-sha: 8d3b3a0eb6241743dacd421666900c8615033d53
Local-runs: none

Inputs: card #7611 (body and all 25 comments — the part-1 records, the scoping follow-up 6110524912, the claim amendment 6110601135 and the dev report 6111080375 among them), PR #12148 (body, 18-file list, net diff against main at 8d3b3a0eb6; merge base 158c75d908, 3 commits, branch claude/issue-7611-suggested-bindings-states, a mode:subagent dev), the server contract objectstack#22854 (body, merged d112087b8980; its contract record 6109964380, flags B and C; the ruling record 6108478638 on objectstack#22801 → A), suggested-audience-bindings.ts and the three routes in rest-server.ts read at d112087b8980, objectui AGENTS.md at the base, and the check-runs on 8d3b3a0eb6, read twice (the second time at 2026-10-11T16:30Z, immediately before this record). For context only, contents?ref= reads of app-shell's package.json and src/index.ts, i18n's src/index.ts and package.json at the base, and the two mounts and the client at the head. Nothing was built, run or re-run.

① Derived judgments

Each line names a change the diff implies, then judges it against the server at d112087b8980 or the rule that governs it.

  1. The list asks for every status. listSuggestedBindings(packageId ? { packageId } : {}) drops the old status: 'pending' filter; the server's list route takes status and packageId as optional filters and reconciles first, so the decided rows the new states need are returned in one call. Both mounts pass a string packageId (the marketplace page draws the panel only inside localResult.ok && suggestFor, and the install dialog passes pkg.manifest_id; AccessPillar takes packageId: string), so no mount lists across packages. Right.
  2. rowState. pending on everyone → Accept and Dismiss; pending on guest → no Accept, the reason line, Dismiss kept; dismissed → the "not granted" line, no action; confirmed with deployment_decision === true → accepted (Revoke) or, when this session's accept answered bindingCreated: false, not in force (Revoke kept); confirmed without the flag → not drawn. The server at the head: confirmAudienceBindingSuggestion takes pending only, then refuses a non-everyone anchor 409; dismissAudienceBindingSuggestion takes pending or confirmed ∧ isDeploymentDecision and refuses everything else 409, the observed baseline row with its own sentence; the sync re-opens a Default-Organization row resolved without the flag, so a dismissed row without a deployment decision cannot come back from a list that reconciles first. The panel draws exactly the actions the server can honour and none it refuses, which is part 1's convention (0df67f237c5a) and objectui#12109's rule. Right. One boundary on the predicate's spelling is carried in ③ (flag A).
  3. Accept surfaces bindingCreated (contract record flag B). confirmSuggestedBinding now returns { suggestion, bindingCreated } with bindingCreated: data?.bindingCreated === true; the panel shows the success toast only on true, otherwise a warning toast with the not-in-force line, records the row id in notInForce, and keeps Revoke because the row is confirmed with the flag, which the server revokes. An envelope that omits the flag reads as not in force — fail-closed, the right direction for a grant claim. Right.
  4. Revoke is the dismiss call on an accepted row. No new route; act(s, 'revoke') posts …/:id/dismiss, the toast says revoked, and the row takes the dismissed state the server answers. This is ruling decision 2 and judgment 8 of 6109964380. Right.
  5. Refusals are shown, then the server's state is re-read. 403/409 on Accept, Dismiss or Revoke → toast.error(err.message) from the ADR-0112 envelope the client already parses, then reread(); a 403 on that re-read sets rows to [] and the panel renders nothing. Right.
  6. A caller without manage_metadata sees nothing (flag C). The mount effect's .catch still swallows the list refusal; nothing renders. Right.
  7. A baseline row is not drawn. confirmed without a deployment decision answers 409 on both actions at the head, so no affordance exists for it; before this diff it was not drawn either. Right.
  8. Public surface. @object-ui/app-shell has one entry (. → dist/index.js), and src/index.ts at the base exports neither components/SuggestedBindingsPanel nor services/suggestedBindingsApi; so the seven required fields added to SuggestedBindingsStrings, the confirmSuggestedBinding return type and the optional deployment_decision on SuggestedBinding are internal moves, and both in-repo consumers supply the seven strings in this diff. What publishes: seven marketplace.suggestedBindings.* keys in all ten packs of @object-ui/i18n — TranslationKeys is derived from en, so the key union widens, and the published en dictionary gains seven values; the Studio pillar's engine.studio.access.suggest… rows live in metadata-admin's own en/zh tables inside app-shell. No export, prop, route or accept set is removed or narrowed. Right.
  9. onResolved now also fires after a revoke, with the server's row; no mount passes it. Right.
  10. Tests. SuggestedBindingsPanel.test.tsx (new, 15 cases) renders the real panel over the real client and a stubbed fetch answering the measured envelopes: one case per state (pending, accepted, dismissed/revoked, baseline not drawn plus its control, guest), the list-403 case and the no-status-filter case; one per action (accept true, accept false, dismiss, revoke through the dismiss route, revoke 409 with re-read, accept 403, revoke 403 then list 403). Three one-time ablations each turned exactly the targeted tests red. The de census moves 77 → 84 with its history line, in the same PR. Right. vi.stubGlobal is undone in afterEach, @object-ui/components is imported at module scope (AGENTS.md § 测试纪律); Inert vi.mock Specifier Check is green.
  11. Repository rules. No cross-file path:line citation in the diff (rule [WIP] Update documentation for project #11; Line Citation Gate green). A dispatched PR stays draft (§9 Operational Rules, exception ②): the PR is a draft, Part of #7611, under claim amendment 6110601135. No governed path in the file list; Governed Surface Queue Guard green. Right.
  12. Gate verdicts on 8d3b3a0eb6 (second read, 2026-10-11T16:30Z): 42 check-runs, 31 success, 3 skipped (Test coverage shard matrix, Test coverage, dependabot — path and actor filters), 0 failed. Success on Type Check, Lint, Build & E2E, Test (dist pins), Test (shard 8/8), Bundle Analysis, Build Docs, the five Changeset gates, Control Byte Scan, Governed Surface Queue Guard, Inert vi.mock Specifier Check, Line Citation Gate, Pre-Install Import Graph Check, README Export Check and the doc/skill gates. Eight were still in progress at this record: Spec Main Shape Gate and Test (shard 1/8) through Test (shard 7/8). The PR's Console Performance Budget comment (6111094338) reads the eager closure at 3176.8 KB under the 3204.6 KB budget, which answers the dev's NOT-MEASURED check-eager-closure-budget. The judgments above are judgments of the diff and do not depend on the eight; the owning seat reads them before queueing, and the landing rule refuses a red one.

② Semver level

  • .changeset/7611-suggested-binding-states.md — @object-ui/app-shell minor, @object-ui/i18n minor, body ends **Clause-②: yes (widening)**. Right on all three. A new user-visible action (Revoke) and new drawn states are a feature, so minor; seven keys added to the published packs widen TranslationKeys and the en dictionary, so the i18n entry is owed and (widening) is the arm this diff earns; nothing removed or narrowed, so no breaking banner and no migration text is owed, and no major (the fixed-group rule, check-changeset-no-major). The body names the states, the revoke, the refusal handling, the baseline row and the seven keys, and carries no model identifier. Changeset Declaration, Bump Policy, Fixed Group Check, Overwrite Report and Claim Re-read are green on the head.
  • objectui AGENTS.md at the base carries no Clause-② text of its own; its changeset rules are met: published src/ of two fixed-group packages changed (17 source files), one changeset declares it, no major.
  • Clause-②: line — the PR body's second line reads Clause-②: yes (widening), at the start of the line, one arm from the closed pair, matching the changeset. The claim amendment 6110601135 set no value of its own. Right.

③ Boundary flags

Dev deviations (4, report 6111080375) — each answered:

  1. No existing test style for this component; the new file follows app-shell's stubbed-fetch convention — right, and the two vi.mock gates are green. Closed.
  2. The changeset names @object-ui/i18n as well — right (② above). Closed.
  3. Duplicate-work check through the repo-scoped pulls list because the search API is refused — the serial constraint of 6110601135 (no open PR touching the panel or its client) is met on that reading; nothing else owed. Closed.
  4. The whole packages/app-shell suite narrowed locally — CI's Test shards own it; seven of eight were still running at this record (①.12). Not a contract matter.

Open questions (2) — each answered on the record:

  1. Should the list row carry an in-force signal? — B is right for this head: the wire at d112087b8980 has no such field, the accepted line claims the decision only, and bindingCreated: false is surfaced for the session. A durable signal is an objectstack contract question (a read-only per-row field derived from the everyone definition the list already reads). Escalated to the owning seat as an optional objectstack follow-up in the #15204 family; the panel needs no change either way, so this does not hold the head.
  2. Is a revoke final? — A, by design at the head: confirm takes pending only and dismiss of a dismissed row is 409 (judgments 7 and 8 of 6109964380; ruling decision 2). The panel offers no Accept on a dismissed row, which matches. Should the server later let confirm take a dismissed deployment-decision row, that state gains an Accept then. Not escalated.

Out-of-scope findings (2, carrier none) — accepted as filed; they are the two questions above, and the first is escalated.

Reviewer's flags (3) — none fails the head:

  • A. The deployment-decision predicate is spelled narrower than the server's. The panel reads s.deployment_decision === true; the server's own isDeploymentDecision accepts === true || === 1. The dev measured the list answering a JSON boolean on sqlite-wasm, the field is declared boolean, and the test fixtures use booleans, so the contract as measured holds. If any driver ever surfaced 1, an accepted row would read as a baseline row — not drawn, its Revoke with it, and gone from the panel right after an Accept. A one-line mirror of the server's predicate closes it. Carrier: the C9 lane, at the seat's option now or at the next touch of the panel.
  • B. notInForce outlives a re-read. The set is keyed by row id and never cleared, so after reread() a confirmed row still draws as not in force until the panel unmounts; only a remount reads it as accepted. The code is the better behaviour (an error-triggered re-read does not erase the warning), the changeset's "after a reload" states it right, and the panel's doc comment and the PR body's "until the panel re-reads" overstate the transience. A wording nit, no action owed.
  • C. A declined row and a revoked row are one wire shape, so both draw as "dismissed or revoked" with no action — measured identical by the dev and inherent to the server's record. Right as shipped; it is the same question as open question 1's signal, and goes with that carrier if the seat files it.

Implemented-by: claude/issue-7611-suggested-bindings-states
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 16:45
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 16:45
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit f962cdc Oct 11, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-7611-suggested-bindings-states branch October 11, 2026 17:03
zhuangjianguo pushed a commit that referenced this pull request Oct 11, 2026
Brings main to faf4ce9 (objectui#12151, #12152, #12153, #12154, #12148,
#12149, #12147, #12158, #12159); none of them touches this branch's files.

Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants