diff --git a/.changeset/28722-customizations-contributions.md b/.changeset/28722-customizations-contributions.md
new file mode 100644
index 00000000000..34848f898fc
--- /dev/null
+++ b/.changeset/28722-customizations-contributions.md
@@ -0,0 +1,18 @@
+---
+'@objectstack/spec': minor
+'@objectstack/runtime': minor
+---
+
+feat(runtime): `GET /packages/:id/customizations` names the package behind each field and flow another package contributes to the base
+
+Clause-②: yes (widening)
+Metadata-protocol: added
+
+Studio draws the items another installed package brings to a base greyed, naming their source (ADR-0029 D7 as amended by ADR-0146 D3). Until now no served read said which package contributed what. The per-base customization read now answers a fourth list, `contributions`, with its count:
+
+- **What is listed.** Every field another package's object extension (`objectExtensions`) adds to an object the base owns, and every flow such a package ships whose trigger object (the `start` node's `config.objectName`) the base owns. One entry per item: `{ kind: 'field' | 'flow', object, name, packageId }`.
+- **What is not.** The base's own items, and its `.custom` pair's (the pair's members stay in `extensions`). A flow authored in this environment is the environment's, not a package's. A flow of a disabled package is not served, so it is not listed.
+- **The stamp is on the item.** Two packages adding a field of the same name are two entries, each naming its package; a client joins nothing by name.
+- `GetPackageCustomizationsResponseSchema` declares the member and `counts.contributions`, strict as the rest of the answer. A client parsing the answer through the schema needs this version of `@objectstack/spec`.
+
+Navigation entries contributed to the base's app are not in this list yet.
diff --git a/content/docs/references/api/package-lifecycle.mdx b/content/docs/references/api/package-lifecycle.mdx
index d5f814c7485..34ff077ecc0 100644
--- a/content/docs/references/api/package-lifecycle.mdx
+++ b/content/docs/references/api/package-lifecycle.mdx
@@ -163,7 +163,7 @@ Per-package customization read request: the base package id, no query parameters
## GetPackageCustomizationsResponse
-Per-package customization read: overrides, extensions and switched-off items of one installed base.
+Per-package customization read: overrides, extensions and switched-off items of one installed base, and the fields and flows other packages contribute to it.
### Properties
@@ -173,7 +173,8 @@ Per-package customization read: overrides, extensions and switched-off items of
| **overrides** | `{ type: string; name: string; member?: string; basedOn?: string; … }[]` | ✅ | Every ACTIVE item of the base's paired `.custom` package that declares `overrides.package` = the base and that the effective read serves over the base's item of the same name: the rows the served-override predicate admits (a declaration from any other package, and an undeclared pair item, are not listed), minus the stored rows every read declines (a shipped flow name). Ordered by type, name and member. |
| **extensions** | `{ object: string; kind: Enum<'field' \| 'validation' \| 'index'>; name?: string; fields?: string[] }[]` | ✅ | Every field, validation rule and index the paired `.custom` package declares in an object extension (`objectExtensions`) of an object the base owns, one entry per member, as the extension declares it. Ordered by object, kind and name. |
| **switchedOff** | `{ type: string; name: string }[]` | ✅ | Every activation-ledger row (`sys_metadata_activation`) that records the base as its package and `active: false`: the base's items this deployment switched off. Ordered by type and name. |
-| **counts** | `{ overrides: integer; extensions: integer; switchedOff: integer }` | ✅ | Each list's length, derived from the lists in this same answer. |
+| **contributions** | `{ kind: Enum<'field' \| 'flow'>; object: string; name: string; packageId: string }[]` | ✅ | Every field and flow another installed package contributes to the base's objects, one entry per item and contributing package: the fields of every object extension of an object the base owns, from any package but the base and its pair, and every flow registered under such a package whose trigger object the base owns (a flow of a disabled package is not served, so not listed). Ordered by object, kind, name and package. |
+| **counts** | `{ overrides: integer; extensions: integer; switchedOff: integer; contributions: integer }` | ✅ | Each list's length, derived from the lists in this same answer. |
### Nested Shape: `GetPackageCustomizationsResponse.overrides[number]`
@@ -201,6 +202,15 @@ Per-package customization read: overrides, extensions and switched-off items of
| **type** | `string` | ✅ | The ledger row's metadata type (`flow`, `action`, `position`, …). |
| **name** | `string` | ✅ | The switched-off item's name. |
+### Nested Shape: `GetPackageCustomizationsResponse.contributions[number]`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **kind** | `Enum<'field' \| 'flow'>` | ✅ | What is contributed: `field`, a field another package's object extension (`objectExtensions`) adds to one of the base's objects; `flow`, a flow another package ships whose trigger object (its `start` node's `config.objectName`) is one of the base's objects. |
+| **object** | `string` | ✅ | The base-owned object the item is on: the extended object, or the flow's trigger object. |
+| **name** | `string` | ✅ | The item's name: the field name as the extension declares it, or the flow's name. |
+| **packageId** | `string` | ✅ | The package that contributes the item: the extension's package, or the package the flow is registered under. Never the base and never its `.custom` pair. |
+
### Nested Shape: `GetPackageCustomizationsResponse.counts`
| Property | Type | Required | Description |
@@ -208,6 +218,7 @@ Per-package customization read: overrides, extensions and switched-off items of
| **overrides** | `integer` | ✅ | `overrides.length`. |
| **extensions** | `integer` | ✅ | `extensions.length`: members, not objects. |
| **switchedOff** | `integer` | ✅ | `switchedOff.length`. |
+| **contributions** | `integer` | ✅ | `contributions.length`: items, not packages. |
---
diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md
index 026d1041d08..95f1ccf5408 100644
--- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md
+++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
| Dir | Sites |
|---|---|
-| `api/` | 477 |
+| `api/` | 478 |
diff --git a/packages/runtime/src/dispatcher-plugin.package-customizations-mount.test.ts b/packages/runtime/src/dispatcher-plugin.package-customizations-mount.test.ts
index 670c333a0a8..2e9a18896d1 100644
--- a/packages/runtime/src/dispatcher-plugin.package-customizations-mount.test.ts
+++ b/packages/runtime/src/dispatcher-plugin.package-customizations-mount.test.ts
@@ -100,7 +100,8 @@ describe('GET /api/v1/packages/:id/customizations — mounted', () => {
overrides: [OVERRIDE],
extensions: [],
switchedOff: [],
- counts: { overrides: 1, extensions: 0, switchedOff: 0 },
+ contributions: [],
+ counts: { overrides: 1, extensions: 0, switchedOff: 0, contributions: 0 },
});
const parsed = GetPackageCustomizationsResponseSchema.safeParse(res.body.data);
expect(parsed.success, JSON.stringify(parsed.success ? null : parsed.error.issues)).toBe(true);
diff --git a/packages/runtime/src/domains/packages-customizations.test.ts b/packages/runtime/src/domains/packages-customizations.test.ts
index a934c62b94e..beb069626f4 100644
--- a/packages/runtime/src/domains/packages-customizations.test.ts
+++ b/packages/runtime/src/domains/packages-customizations.test.ts
@@ -339,7 +339,8 @@ describe('[#28411] GET /packages/:id/customizations — overrides, extensions an
overrides: [{ type: 'view', name: VIEW, basedOn: '1.0.0', version: expect.any(Number) }],
extensions: [{ object: 'showcase_task', kind: 'field', name: 'region__c' }],
switchedOff: [{ type: 'flow', name: 'showcase_task_reminder' }],
- counts: { overrides: 1, extensions: 1, switchedOff: 1 },
+ contributions: [],
+ counts: { overrides: 1, extensions: 1, switchedOff: 1, contributions: 0 },
});
const parsed = GetPackageCustomizationsResponseSchema.safeParse(data);
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
@@ -442,7 +443,8 @@ describe('[#28411] GET /packages/:id/customizations — overrides, extensions an
overrides: [],
extensions: [],
switchedOff: [],
- counts: { overrides: 0, extensions: 0, switchedOff: 0 },
+ contributions: [],
+ counts: { overrides: 0, extensions: 0, switchedOff: 0, contributions: 0 },
});
expect(engine.registry.getAllPackages().length).toBe(before);
expect(engine.registry.getPackage(PAIR)).toBeFalsy();
@@ -488,3 +490,94 @@ describe('[#28411] GET /packages/:id/customizations — overrides, extensions an
expect(r.response?.body?.error?.message).toContain('includeDrafts');
});
});
+
+const THIRD = 'com.example.acme-sync';
+const FOURTH = 'com.example.partner';
+
+/** A flow triggered by a record change on `object`. */
+const flowOn = (name: string, object: string) => ({
+ name,
+ label: name,
+ type: 'record_change',
+ status: 'active',
+ nodes: [
+ { id: 'start', type: 'start', label: 'Start', config: { objectName: object, triggerType: 'record-after-update' } },
+ { id: 'end', type: 'end', label: 'End' },
+ ],
+ edges: [{ id: 'e1', source: 'start', target: 'end' }],
+});
+
+/** A code package registered as the engine registers every manifest: an object extension and flows. */
+const registerContributor = (engine: ObjectQL, id: string, fields: readonly string[], flows: ReadonlyArray>) =>
+ engine.registerApp({
+ id,
+ name: id.split('.').join('-'),
+ version: '1.0.0',
+ objectExtensions: [{
+ extend: 'showcase_task',
+ fields: Object.fromEntries(fields.map((f) => [f, { name: f, label: f, type: 'text' }])),
+ }],
+ flows: [...flows],
+ });
+
+/**
+ * The card's fixture (#28722): the base ships a flow on its object, the pair
+ * extends that object and carries a flow on it, and a third code package
+ * extends the same object with a field and ships a flow on it — plus a flow
+ * on an object the base does not own.
+ */
+async function contributedFixture() {
+ const booted = await boot();
+ const { engine, protocol } = booted;
+ engine.registry.registerItem('flow', flowOn('showcase_task_escalate', 'showcase_task'), 'name', BASE);
+ await install(protocol, PAIR);
+ extend(engine, PAIR, 'showcase_task', { fields: { region__c: { name: 'region__c', label: 'Region', type: 'text' } } });
+ engine.registry.registerItem('flow', flowOn('showcase_task_region_notify', 'showcase_task'), 'name', PAIR);
+ registerContributor(engine, THIRD, ['acme_sync_id'], [flowOn('acme_task_sync', 'showcase_task'), flowOn('acme_note_sync', 'other_note')]);
+ return booted;
+}
+
+describe('[#28722] GET /packages/:id/customizations — the fields and flows other packages contribute to a base', () => {
+ it('names the third package for its extension field and its flow, and names neither the base nor the pair', async () => {
+ const { dispatcher } = await contributedFixture();
+ const r = await customizations(dispatcher, BASE);
+ expect(r.response?.status).toBe(200);
+ const data = r.response?.body?.data;
+ expect(data.contributions).toEqual([
+ { kind: 'field', object: 'showcase_task', name: 'acme_sync_id', packageId: THIRD },
+ { kind: 'flow', object: 'showcase_task', name: 'acme_task_sync', packageId: THIRD },
+ ]);
+ expect(data.counts.contributions).toBe(2);
+ // The pair's member stays where N-15 serves it, and is not a contribution.
+ expect(data.extensions).toEqual([{ object: 'showcase_task', kind: 'field', name: 'region__c' }]);
+ const parsed = GetPackageCustomizationsResponseSchema.safeParse(data);
+ expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
+ });
+
+ it('stamps each item with its own package: two packages adding a field of one name are two entries', async () => {
+ const { dispatcher, engine } = await contributedFixture();
+ registerContributor(engine, FOURTH, ['acme_sync_id'], []);
+ const { contributions } = (await customizations(dispatcher, BASE)).response?.body?.data;
+ expect(contributions.filter((c: any) => c.kind === 'field')).toEqual([
+ { kind: 'field', object: 'showcase_task', name: 'acme_sync_id', packageId: THIRD },
+ { kind: 'field', object: 'showcase_task', name: 'acme_sync_id', packageId: FOURTH },
+ ]);
+ });
+
+ it('a flow authored in this environment is not a package\'s contribution', async () => {
+ const { dispatcher, engine } = await contributedFixture();
+ // The bare slot every package-less registration takes: the sentinel
+ // stamp, and a tenant-authored overlay wearing a real package's id.
+ engine.registry.registerItem('flow', { ...flowOn('showcase_task_local', 'showcase_task'), _packageId: 'sys_metadata' } as any);
+ engine.registry.registerItem('flow', { ...flowOn('showcase_task_org', 'showcase_task'), _packageId: THIRD, _provenance: 'org' } as any);
+ const { contributions } = (await customizations(dispatcher, BASE)).response?.body?.data;
+ expect(contributions.map((c: any) => `${c.kind}:${c.name}`)).toEqual(['field:acme_sync_id', 'flow:acme_task_sync']);
+ });
+
+ it('another base\'s read lists what is contributed to its own objects only', async () => {
+ const { dispatcher } = await contributedFixture();
+ const data = (await customizations(dispatcher, OTHER)).response?.body?.data;
+ expect(data.contributions).toEqual([{ kind: 'flow', object: 'other_note', name: 'acme_note_sync', packageId: THIRD }]);
+ expect(data.counts.contributions).toBe(1);
+ });
+});
diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts
index c1d593d41c9..05d05a1e276 100644
--- a/packages/runtime/src/domains/packages.ts
+++ b/packages/runtime/src/domains/packages.ts
@@ -32,7 +32,7 @@ import {
// (`packages-capability-gate.test.ts`, beside this file), so it names
// `OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES` — same value it read before,
// no re-ruling of the package cohort as a side effect of #7020.
-import { OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES } from '@objectstack/metadata-core';
+import { OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES, isCodeArtifactBody } from '@objectstack/metadata-core';
// [#28411, ADR-0146 D8 item 4] The per-package customization read's three
// sources, each read from its owner: the pair's id and a pair's base
// (`@objectstack/spec/kernel`, the one spelling of the suffix), and the
@@ -2527,7 +2527,8 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
// GET /packages/:id/customizations → [#28411, ADR-0146 D8 item 4] what
// this deployment changed on an installed base: its `.custom` pair's
// served overrides, the members the pair adds to the base's objects,
- // and the base's items switched off in the activation ledger. See
+ // the base's items switched off in the activation ledger, and the
+ // fields and flows other packages contribute to it (#28722). See
// {@link readPackageCustomizations}.
if (parts.length === 2 && parts[1] === 'customizations' && m === 'GET') {
// The read authority of `GET /packages/:id`, the same gate, asked
@@ -3093,7 +3094,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
*/
/**
* [#28411, ADR-0146 D8 item 4, D3] The answer of `GET /packages/:id/customizations`
- * for the installed base `baseId`: three lists, each read from the one owner
+ * for the installed base `baseId`: four lists, each read from the one owner
* of its fact, and their lengths. Served rows and ledger rows only; ⛔ no diff
* against the base and no `basedOn` verdict (ADR-0146 D2, D6).
*
@@ -3111,6 +3112,9 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
* ({@link listPackageMetadataActivations}). A composition with no ledger
* object has switched nothing off, which is the ledger's own reading of
* absence (ADR-0126 §4: no row means active).
+ * - **contributions** — [#28722, ADR-0029 D7 / ADR-0146 D3] what OTHER packages
+ * bring to the base's surface, each item stamped with its package: see
+ * {@link readBaseContributions}.
*
* No per-organization axis (ADR-0131 D6): every source is environment-level,
* so every caller the read gate admits gets the same answer. A base with no
@@ -3168,15 +3172,91 @@ async function readPackageCustomizations(
.map((row) => ({ type: row.metadataType, name: row.name }))
.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
+ const contributions = readBaseContributions(baseId, pair.ok ? pair.value : undefined, registry);
+
return {
packageId: baseId,
overrides,
extensions,
switchedOff,
- counts: { overrides: overrides.length, extensions: extensions.length, switchedOff: switchedOff.length },
+ contributions,
+ counts: {
+ overrides: overrides.length,
+ extensions: extensions.length,
+ switchedOff: switchedOff.length,
+ contributions: contributions.length,
+ },
};
}
+/**
+ * [#28722, ADR-0029 D7 as amended by ADR-0146 D3] The `contributions` of
+ * `GET /packages/:id/customizations`: every field and flow a package other than
+ * the base `baseId` and its pair `pairId` contributes to an object the base
+ * OWNS (`getObjectOwner`), each entry naming that package. Read from the two
+ * registries that already record the contributor, never inferred:
+ *
+ * - **field** — the object registry's `extend` contributors of the object, as
+ * the customizations' `extensions` reads the pair's: each contributor's
+ * `definition` is its extension body as declared, so its `fields` keys ARE
+ * the fields it adds, and its `packageId` is the package that declared it.
+ * One entry per contributor and field, so two packages adding a field of
+ * one name are two entries.
+ * - **flow** — the metadata registry's flows (`listItems('flow')`, which
+ * already leaves a disabled package's out), whose `start` node's
+ * `config.objectName` is a base-owned object, attributed to the package
+ * stamp the registration put on it when that stamp is a code package's
+ * ({@link isCodeArtifactBody}: not the package-less `sys_metadata`
+ * sentinel, not a tenant-authored overlay). A flow authored in this
+ * environment is the environment's, not a package's, and is not listed.
+ *
+ * Ordered by object, kind, name and package.
+ */
+function readBaseContributions(
+ baseId: string,
+ pairId: string | undefined,
+ registry: any,
+): GetPackageCustomizationsResponse['contributions'] {
+ type Contribution = GetPackageCustomizationsResponse['contributions'][number];
+ const contributions: Contribution[] = [];
+ if (typeof registry?.getAllObjects !== 'function') return contributions;
+ const isContributor = (packageId: unknown): packageId is string =>
+ typeof packageId === 'string' && packageId !== '' && packageId !== baseId && packageId !== pairId;
+
+ const owned = new Set();
+ for (const object of registry.getAllObjects(baseId) as Array<{ name?: unknown }>) {
+ const name = object?.name;
+ if (typeof name === 'string' && registry.getObjectOwner(name)?.packageId === baseId) owned.add(name);
+ }
+
+ for (const name of owned) {
+ for (const contributor of registry.getObjectContributors(name) as Array<{ packageId?: unknown; ownership?: unknown; definition?: any }>) {
+ if (contributor.ownership !== 'extend' || !isContributor(contributor.packageId)) continue;
+ for (const field of Object.keys(contributor.definition?.fields ?? {})) {
+ contributions.push({ kind: 'field', object: name, name: field, packageId: contributor.packageId });
+ }
+ }
+ }
+
+ if (owned.size > 0 && typeof registry.listItems === 'function') {
+ for (const flow of registry.listItems('flow') as Array<{ name?: unknown; nodes?: unknown; _packageId?: unknown }>) {
+ if (!isCodeArtifactBody(flow) || !isContributor(flow._packageId) || typeof flow.name !== 'string') continue;
+ const start = Array.isArray(flow.nodes)
+ ? flow.nodes.find((node: any) => node?.type === 'start')
+ : undefined;
+ const object = start?.config?.objectName;
+ if (typeof object !== 'string' || !owned.has(object)) continue;
+ contributions.push({ kind: 'flow', object, name: flow.name, packageId: flow._packageId });
+ }
+ }
+
+ const kindOrder = { field: 0, flow: 1 } as const;
+ return contributions.sort((a, b) => a.object.localeCompare(b.object)
+ || kindOrder[a.kind] - kindOrder[b.kind]
+ || a.name.localeCompare(b.name)
+ || a.packageId.localeCompare(b.packageId));
+}
+
async function assemblePackageManifest(
deps: DomainHandlerDeps,
packageId: string,
diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts
index 3dc5d07f620..8bba418ad5d 100644
--- a/packages/runtime/src/route-ledger.ts
+++ b/packages/runtime/src/route-ledger.ts
@@ -549,7 +549,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
// [#28411, ADR-0146 D8 item 4] The per-package customization read.
{ route: 'GET /packages/:id/customizations', domain: '/packages', disposition: 'sdk', client: 'packages.customizations',
responseSchema: 'GetPackageCustomizationsResponseSchema',
- note: 'Enveloped — the named schema is the `data`. A new route whose declaration is its contract, not a transcription: for an installed base, its `.custom` pair\'s served overrides (the served-override predicate the effective read asks, never re-derived), the fields, validations and indexes the pair\'s object extensions add to objects the base owns, and the base\'s `active: false` activation-ledger rows, with each list\'s length. Same read gate and 404 wording as `GET /packages/:id`; closed query set (none), declared with the path id in `GetPackageCustomizationsRequestSchema` (no `requestSchema` here: that field names a JSON body, and a GET reads none); a `.custom` id refused 400 `VALIDATION_ERROR` naming its base; no pair answers empty lists and never creates one. Pinned in `domains/packages-customizations.test.ts`, which drives THIS handler over the real protocol and parses the payload through the response schema' },
+ note: 'Enveloped — the named schema is the `data`. A new route whose declaration is its contract, not a transcription: for an installed base, its `.custom` pair\'s served overrides (the served-override predicate the effective read asks, never re-derived), the fields, validations and indexes the pair\'s object extensions add to objects the base owns, the base\'s `active: false` activation-ledger rows, and every field and flow another package contributes to the base\'s objects, each entry naming its package, with each list\'s length. Same read gate and 404 wording as `GET /packages/:id`; closed query set (none), declared with the path id in `GetPackageCustomizationsRequestSchema` (no `requestSchema` here: that field names a JSON body, and a GET reads none); a `.custom` id refused 400 `VALIDATION_ERROR` naming its base; no pair answers empty lists and never creates one. Pinned in `domains/packages-customizations.test.ts`, which drives THIS handler over the real protocol and parses the payload through the response schema' },
{ route: 'POST /packages/:id/adopt-orphans', domain: '/packages', disposition: 'sdk', client: 'packages.adoptOrphans',
responseSchema: 'ReassignOrphanedMetadataResponseSchema',
note: 'Enveloped — the named schema is the `data`: `reassignOrphanedMetadata`\'s declared return, transcribed describe-only. Conformance: spec `api/package-lifecycle.test.ts`' },
diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json
index 0053ba102cf..fa42c7d26d0 100644
--- a/packages/spec/authorable-surface/api.json
+++ b/packages/spec/authorable-surface/api.json
@@ -840,6 +840,7 @@
"api/GetObjectPermissionsResponse:object",
"api/GetObjectPermissionsResponse:permissions",
"api/GetPackageCustomizationsRequest:packageId",
+ "api/GetPackageCustomizationsResponse:contributions",
"api/GetPackageCustomizationsResponse:counts",
"api/GetPackageCustomizationsResponse:extensions",
"api/GetPackageCustomizationsResponse:overrides",
diff --git a/packages/spec/src/api/package-lifecycle.zod.ts b/packages/spec/src/api/package-lifecycle.zod.ts
index 562eb39dc00..83aface01e9 100644
--- a/packages/spec/src/api/package-lifecycle.zod.ts
+++ b/packages/spec/src/api/package-lifecycle.zod.ts
@@ -346,6 +346,14 @@ export const GetPackageCustomizationsRequestSchema = lazySchema(() => z.object({
*
* X with no `.custom` package answers empty lists and zero counts (the read
* never creates the pair); the switched-off list does not depend on the pair.
+ *
+ * [#28722, ADR-0029 D7 as amended by ADR-0146 D3] Beside the pair's changes,
+ * the same read names what OTHER installed packages bring to X's surface, so
+ * Studio can draw those items greyed with their source: `contributions`, one
+ * entry per item, each stamped with the package that contributes it. The
+ * stamp is on the item, so two packages adding a field of the same name are
+ * two entries and nothing is joined by name on the client. X's own items and
+ * the pair's (listed above) are never contributions.
*/
export const GetPackageCustomizationsResponseSchema = lazySchema(() => z.object({
packageId: z.string().describe('The base package the customizations are of (the request\'s id).'),
@@ -390,12 +398,34 @@ export const GetPackageCustomizationsResponseSchema = lazySchema(() => z.object(
'Every activation-ledger row (`sys_metadata_activation`) that records the base as its package and '
+ '`active: false`: the base\'s items this deployment switched off. Ordered by type and name.',
),
+ contributions: z.array(z.object({
+ kind: z.enum(['field', 'flow']).describe(
+ 'What is contributed: `field`, a field another package\'s object extension (`objectExtensions`) adds to '
+ + 'one of the base\'s objects; `flow`, a flow another package ships whose trigger object (its `start` '
+ + 'node\'s `config.objectName`) is one of the base\'s objects.',
+ ),
+ object: z.string().describe('The base-owned object the item is on: the extended object, or the flow\'s trigger object.'),
+ name: z.string().describe('The item\'s name: the field name as the extension declares it, or the flow\'s name.'),
+ packageId: z.string().describe(
+ 'The package that contributes the item: the extension\'s package, or the package the flow is registered '
+ + 'under. Never the base and never its `.custom` pair.',
+ ),
+ }).strict()).describe(
+ 'Every field and flow another installed package contributes to the base\'s objects, one entry per item and '
+ + 'contributing package: the fields of every object extension of an object the base owns, from any package '
+ + 'but the base and its pair, and every flow registered under such a package whose trigger object the base '
+ + 'owns (a flow of a disabled package is not served, so not listed). Ordered by object, kind, name and package.',
+ ),
counts: z.object({
overrides: z.number().int().nonnegative().describe('`overrides.length`.'),
extensions: z.number().int().nonnegative().describe('`extensions.length`: members, not objects.'),
switchedOff: z.number().int().nonnegative().describe('`switchedOff.length`.'),
+ contributions: z.number().int().nonnegative().describe('`contributions.length`: items, not packages.'),
}).strict().describe('Each list\'s length, derived from the lists in this same answer.'),
-}).strict().describe('Per-package customization read: overrides, extensions and switched-off items of one installed base.'));
+}).strict().describe(
+ 'Per-package customization read: overrides, extensions and switched-off items of one installed base, and the '
+ + 'fields and flows other packages contribute to it.',
+));
/**
* The request of `ensureCustomizationPackage` — the ONE creation path of a
diff --git a/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.customizations-28568.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.customizations-28568.test.tsx
index 163fb224171..1d342770c7f 100644
--- a/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.customizations-28568.test.tsx
+++ b/ui/packages/app-shell/src/views/metadata-admin/metadataAreaHost.customizations-28568.test.tsx
@@ -113,9 +113,10 @@ const HOTCRM_READ = {
],
extensions: [{ object: 'crm_account', kind: 'field', name: 'tier__c' }],
switchedOff: [],
- counts: { overrides: 2, extensions: 1, switchedOff: 0 },
+ contributions: [],
+ counts: { overrides: 2, extensions: 1, switchedOff: 0, contributions: 0 },
};
-const EMPTY_READ = { packageId: PLAIN, overrides: [], extensions: [], switchedOff: [], counts: { overrides: 0, extensions: 0, switchedOff: 0 } };
+const EMPTY_READ = { packageId: PLAIN, overrides: [], extensions: [], switchedOff: [], contributions: [], counts: { overrides: 0, extensions: 0, switchedOff: 0, contributions: 0 } };
let customizationsFail = false;
const fetchMock = vi.fn(async (url: string) => {
diff --git a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.customizationCounts-28566.test.tsx b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.customizationCounts-28566.test.tsx
index a2f8b4045df..83e19b9b9cc 100644
--- a/ui/packages/app-shell/src/views/studio-design/BuilderLanding.customizationCounts-28566.test.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/BuilderLanding.customizationCounts-28566.test.tsx
@@ -47,7 +47,8 @@ function served(id: string, overrides: number, extensions: number, switchedOff:
overrides: Array.from({ length: overrides }, (_, i) => ({ type: 'view', name: `view_${i}` })),
extensions: Array.from({ length: extensions }, (_, i) => ({ object: 'account', kind: 'field' as const, name: `field_${i}` })),
switchedOff: Array.from({ length: switchedOff }, (_, i) => ({ type: 'flow', name: `flow_${i}` })),
- counts: { overrides, extensions, switchedOff },
+ contributions: [],
+ counts: { overrides, extensions, switchedOff, contributions: 0 },
};
return { status: 200, body: { success: true, data } };
}
diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx
index 25f1c162efd..ebfc9d35c4a 100644
--- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx
+++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx
@@ -130,9 +130,10 @@ const READ = {
overrides: [{ type: 'dashboard', name: 'pipeline', basedOn: '4.0.0' }],
extensions: [{ object: 'crm_account', kind: 'field', name: 'tier__c' }],
switchedOff: [],
- counts: { overrides: 1, extensions: 1, switchedOff: 0 },
+ contributions: [],
+ counts: { overrides: 1, extensions: 1, switchedOff: 0, contributions: 0 },
};
-const EMPTY_READ = { packageId: BASE, overrides: [], extensions: [], switchedOff: [], counts: { overrides: 0, extensions: 0, switchedOff: 0 } };
+const EMPTY_READ = { packageId: BASE, overrides: [], extensions: [], switchedOff: [], contributions: [], counts: { overrides: 0, extensions: 0, switchedOff: 0, contributions: 0 } };
/** The base's own lists, by type. The override is served under the base's name (ADR-0146 D8 item 2). */
const BASE_LISTS: Record>> = {
diff --git a/ui/packages/app-shell/src/views/studio-design/customizationMarks.test.ts b/ui/packages/app-shell/src/views/studio-design/customizationMarks.test.ts
index ab962c99e88..098ff6c0b72 100644
--- a/ui/packages/app-shell/src/views/studio-design/customizationMarks.test.ts
+++ b/ui/packages/app-shell/src/views/studio-design/customizationMarks.test.ts
@@ -28,7 +28,8 @@ const READ: PackageCustomizations = {
{ object: 'crm_lead', kind: 'index', fields: ['email'] },
],
switchedOff: [{ type: 'flow', name: 'nightly_sync' }],
- counts: { overrides: 3, extensions: 2, switchedOff: 1 },
+ contributions: [],
+ counts: { overrides: 3, extensions: 2, switchedOff: 1, contributions: 0 },
};
const index = indexCustomizations('hotcrm', READ)!;
@@ -68,7 +69,7 @@ describe('the directory\'s numbers (objectstack#28568)', () => {
});
it('an empty read counts nothing', () => {
- const empty = { ...READ, overrides: [], extensions: [], counts: { overrides: 0, extensions: 0, switchedOff: 1 } };
+ const empty = { ...READ, overrides: [], extensions: [], counts: { overrides: 0, extensions: 0, switchedOff: 1, contributions: 0 } };
expect(customizationCountsByType(empty)).toEqual({});
expect(customizationTotal(empty)).toBe(0);
});