Skip to content

feat(console): the settings save renders the crypto-unavailable refusal as a first-class state (#4570) - #4579

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4570-crypto-unavailable-refusal
Aug 13, 2026
Merged

feat(console): the settings save renders the crypto-unavailable refusal as a first-class state (#4570)#4579
yinlianghui merged 1 commit into
mainfrom
claude/issue-4570-crypto-unavailable-refusal

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4570

A deployment with nothing able to encrypt a declared-secret setting refuses the write. Since objectstack#8396 the framework says so in its own wire envelope — status 500, error.code SETTINGS_CRYPTO_UNAVAILABLE, error.details locating the refused { namespace, key }, and error.message carrying the operator prescription.

The console read none of it. SETTINGS_CRYPTO_UNAVAILABLE fell through to the generic error path, where extractFieldErrors finds no details.fields array and returns null — so nothing was marked, nothing was rendered, and the whole refusal collapsed into one transient "save failed" toast. The admin was told the save failed; that the deployment cannot encrypt, and which key it refused, was on the wire and thrown away.

The shape, mirrored from the SETTINGS_LOCKED precedent

Measured first, then mirrored (SettingsView.tsx ~:152): detect on error.code, locate the subject from the declared error.details slot, frame it in the console's own copy.

  • The refused key is named as namespace.key, read from error.details.
  • The server's prescription renders verbatim. The server owns that copy — the console frames the refusal but never restates how to fix it, because a second wording is how the two drift into disagreeing.
  • One deliberate difference from LOCKED, and it is the point of the card: LOCKED is a transient toast, while this refusal names an operator prescription that has to survive long enough to be acted on, so it also renders as a persistent panel. The toast still fires, mirroring LOCKED's Locked by environment: KEY with a code-specific Cannot encrypt secrets: ai.api_key.
  • The draft is kept, so the value is not lost while the deployment is reconfigured.
  • The refusal clears only when its claim can actually have become false: a new save attempt, a save that succeeds, a discard, or a reload. It deliberately does not clear on editing the key — that is a field-error semantic (a field error describes the value the server saw, so typing contradicts it), and typing does not make a deployment able to encrypt.

Red-first, with the wire envelope as the fixture

Predictions were written before the first source edit. Pre-fix baseline, measured on the unmodified view: Tests 5 failed | 3 passed (8), every failure the same cause — Unable to find an element with the text: /cannot encrypt secrets/i. Post-fix: 8 passed.

Reverse verification removed only the source fix via git checkout origin/main -- SettingsView.tsx (never git stash), keeping the tests: 5 failed | 36 passed (41) — the 5 red are exactly the new cases, and the other four settings test files stayed green. Restored and verified byte-identical by sha256 (141e4108...).

One honest correction to the written prediction: the never-the-value case was predicted green-and-vacuous, on the assumption it would be a document-wide absence check that trivially holds when no panel exists. It was instead written to await the panel first and then read only that panel's textContent, which makes it impossible to pass vacuously — so it joins the red set pre-fix. Stronger construction, same underlying reasoning about the pre-fix DOM.

Pins

  • SETTINGS_LOCKED and SETTINGS_VALIDATION are byte-untouched. The entire diff contains exactly one deleted line — the lucide-react import, re-added with ShieldAlert. Everything else is pure insertion. Both sub-blocks were hash-compared against origin/main with identical anchors and are identical: LOCKED d15e7d41..., VALIDATION 662cca45....
  • The fallback must not narrow — an unrecognized code still takes the generic path (toast, no panel, no field marks). Adding a branch to a code chain is exactly how an unknown code stops being handled.
  • The value is never rendered. The envelope locates the refusal and deliberately does not carry the secret, and the console does not re-introduce it from the draft it is holding. The assertion is scoped to the panel's own textContent — the secret is legitimately inside the input the admin typed it into, so a document-wide query would assert the wrong thing.

Verification

  • pnpm exec vitest run --maxWorkers=2 apps/console/ — 46 files, 520 tests, all green.
  • Both tsc passes for the console (tsc --noEmit, tsc -b tsconfig.node.json --force) after a dependency-closure build — both exit 0.
  • ESLint net zero: SettingsView.tsx is 6 warnings / 0 errors on origin/main and 6 warnings / 0 errors here; the new test file contributes 0.
  • check:control-bytes, check:phantom-deps, check:i18n-keys, check:i18n-drift, changeset presence and the no-major guard all green. The i18n drift gate confirms 0 locale values changed.

Declared deviations from the dispatch's presumptions

  1. Package is @object-ui/console, not @object-ui/app-shell. The card expected an app-shell view and an app-shell patch changeset. There is exactly one SettingsView.tsx in the repo and it is apps/console/src/pages/settings/SettingsView.tsx; app-shell has no SettingsView and must not be bumped. Changeset names @object-ui/console: patch, and the PR title is scoped console for the same reason.
  2. No i18n keys added. Measured channel: SettingsView routes zero framing copy through i18n — 'Settings saved', 'Save failed', 'Locked by environment', 'Action failed' and the rest are all hard-coded English, including the very branch being mirrored. Its sibling SettingsHub does use t('console.settingsHub.*'), whose keys live across ten locale packs in packages/i18n — outside this card's surface. Routing one string through i18n here would leave a single translated string among a dozen hard-coded ones in the same file, drag ten packs into the surface, and walk into the objectui#4514 provider-less trap: these tests mount no I18nProvider, and t() outside a provider renders the raw key.
  3. No api.ts edit. lockedKeyOf lives there because it is a dual-position compat shim (declared details.key plus the pre-objectstack#4224 sibling). This code is new in #8396 with exactly one declared position, so there is nothing to reconcile and the read stays inline in the view.
  4. Envelope source. The read-only sibling checkout is at 5d24f4b94 (#7166), which predates #8396 — it still carries the base64 NoopCryptoAdapter with no refusal at all, so the envelope could not be re-confirmed against framework source and was taken from the issue body, which the dispatch names authoritative.

Generated by Claude Code

…al as a first-class state (#4570)

A deployment that cannot encrypt a declared-secret setting refuses the write,
and since objectstack#8396 it says so in its own envelope:
SETTINGS_CRYPTO_UNAVAILABLE, with error.details locating the refused
{ namespace, key } and error.message carrying the operator prescription.

SettingsView read none of it. The code fell through to the generic error path,
where extractFieldErrors finds no details.fields array and returns null, so
nothing was marked and the refusal collapsed into one transient "save failed"
toast — the admin was told the save failed, while which key was refused and
that the DEPLOYMENT cannot encrypt were on the wire and discarded.

It now branches on the code the way it already does for SETTINGS_LOCKED: the
refused key is named as namespace.key from the declared error.details slot, and
the server's prescription renders verbatim in a persistent panel. The console
frames the refusal but never restates how to fix it — the server owns that copy.

The value is never rendered: the envelope deliberately does not carry the
secret, and the console does not re-introduce it from the draft it holds. The
draft survives so the value is not lost while the deployment is reconfigured,
and the refusal clears only when its claim can have become false — a new save
attempt, a successful save, a discard, or a reload. Notably it does NOT clear
on editing the key: that is a field-error semantic, and typing does not make a
deployment able to encrypt.

SETTINGS_LOCKED and SETTINGS_VALIDATION are byte-untouched, and an unrecognized
code still takes the generic path — all three pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 13, 2026 1:16pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-Cg5xPSwT.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
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.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
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.65KB 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) 489.32KB 108.45KB
core (index.js) 3.37KB 1.34KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 163.56KB 44.83KB
fields (index.js) 230.37KB 57.17KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
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.74KB
mobile (index.js) 1.50KB 0.62KB
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) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 62.10KB 17.67KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 121.04KB 31.57KB
plugin-designer (index.js) 212.58KB 42.83KB
plugin-detail (index.js) 239.88KB 59.99KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.30KB 40.02KB
plugin-grid (index.js) 189.37KB 50.33KB
plugin-kanban (index.js) 52.74KB 14.53KB
plugin-list (index.js) 111.13KB 27.12KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.16KB 10.96KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.08KB 20.55KB
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.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.73KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
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 (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 13, 2026 13:34
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 13, 2026
Merged via the queue into main with commit 36a4124 Aug 13, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4570-crypto-unavailable-refusal branch August 13, 2026 13:35
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.

Settings save: render the dedicated SETTINGS_CRYPTO_UNAVAILABLE refusal instead of the generic error path

2 participants