Skip to content

Commit 6d15aca

Browse files
fix(app-shell): no drafts-ledger read for a member, no published read for a new draft (#26056)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9DThgomJbGpJoLcoasFJSw1)_ Fixes #25894 Clause-②: no (gating internal reads; no exported type change) Metadata-protocol: none ## What the card saw, and where each request came from Both requests were located on `origin/main` before any change. 1. **`GET /api/v1/meta/_drafts` → 403 for a member.** The only env-wide reader of the ledger a member reaches is Home's "My work" drafts row (`usePendingDraftsRow` in `console/home/HomePage.tsx`, which called `usePendingDrafts({})` for whoever opened Home). The approvals inbox the card walked from lives on Home. No record-page component reads the ledger on main: the other readers (`DraftPreviewBar` in preview mode, the chat's package-bound `PendingDraftsBar`, Studio, the metadata editors through `MetadataClient.getDraft`) are authoring surfaces. 2. **`GET /api/v1/meta/object/NAME?package=PKG` → 404 for a draft-only object.** That exact URL shape (a by-name read with `package=` and no `state=draft`) has one producer in `ui/`: `fetchItemBody` in `preview/DraftChangesPanel.tsx`, the published-side read of a row's drill-in (`EntryDetail`). Studio's Publish opens the sheet in the confirm posture, where every row is expanded as the list arrives, so the drill-in read the published body before the per-type new-vs-update read had answered. A NEW item has no published body, so that read could only answer 404. ## The change - `hooks/useCanAuthorMetadata.ts`: new `PENDING_DRAFTS_CAPABILITIES` (`studio.access`, `setup.access`, `manage_metadata`) and `useCanReadPendingDrafts()`, "holds any one of them", unknown fails open (the module's existing doctrine). The cohort is the door's own: `GET /meta/_drafts` asks `mayReadPendingDrafts`, which is `isObjectSchemaMaskExempt`, the ADR-0106 D4 cohort. It is deliberately not `manage_metadata` alone (the Studio entry's gate), which would have taken the row away from a `setup.access` admin the door admits. - `console/home/HomePage.tsx`: the drafts row reads with `usePendingDrafts({ enabled: canReadDrafts })`. Held, the row is `null`, as with nothing to publish. - `preview/DraftChangesPanel.tsx`: the panel tracks whether the latest load's classification reads are in flight (`classifying`, keyed by load generation). A drill-in waits while its entry is unclassified and they are pending, and a `kind: 'new'` entry is not asked for its published body. An entry whose classification read failed stays unclassified once they settle and is read both ways, as before. - Four existing Home tests mock `../../../hooks/useCanAuthorMetadata` with an explicit factory; each gains `useCanReadPendingDrafts: () => true`, which keeps them on their previous behaviour. ## Tests Both new files drive the real code path: the Home test mounts the real `MePermissionsProvider` over a `/me/permissions` payload, and the panel test runs the real panel against a recording `fetch`. - `HomePage.draftsReadGate-25894.test.tsx`: a member with a reported set holding none of the three gets no `_drafts` read and no row; a reported empty set gets no read; CONTROLS: each of `setup.access`, `studio.access`, `manage_metadata` alone reads once and renders the row; an unreported set fails open and reads. - `DraftChangesPanel.newEntryNoPublishedRead-25894.test.tsx`: in the confirm posture a NEW entry sends only its draft read; CONTROL: an UPDATE entry still reads its published body (with `package=`); while the published-list read is held, no drill-in sends a published read; CONTROL: a failed classification read leaves both entries read both ways. Run, under the shared verify lock, from `ui/`: `vitest run --maxWorkers=2 packages/app-shell/src/preview/__tests__/DraftChangesPanel packages/app-shell/src/console/home/__tests__/HomePage`. Result: 29 files, 186 tests, all pass. This covers every `DraftChangesPanel` and `HomePage` test file. The full app-shell package is left to CI `Test Core`, a declared narrowing. Ablation: both fixed files were set back to `origin/main` (the hook left in place), and the two new files were re-run. The 4 headline cases went red: no-published-read, held-classification, member-no-read, empty-set-no-read. The 5 controls stayed green. The files were then restored with `git checkout HEAD`, and `git diff HEAD` was empty afterwards. The tests import app-shell source directly, so no dist rebuild sits between the mutation and the run. ## Derived gates `pnpm --filter @object-ui/app-shell type-check` (both `tsc --noEmit` and `tsc -p tsconfig.test.json`), after `pnpm --filter '@object-ui/app-shell^...' build`: exit 0, zero errors, at head e32db46. `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 35 commands. All exited 0, `node scripts/check-sdui-manifest.mjs` included. `--ran` reconciliation: 34 derived families accounted for, 34 run, 0 NOT-MEASURED, each with an exit code. `node scripts/gen-sdui-manifest-node.mjs` was re-run. The registry sources hash is unchanged, because app-shell is not one of the 27 registry packages, so `scripts/sdui-manifest.record.json` needs no change and is not in this diff. ## Acceptance notes - `@objectstack/client`'s `meta.listDrafts` and `MetadataClient.getDraft`'s ledger probe also send an unfiltered `GET /meta/_drafts`. Every caller of either on main sits behind an authoring surface, so neither is gated here. - `DraftChangesPanel`'s per-member classification (`email_template` members) still reads the by-name door with `?member=` and gets a 404 for a new member. That read is how the sheet learns the member is new, so it stays. Its drill-in is now spared the second 404. - #25984 (card #25890) also edits `DraftChangesPanel.tsx`, in `computeChangeDetail` and the drill-in's render, not the load effect or the panel state this PR touches. Its `isNew = published === null` reads the same answer when the published read is skipped. This branch merges `origin/main` once #25984 lands. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_011fs46jjQVe98Y5YTfDTixa --- _Generated by [Claude Code](https://claude.ai/code/session_011fs46jjQVe98Y5YTfDTixa)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 679fc7c commit 6d15aca

10 files changed

Lines changed: 390 additions & 7 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): the Console no longer sends two requests it knows will be refused, the drafts ledger for a member and the published body of a new draft
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Home's drafts row** read `GET /api/v1/meta/_drafts` for whoever opened Home. A member holding none of the capabilities the ledger admits (`studio.access`, `setup.access` or `manage_metadata`) got a 403, and the console logged it as a failed request on every visit. The row now reads the ledger only for a session that holds one of them, through the new `useCanReadPendingDrafts()`. An unreported capability set still reads it, and the server still enforces the gate.
11+
- **The pending-changes sheet** (Studio's Publish and Changes, and the draft-preview bar's Changes) read each draft's published body beside its draft. Under Publish, where every row opens expanded, that read was sent before the sheet knew which drafts are new, so a draft-only object such as one just saved from New object got `GET /api/v1/meta/object/NAME?package=PKG` → 404. A row now waits for its new-or-update classification, and a new draft is not asked for a published body. A row whose classification read failed is read both ways, as before.
12+
13+
What to do: nothing. The pages render exactly as before.

‎ui/packages/app-shell/src/console/home/HomePage.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { useAgents, isAskAgent, agentHasCapability } from '@object-ui/plugin-cha
3030
// May this session author metadata? Shared with the chat surfaces since the
3131
// maker convergence (cloud#1674) — the doctrine (objectstack#8270 posture,
3232
// unknown→fail-open) lives with the hook.
33-
import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js';
33+
import { useCanAuthorMetadata, useCanReadPendingDrafts } from '../../hooks/useCanAuthorMetadata.js';
3434
import { HomeAppsStrip } from './HomeAppsStrip.js';
3535
import { appSegmentOf, lastOpenedByApp } from './appLastOpened.js';
3636
import { recentItemObject, recentItemTypeLabel } from './recentItemTypeLabel.js';
@@ -292,10 +292,16 @@ function usePackageName(packageId: string | null): string | undefined {
292292
* publish. `usePublishAllDrafts` is the shared one-click publish, also run by
293293
* the ADR-0037 draft-preview bar. Returns the row, or `null` with nothing to
294294
* publish.
295+
*
296+
* Read only for a session the ledger admits (objectstack-ai/objectstack#25894):
297+
* Home is every member's landing, and a member holding none of the ledger's
298+
* capabilities was sent a `GET /meta/_drafts` that answers 403 on every visit.
299+
* Held, the read is held too, so the row is `null`, as with nothing to publish.
295300
*/
296301
function usePendingDraftsRow(t: (key: string, opts?: any) => string, language: string): React.ReactNode {
297302
const { publishAll, publishing } = usePublishAllDrafts(t);
298-
const { count, entries } = usePendingDrafts({});
303+
const canReadDrafts = useCanReadPendingDrafts();
304+
const { count, entries } = usePendingDrafts({ enabled: canReadDrafts });
299305
const drafted = (count ?? 0) > 0;
300306
// Named only when every draft is in one package: a row naming one package
301307
// for drafts spread over several would misplace the rest. Named by the name
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack-ai/objectstack#25894 — Home reads the pending-drafts ledger only
5+
* for a session the ledger admits.
6+
*
7+
* Measured on the 2026-10-25 browser pass: signed in as a member with no
8+
* metadata capability, the console sent `GET /api/v1/meta/_drafts` and logged
9+
* its 403 as a resource error. Home is every member's landing (the approvals
10+
* inbox lives here), and its drafts row read the env-wide ledger for whoever
11+
* opened it. The door admits `studio.access`, `setup.access` or
12+
* `manage_metadata` (`mayReadPendingDrafts`); a session holding none of them
13+
* is now sent no read.
14+
*
15+
* Drives the REAL `MePermissionsProvider` over a `/me/permissions` payload, as
16+
* `HomePage.authoringCapabilityGate.test.tsx` does, so the gate consumes the
17+
* server's answer through `hasCapabilities`.
18+
*
19+
* Pinned:
20+
* - a reported set holding none of the three: no `_drafts` read, no row;
21+
* - CONTROL: each of the three alone reads the ledger and renders the row;
22+
* - CONTROL: an unreported set (no `systemPermissions`) fails OPEN and reads.
23+
*/
24+
25+
import '@testing-library/jest-dom/vitest';
26+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
27+
import React from 'react';
28+
import { act, render, screen, cleanup } from '@testing-library/react';
29+
import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions';
30+
31+
vi.mock('react-router-dom', async () => {
32+
const { createElement } = await import('react');
33+
return {
34+
useNavigate: () => vi.fn(),
35+
Link: ({ to, ...rest }: { to: string } & Record<string, unknown>) => createElement('a', { ...rest, href: to }),
36+
};
37+
});
38+
39+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
40+
...(await importOriginal<Record<string, unknown>>()),
41+
useObjectTranslation: () => ({ t: (key: string) => `«${key}»`, language: 'en' }),
42+
useObjectLabel: () => ({ appLabel: (app: any) => String(app?.label ?? app?.name ?? '') }),
43+
}));
44+
45+
vi.mock('@object-ui/auth', async (importOriginal) => ({
46+
...(await importOriginal<Record<string, unknown>>()),
47+
useAuth: () => ({ user: { id: 'u1', name: 'Sales Manager', email: 'sales.manager@example.com' } }),
48+
useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }),
49+
}));
50+
51+
vi.mock('@object-ui/plugin-chatbot/core', async (importOriginal) => ({
52+
...(await importOriginal<typeof import('@object-ui/plugin-chatbot/core')>()),
53+
useAgents: () => ({ agents: [] }),
54+
isAskAgent: () => false,
55+
agentHasCapability: () => false,
56+
}));
57+
58+
vi.mock('../../../providers/MetadataProvider', () => ({
59+
useMetadata: () => ({ apps: [{ name: 'crm', label: 'CRM' }], loading: false }),
60+
}));
61+
vi.mock('../../../context/NavigationContext', () => ({
62+
useNavigationContext: () => ({ currentAppName: 'crm' }),
63+
}));
64+
vi.mock('../../../hooks/useRecentItems', () => ({ useRecentItems: () => ({ recentItems: [] }) }));
65+
vi.mock('../../../hooks/useFavorites', () => ({ useFavorites: () => ({ favorites: [] }) }));
66+
vi.mock('../../../hooks/useHomeInbox', () => ({
67+
useHomeInbox: () => ({
68+
pendingApprovalsCount: 0,
69+
pendingApprovals: [],
70+
pendingApprovalsStatus: 'ready',
71+
pendingApprovalsReadable: true,
72+
notifications: [],
73+
unreadTopicCount: 0,
74+
notificationsStatus: 'ready',
75+
notificationsReadable: true,
76+
activity: { value: [], status: 'ready', readable: true },
77+
}),
78+
}));
79+
vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' }));
80+
vi.mock('../../../preview/usePublishAllDrafts', () => ({
81+
usePublishAllDrafts: () => ({ publishAll: async () => ({ ok: true }), publishing: false }),
82+
}));
83+
vi.mock('../../../runtime-config', () => ({
84+
getRuntimeConfig: () => ({ branding: { productName: 'ObjectStack' } }),
85+
isMarketplaceEnabled: () => true,
86+
isAiStudioEnabled: () => true,
87+
}));
88+
89+
import { HomePage } from '../HomePage';
90+
91+
const DRAFTS_ROUTE = '/api/v1/meta/_drafts';
92+
const routeOf = (url: string) => url.split('?')[0];
93+
94+
/** Two drafts in two packages: the row names no package, so no package list is read. */
95+
const DRAFTS = [
96+
{ type: 'object', name: 'visit', label: 'Visit', packageId: 'com.a' },
97+
{ type: 'object', name: 'invoice', label: 'Invoice', packageId: 'com.b' },
98+
];
99+
100+
let fetchCalls: string[] = [];
101+
102+
beforeEach(() => {
103+
fetchCalls = [];
104+
vi.stubGlobal(
105+
'fetch',
106+
vi.fn(async (input: unknown) => {
107+
const url = String(input);
108+
fetchCalls.push(url);
109+
if (routeOf(url) === DRAFTS_ROUTE) return { ok: true, status: 200, json: async () => ({ drafts: DRAFTS }) };
110+
return { ok: false, status: 404, json: async () => ({}) };
111+
}),
112+
);
113+
});
114+
115+
afterEach(() => {
116+
expect(fetchCalls.filter((url) => routeOf(url) !== DRAFTS_ROUTE)).toEqual([]);
117+
// Unmount before restoring the real `fetch` (objectui#7439).
118+
cleanup();
119+
vi.unstubAllGlobals();
120+
});
121+
122+
const payload = (systemPermissions?: string[]): MePermissionsResponse =>
123+
({
124+
authenticated: true,
125+
userId: 'u1',
126+
tenantId: 'acme',
127+
roles: ['everyone'],
128+
permissionSets: ['member_default'],
129+
...(systemPermissions ? { systemPermissions } : {}),
130+
objects: {},
131+
fields: {},
132+
}) as MePermissionsResponse;
133+
134+
async function renderHome(permissions: MePermissionsResponse) {
135+
render(
136+
<MePermissionsProvider initialPermissions={permissions}>
137+
<HomePage />
138+
</MePermissionsProvider>,
139+
);
140+
await act(async () => {
141+
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
142+
});
143+
}
144+
145+
const draftsReads = () => fetchCalls.filter((url) => routeOf(url) === DRAFTS_ROUTE);
146+
147+
describe('Home reads the drafts ledger only for a session it admits (objectstack-ai/objectstack#25894)', () => {
148+
it('a member holding none of the ledger capabilities: no `_drafts` read and no row', async () => {
149+
await renderHome(payload(['export_reports']));
150+
151+
expect(screen.getByRole('heading', { level: 1 }), 'Home never rendered: the harness is dead').toBeInTheDocument();
152+
expect(draftsReads()).toEqual([]);
153+
expect(screen.queryByTestId('home-pending-drafts')).toBeNull();
154+
});
155+
156+
it('a REPORTED empty set holds none of them either: no read', async () => {
157+
await renderHome(payload([]));
158+
159+
expect(draftsReads()).toEqual([]);
160+
});
161+
162+
it.each(['setup.access', 'studio.access', 'manage_metadata'])(
163+
'CONTROL: `%s` alone reads the ledger and shows the row',
164+
async (capability) => {
165+
await renderHome(payload([capability]));
166+
167+
expect(draftsReads()).toEqual([DRAFTS_ROUTE]);
168+
expect(await screen.findByTestId('home-pending-drafts')).toBeInTheDocument();
169+
},
170+
);
171+
172+
it('CONTROL: an unreported capability set fails OPEN and reads', async () => {
173+
await renderHome(payload(undefined));
174+
175+
expect(draftsReads()).toEqual([DRAFTS_ROUTE]);
176+
expect(await screen.findByTestId('home-pending-drafts')).toBeInTheDocument();
177+
});
178+
});

‎ui/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ vi.mock('../../../context/NavigationContext', () => ({
107107
}));
108108
vi.mock('../../../hooks/useCanAuthorMetadata', () => ({
109109
useCanAuthorMetadata: () => state.canAuthorMetadata,
110+
useCanReadPendingDrafts: () => true,
110111
}));
111112
// The page's one inbox seam: the approvals count AND whether it is an answer.
112113
vi.mock('../../../hooks/useHomeInbox', () => ({

‎ui/packages/app-shell/src/console/home/__tests__/HomePage.myWork-12602.test.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@ vi.mock('../../../providers/MetadataProvider', () => ({
105105
vi.mock('../../../context/NavigationContext', () => ({
106106
useNavigationContext: () => ({ currentAppName: 'crm' }),
107107
}));
108-
vi.mock('../../../hooks/useCanAuthorMetadata', () => ({ useCanAuthorMetadata: () => true }));
108+
vi.mock('../../../hooks/useCanAuthorMetadata', () => ({
109+
useCanAuthorMetadata: () => true,
110+
useCanReadPendingDrafts: () => true,
111+
}));
109112
// The page's one inbox seam: both sources, their answers and their read grants.
110113
vi.mock('../../../hooks/useHomeInbox', () => ({
111114
useHomeInbox: () => ({

‎ui/packages/app-shell/src/console/home/__tests__/HomePage.pendingDraftsPackageName-13136.test.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,10 @@ vi.mock('../../../providers/MetadataProvider', () => ({
8686
vi.mock('../../../context/NavigationContext', () => ({
8787
useNavigationContext: () => ({ currentAppName: 'crm' }),
8888
}));
89-
vi.mock('../../../hooks/useCanAuthorMetadata', () => ({ useCanAuthorMetadata: () => true }));
89+
vi.mock('../../../hooks/useCanAuthorMetadata', () => ({
90+
useCanAuthorMetadata: () => true,
91+
useCanReadPendingDrafts: () => true,
92+
}));
9093
vi.mock('../../../hooks/useHomeInbox', () => ({
9194
useHomeInbox: () => ({
9295
pendingApprovals: [],

‎ui/packages/app-shell/src/console/home/__tests__/HomePage.recentlyAccessed-12604.test.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ vi.mock('../../../context/NavigationContext', () => ({
101101
}));
102102
vi.mock('../../../hooks/useCanAuthorMetadata', () => ({
103103
useCanAuthorMetadata: () => state.canAuthorMetadata,
104+
useCanReadPendingDrafts: () => true,
104105
}));
105106
// The page's one inbox seam: the approvals count AND whether it is an answer.
106107
vi.mock('../../../hooks/useHomeInbox', () => ({

‎ui/packages/app-shell/src/hooks/useCanAuthorMetadata.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,26 @@ export function useCanReadAuthoringTrail(): boolean {
7373
const { hasCapabilities } = usePermissions();
7474
return AUTHORING_TRAIL_CAPABILITIES.some((capability) => hasCapabilities([capability]));
7575
}
76+
77+
/**
78+
* objectstack-ai/objectstack#25894 — the capabilities the pending-drafts ledger
79+
* (`GET /meta/_drafts`) admits. The door asks `mayReadPendingDrafts`, the
80+
* ADR-0106 D4 mask-exemption cohort (`OBJECT_SCHEMA_MASK_EXEMPT_CAPABILITIES`
81+
* in `@objectstack/metadata-core`): the write capability plus the two
82+
* read-only exemptions. Any ONE of them opens it. `system` is a principal
83+
* flag with no client half, as for the trail above.
84+
*/
85+
export const PENDING_DRAFTS_CAPABILITIES: readonly string[] = ['studio.access', 'setup.access', AUTHORING_CAPABILITY];
86+
87+
/**
88+
* May this session read the pending-drafts ledger?
89+
*
90+
* Asked by a surface that reads the ledger for whoever opens it (Home's drafts
91+
* row), so a member the door refuses is not sent a request that always answers
92+
* 403. Unknown fails OPEN, as {@link useCanAuthorMetadata} does: the server
93+
* enforces regardless.
94+
*/
95+
export function useCanReadPendingDrafts(): boolean {
96+
const { hasCapabilities } = usePermissions();
97+
return PENDING_DRAFTS_CAPABILITIES.some((capability) => hasCapabilities([capability]));
98+
}

‎ui/packages/app-shell/src/preview/DraftChangesPanel.tsx‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -656,13 +656,22 @@ function FieldRow({
656656
* answer carries a numeric `total`: with none, the choice names no number
657657
* rather than a guessed one. What the publish actually wrote is the door's
658658
* receipt, which the host reports.
659+
*
660+
* `classifying` (objectstack-ai/objectstack#25894): the panel's new-vs-update
661+
* reads are still in flight. A NEW entry has no published body, so its
662+
* published read is not sent: it could only answer 404, logged as a failed
663+
* request on the console for every draft-only item. The drill-in waits while
664+
* the entry is unclassified and the reads are pending; one whose read failed
665+
* (still unclassified once they settle) is read both ways, as before.
659666
*/
660667
function EntryDetail({
661668
entry,
669+
classifying = false,
662670
onCompared,
663671
backfill,
664672
}: {
665673
entry: DraftChangeEntry;
674+
classifying?: boolean;
666675
onCompared?: (unchanged: boolean) => void;
667676
backfill?: EntryBackfillControl;
668677
}) {
@@ -687,13 +696,21 @@ function EntryDetail({
687696
onComparedRef.current = onCompared;
688697
});
689698

699+
const classifiedNew = entry.kind === 'new';
700+
const awaitingKind = entry.kind === undefined && classifying;
690701
useEffect(() => {
702+
if (awaitingKind) return;
691703
let cancelled = false;
692704
(async () => {
693705
try {
694706
const [published, draft] = await Promise.all([
695-
// A NEW item 404s on the published read — that's data, not an error.
696-
fetchItemBody(entry.type, entry.name, { packageId: entry.packageId, member: entry.member }),
707+
// A NEW item has no published body: not asked
708+
// (objectstack-ai/objectstack#25894). An item the
709+
// classification could not answer still 404s here when it is new —
710+
// that's data, not an error.
711+
classifiedNew
712+
? null
713+
: fetchItemBody(entry.type, entry.name, { packageId: entry.packageId, member: entry.member }),
697714
fetchItemBody(entry.type, entry.name, {
698715
draft: true,
699716
packageId: entry.packageId,
@@ -715,7 +732,7 @@ function EntryDetail({
715732
return () => {
716733
cancelled = true;
717734
};
718-
}, [entry.type, entry.name, entry.packageId, entry.member]);
735+
}, [entry.type, entry.name, entry.packageId, entry.member, classifiedNew, awaitingKind]);
719736

720737
// objectui#12929 — how many records the choice would reach: asked once per
721738
// object, and only where a choice is offered.
@@ -945,6 +962,14 @@ export function DraftChangesPanel({
945962
const { t, language } = useObjectTranslation();
946963
const [entries, setEntries] = useState<DraftChangeEntry[] | null>(null);
947964
const [error, setError] = useState<string | null>(null);
965+
/**
966+
* The new-vs-update reads of the latest load are in flight
967+
* (objectstack-ai/objectstack#25894): a drill-in holds its published read
968+
* until its entry is classified or they settle. Keyed by load generation, so
969+
* an earlier load settling late does not end a later one's.
970+
*/
971+
const [classifying, setClassifying] = useState(false);
972+
const loadGenRef = useRef(0);
948973
/**
949974
* Author-time security findings that the publish door would REFUSE
950975
* (`7a90afdf9`). Surfaced here, next to the button, so the refusal is
@@ -1034,6 +1059,8 @@ export function DraftChangesPanel({
10341059
};
10351060

10361061
const load = useCallback(async () => {
1062+
const gen = ++loadGenRef.current;
1063+
setClassifying(true);
10371064
setEntries(null);
10381065
setError(null);
10391066
setProblems([]);
@@ -1111,6 +1138,8 @@ export function DraftChangesPanel({
11111138
]);
11121139
} catch (e) {
11131140
setError((e as Error).message);
1141+
} finally {
1142+
if (loadGenRef.current === gen) setClassifying(false);
11141143
}
11151144
}, [packageId, include]);
11161145

@@ -1311,6 +1340,7 @@ export function DraftChangesPanel({
13111340
<div className="border-t bg-muted/20">
13121341
<EntryDetail
13131342
entry={entry}
1343+
classifying={classifying}
13141344
onCompared={(same) => markCompared(key, same)}
13151345
backfill={backfillControl}
13161346
/>

0 commit comments

Comments
 (0)