Skip to content

app-shell: give the pre-publish security block a way to the object it names - #5599

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-5476-surface-deeplink-live-channel
Aug 21, 2026
Merged

app-shell: give the pre-publish security block a way to the object it names#5599
os-sales merged 1 commit into
mainfrom
claude/issue-5476-surface-deeplink-live-channel

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#5476

Route A as ruled — a live target channel beside the mount-time capture. The
measurement asked for before landing is at the bottom, along with one scope
deviation I could not avoid and am flagging rather than burying.

The defect

The pending-changes sheet names the drafts the publish door would refuse —
object/crmext_visit, with the rule's fix-it hint and "Fix it on the object
under Settings → Record sharing". Naming it shipped in #5418; reaching it did
not. useSurfaceDeepLink reads ?surface=TYPE:NAME exactly once, at mount,
and the sheet opens over an already-mounted DataPillar — so writing the param
changed the URL and moved nothing.

What route A actually is

The mount-time useRef is not a bug to route around: the MIRROR half rewrites
the param on every in-pillar selection, so a capture that followed the URL would
re-trigger its restore on each one. Both existing halves are URL-shaped, which
is exactly why a producer already inside the pillar cannot use either. The
missing half is a live target carried beside the URL.

surfaceDeepLinkChannel.ts (new, in studio-design/) is that half:

  • Producers call useSurfaceNavigator() and ask by surface identity
    ({type, name}) — never by a Studio route the sheet would have to know how to
    build.
  • The host (StudioDesignSurface) routes a request for ANOTHER pillar's
    surface back through the URL, because that pillar is unmounted and its
    mount-time capture is the mechanism built for precisely that; it vetoes what
    the author declines over unsaved edits, and closes the sheet.
  • Subscribers (today only DataPillar) apply a request AT MOST ONCE, by a
    monotonic id.

That one-shot rule is the whole regression guard. A standing request re-resolved
on the next rail reload would drag the author back off whatever they had since
selected — the exact behaviour the mount-time ref exists to prevent.

Off-Studio degradation

The sheet's other home is the Home / draft-preview bar, where the Studio object
editor is not a reachable destination at all. useSurfaceNavigator() returns
null wherever no host published the channel, so reachability is answered
structurally, by the tree — never by sniffing a route string, which would
also have crashed the existing suites that render this panel with no router at
all. Off-Studio the item name stays the exact prose #5418 shipped.

Both directions are assertions in DraftChangesPanel.securityLink.test.tsx:
the in-Studio case clicks the control and expects the surface identity; the
off-Studio case asserts no button and no link — not a disabled one, not a dead
anchor — and that the item name and the "Settings → Record sharing" sentence are
still there.

The measurement you asked for: does A change what the other three pillars observe?

No.useSurfaceDeepLink is untouched apart from its doc comment — same
signature, same return, same behaviour. The Interfaces / Automations / Access
call sites are byte-identical, and none of them subscribes to the channel.
git diff on the hook is comment-only; the three other useSurfaceDeepLink(...)
call sites do not appear in the diff at all.

Reverse-verification

Two ablations, each mutated with an anchored grep -c in both directions plus
git diff --stat, restored through a trap ... EXIT INT TERM, tree verified
byte-identical afterwards (git status --porcelain empty). Both suites reach the
mutated module by relative same-package path, so no dist/ is in the resolution
path and there is nothing to rebuild — the import specifiers are printed in the
ablation log.

Ablation 1 — delete the live half's delivery (setRequested(...), 1 -> 0
occurrences, marker 0 -> 1):

mutated: Test Files 2 failed | 2 passed (4) · Tests 4 failed | 11 passed (15)
x delivers a request to subscribers after mount
x stamps each request with a fresh id
x opens the object the request names, with no remount and no URL to capture
x never re-applies it: a later selection survives the next rail reload
restored: 15 passed

The mount-time CONTROL stayed green throughout — useSurfaceDeepLink.test.ts
(the pre-existing pins) and the two new capture pins: the capture ignores every
URL change after mount, and a live request never moves it. Capability added,
not swapped.

Ablation 2 — delete the one-shot guard (if (requestedSurface.id === ...) return;, 1 -> 0): exactly one test red — "never re-applies it" — while "opens
the object the request names" stayed green.

Ablation 2 is why this PR has a stronger test than it started with. On its first
run it came back all green: my one-shot pin was asserting an absence in a
window where the rail reload had not yet happened, so nothing had had a chance to
violate it. The pin now waits on client.list actually being called again before
asserting nothing moved. A phantom assertion that the ablation caught, not a
tuning tweak.

Gates (exit codes captured before any pipe; each gate's own verdict quoted)

All at a506dff0d, the final commit.

gateverdict
pnpm --filter '@object-ui/app-shell^...' buildVERDICT command-exit 0 · held the lock 140s
pnpm --filter @object-ui/app-shell type-checkEXIT=0tsc --noEmit && tsc -p tsconfig.test.json
pnpm --filter @object-ui/app-shell lintEXIT=02510 problems (0 errors, 2510 warnings)
vitest run (narrowed, see below)Test Files 48 passed (48) · Tests 298 passed (298)
check-control-bytesOK (scanned 4650 tracked text file(s); skipped 85 binary)
check-changeset-presence7 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-majorNo changeset declares a major bump.
check-changeset-fixedAll workspace packages are in the changeset fixed group.
check-eager-closure-budget3785.3 KB gzipped across 52 of 508 chunks (budget: 3867.2 KB, headroom: 81.9 KB)
check-lint-coverage46/46 packages linted, 0 with outstanding errors
check-type-check-coverage45/46 via type-check · 41/41 packages compile their tests
check-i18n-call-site-keysevery in-scope call-site key resolves; no new keys added

check-eager-closure-budget first exited 2 — its documented "no trustworthy
measurement" code, because no console build had written the report. This diff
puts a module into the console's EAGER graph, so a broken gauge was not good
enough: apps/console was built and the gate re-run for the number above. The
new module imports react and nothing else — useSurfaceDeepLink was
deliberately NOT imported into the sheet, since it reaches nav-selection and
through it the App-nav inspector.

Declared narrowing of the vitest run. Not the whole package: the run covers
views/studio-design/, preview/, and the three metadata-admin suites that
name anything this diff touches. Containment is measured, not assumed — grepping
the whole package for every identifier changed here (surfaceDeepLinkChannel,
useSurfaceDeepLink, DraftChangesPanel, StudioDesignSurface, DataPillar)
returns 26 suites, and all 26 are inside that run. Across the rest of
packages/, zero files outside app-shell reference any of them. CI runs the
full farm regardless.

Scope deviation — please rule

The dispatch fenced this to packages/app-shell/src/views/studio-design/** plus
a changeset, on the stated grounds that "the only other consumer of the hook is
StudioDesignSurface.tsx, in the same directory". That is true of the hook,
but the sheet that has to render the link is
packages/app-shell/src/preview/DraftChangesPanel.tsx — a sibling directory —
and the binding requirement ("the link must degrade to prose off-Studio, and
that degradation is a test assertion") has no subject without it.

So two files sit outside the fence, and I did not take them silently:

packages/app-shell/src/preview/DraftChangesPanel.tsx the link / prose branch
packages/app-shell/src/preview/__tests__/DraftChangesPanel.securityLink.test.tsx

Same package, no change to the package's public exports (src/index.ts
untouched), no overlap with #5544's src/chrome/**, and the import direction
preview/ -> views/ is the one already established there
(views/metadata-admin/previews/object-fields-io.js). The alternative was
shipping an unconsumed channel and a degradation requirement with nothing to
degrade. Happy to split the sheet half into its own PR if you would rather rule
the other way.

Deliberate non-goals

  • The request carries a surface identity only, so the pillar lands on the object
    — not on its Settings tab. Adding a tab axis would widen the identity four
    pillars share; the sheet's sentence still says where to go once the object is
    open.
  • PILLAR_FOR_SURFACE_TYPE lists only the types a pillar actually resolves.
    An unlisted type is delivered in place rather than guessed at: navigating to
    the wrong pillar costs the author their position and buys nothing.

Generated by Claude Code

… names
Studio's pending-changes sheet reports the drafts the publish door would
refuse — `object/crmext_visit` — and could not take the author there. The
`?surface=<type>:<name>` deep-link captures the URL once, at MOUNT, and the
sheet opens over an already-mounted pillar, so writing the param changed the
URL and moved nothing.
The mount-time capture stays exactly as it was: the mirror half rewrites the
param on every in-pillar selection, so a capture that followed the URL would
re-trigger its restore on each one. The missing piece is a live target
delivered beside the URL. `surfaceDeepLinkChannel` adds it — producers ask by
surface identity, the host routes cross-pillar requests back through the URL
(that pillar is unmounted, so its capture is the right mechanism) and vetoes
what the author declines over unsaved edits, and the mounted pillar applies
the rest AT MOST ONCE, by id, so a standing request can never drag them back
off a later selection.
Off-Studio the producer hook is null and the item name stays the prose it has
always been: the sheet is shared with the Home / draft-preview bar, where the
designer is not a reachable destination and a dead link is worse than the
sentence saying where to go. Reachability is answered by the tree, never by a
route string.
Fixes#5476
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2pRjcqAYtoEjgr3wwhnK
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3785.4 KB3867.2 KB
Main entry chunk (gzip)151.6 KB350 KB
Entry fileindex-irv2PU6b.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 (index.js)10.04KB3.72KB
app-shell (runtime-config.js)8.91KB2.99KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
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)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
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.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
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)506.99KB113.73KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.33KB
fields (index.js)237.61KB59.63KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.22KB3.08KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
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.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
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.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.72KB18.35KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)128.53KB32.97KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.15KB60.89KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.07KB30.43KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.70KB27.17KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.50KB20.68KB
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)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
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)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.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-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
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 (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — ACCEPT (card #5476)

Gates. 22 named check runs read individually for completed + success: 19 success, 3 skippedTest (coverage), the unexpanded Test (coverage shard …/4) matrix placeholder, and dependabot, the three always-skipped no-ops in this repo's set. All four real shards, Type Check, Lint, Build & E2E, Build Docs, Bundle Analysis, Doc Snippet Type Check, Doc Component Type Check, Live E2E (informational), Internal Docs Link Check, Control Byte Scan, Skill Guide Path Check and all three changeset checks are green on the head commit.

The three questions, ruled

Q1 — the two preview/ files: keep them. (A)

They are in the same package, src/index.ts is untouched so no published surface moves, there is no overlap with #5544's chrome/** hold, and the preview/ → views/ import direction is already established in this package rather than being introduced by this PR. Four independent reasons, none of which depends on the others.

Q2 — ship the landing on the object; do not add a tab axis to the shared surface identity. (A)

A tab axis would widen a shared identity to serve one destination, and every later surface would then have to answer what its tab is. Landing on the object keeps the change local to the thing that actually needs it.

Q3 — footer form: session URL in PR bodies, bare form in comments. (A)

Now a standing rule for this lane, recorded on the seat post rather than re-decided per card.

PM fence error, disclosed

I fenced this card to packages/app-shell/src/views/studio-design/**, reasoning that the hook's only other consumer lives there. That was wrong in a way that mattered: the sheet that must render the link is preview/DraftChangesPanel.tsx, so my own binding requirement had no subject inside my own fence. The two preview/ files above are a consequence of my fencing error, not scope creep by the implementer — which is also why Q1 exists at all.

This is the second fence error of the same shape in this round (the other is on #5597) and the third this session. The pattern, stated so it stops recurring: I keep fencing the thing being changed rather than the thing being delivered. Recorded on the seat post.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review August 21, 2026 16:53
@os-sales
os-sales added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit d15a92dAug 21, 2026
23 checks passed
@os-sales
os-sales deleted the claude/issue-5476-surface-deeplink-live-channel branch August 21, 2026 16:54
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants

@os-sales@claude