Found while surveying response contracts for the framework's packages.* routes (objectstack#12038, survey half). Filed unassigned; not fixed there — that card is read-only and this lands here.
What is measured
packages/app-shell/src/views/studio-design/packages-io.ts:64-76 — duplicatePackage() posts to POST /api/v1/packages/:id/duplicate and then classifies the outcome as:
constpayload=(awaitres.json().catch(()=>null))as|{success?: boolean;error?: {message?: string}}|null;if(!res.ok||payload?.success===false){thrownewError(payload?.error?.message||`HTTP ${res.status}`);}That reads success at the top level of the body. The top level of a 200 from this route is the dispatcher envelope, whose success is true by construction.
The route is served ONLY by the runtime dispatcher, and it wraps. Measured on objectstack origin/main @ d7b3963c2:
packages/runtime/src/domains/packages.ts:816-846 — the /duplicate branch answers deps.success(result).deps.success(data) is { status: 200, body: { success: true, data } } (packages/runtime/src/http-dispatcher.ts:367).packages/rest/src/rest-server.ts mounts no/packages/:id/duplicate twin, so there is no second surface that answers the operation result bare.
The operation's own verdict lives one level down, and it is a real three-state.MetadataProtocol.duplicatePackage (packages/metadata-protocol/src/protocol.ts:16971) declares and returns:
Promise<{success: boolean;copiedCount: number;failedCount: number;targetPackageId: string;copied: Array<{type: string;name: string}>;failed: Array<{type: string;name: string;error: string}>}>with
success: failed.length===0&&copied.length>0,
So data.success is false in two reachable outcomes that both answer HTTP 200:
- some items failed to copy (
failed.length > 0) — a PARTIAL duplicate; - nothing was copied at all (
copied.length === 0) — an EMPTY duplicate, e.g. a source package whose rows this caller's scope cannot see.
In both, res.ok is true and the envelope's success is true, so the throw never fires. The Studio author is told the base was duplicated. copiedCount / failedCount / failed[] — the fields that say what actually happened — are never read.
The control, in this same file's neighbourhood
packages/app-shell/src/preview/commitHistory.ts:193-208 calls the sibling commit-revert route and gets it right:
constinner=(payload?.dataasRecord<string,unknown>|undefined)??payload??undefined;if(!res.ok||(inneras{success?: boolean})?.success===false){ ... }It unwraps data first, then reads the operation's flag. Same envelope, same shape of question, opposite outcome. So this is one consumer out of step with the pattern already used next door, not a missing convention.
Suggested shape of the fix
Unwrap before reading, and surface the partial case rather than folding it into success — failed[] carries a per-item error string that the author needs:
constinner=(payload?.dataasRecord<string,unknown>|undefined)??payload??undefined;if(!res.ok||(inneras{success?: boolean})?.success===false){// report failedCount / failed[].error, not just a generic HTTP message}Why it was invisible
POST /packages/:id/duplicate has no published response schema — its route-ledger row reports responseSchema=None (packages/runtime/src/route-ledger.ts:313) and the client SDK method returns unwrapResponse<any>. With nothing declared, a consumer reading the wrong success typechecks perfectly. That is the hazard objectstack#12038 exists to close; this issue is the already-realised instance of it, recorded so the fix is not deferred behind that decision.
Not filed against
fetchCommits / revertCommit (commitHistory.ts) and fetchPendingDrafts (usePendingDrafts.ts) each carry a defensive three-arm envelope chain (bare array | {key} | {data:{key}}). Those are tolerant rather than wrong — only one arm is live today — so they are recorded in the objectstack#12038 survey as contract-absence evidence, not as defects here.
Found while surveying response contracts for the framework's
packages.*routes (objectstack#12038, survey half). Filed unassigned; not fixed there — that card is read-only and this lands here.What is measured
packages/app-shell/src/views/studio-design/packages-io.ts:64-76—duplicatePackage()posts toPOST /api/v1/packages/:id/duplicateand then classifies the outcome as:That reads
successat the top level of the body. The top level of a 200 from this route is the dispatcher envelope, whosesuccessistrueby construction.The route is served ONLY by the runtime dispatcher, and it wraps. Measured on objectstack
origin/main@d7b3963c2:packages/runtime/src/domains/packages.ts:816-846— the/duplicatebranch answersdeps.success(result).deps.success(data)is{ status: 200, body: { success: true, data } }(packages/runtime/src/http-dispatcher.ts:367).packages/rest/src/rest-server.tsmounts no/packages/:id/duplicatetwin, so there is no second surface that answers the operation result bare.The operation's own verdict lives one level down, and it is a real three-state.
MetadataProtocol.duplicatePackage(packages/metadata-protocol/src/protocol.ts:16971) declares and returns:with
So
data.successisfalsein two reachable outcomes that both answer HTTP 200:failed.length > 0) — a PARTIAL duplicate;copied.length === 0) — an EMPTY duplicate, e.g. a source package whose rows this caller's scope cannot see.In both,
res.okis true and the envelope'ssuccessis true, so thethrownever fires. The Studio author is told the base was duplicated.copiedCount/failedCount/failed[]— the fields that say what actually happened — are never read.The control, in this same file's neighbourhood
packages/app-shell/src/preview/commitHistory.ts:193-208calls the sibling commit-revert route and gets it right:It unwraps
datafirst, then reads the operation's flag. Same envelope, same shape of question, opposite outcome. So this is one consumer out of step with the pattern already used next door, not a missing convention.Suggested shape of the fix
Unwrap before reading, and surface the partial case rather than folding it into success —
failed[]carries a per-itemerrorstring that the author needs:Why it was invisible
POST /packages/:id/duplicatehas no published response schema — its route-ledger row reportsresponseSchema=None(packages/runtime/src/route-ledger.ts:313) and the client SDK method returnsunwrapResponse<any>. With nothing declared, a consumer reading the wrongsuccesstypechecks perfectly. That is the hazard objectstack#12038 exists to close; this issue is the already-realised instance of it, recorded so the fix is not deferred behind that decision.Not filed against
fetchCommits/revertCommit(commitHistory.ts) andfetchPendingDrafts(usePendingDrafts.ts) each carry a defensive three-arm envelope chain (bare array |{key}|{data:{key}}). Those are tolerant rather than wrong — only one arm is live today — so they are recorded in the objectstack#12038 survey as contract-absence evidence, not as defects here.