From 74f0c1943f11f76f2cde58d3768a777dc64a8b4d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 15:58:57 +0000 Subject: [PATCH 1/7] feat(runtime): name the package behind each field and flow contributed to an installed base GET /packages/:id/customizations gains a contributions list: every field another package's object extension adds to one of the base's objects and every flow such a package ships whose trigger object the base owns, each stamped with its package. The base and its .custom pair are never listed. Co-Authored-By: Claude --- .../domains/packages-customizations.test.ts | 97 ++++++++++++++++++- packages/runtime/src/domains/packages.ts | 88 ++++++++++++++++- packages/spec/authorable-surface/api.json | 1 + .../spec/src/api/package-lifecycle.zod.ts | 32 +++++- 4 files changed, 211 insertions(+), 7 deletions(-) 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/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 5fc2984b66e..39d8c6e7d37 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 From 2239ed4f2d27cff74a1fd31d82293442f8783c8e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 16:04:29 +0000 Subject: [PATCH 2/7] test(ui): carry the contributions member in hand-built customization answers Regenerate the spec docs and strictness ledger for the new member, and add the changeset. Co-Authored-By: Claude --- .../28722-customizations-contributions.md | 18 ++++++++++++++++++ .../docs/references/api/package-lifecycle.mdx | 15 +++++++++++++-- .../api.md | 2 +- ...plugin.package-customizations-mount.test.ts | 3 ++- ...adataAreaHost.customizations-28568.test.tsx | 5 +++-- ...rLanding.customizationCounts-28566.test.tsx | 3 ++- .../studio-design/customizationMarks.test.ts | 5 +++-- 7 files changed, 42 insertions(+), 9 deletions(-) create mode 100644 .changeset/28722-customizations-contributions.md diff --git a/.changeset/28722-customizations-contributions.md b/.changeset/28722-customizations-contributions.md new file mode 100644 index 00000000000..ec85a5de615 --- /dev/null +++ b/.changeset/28722-customizations-contributions.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': patch +'@objectstack/runtime': patch +--- + +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: none + +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 6f0f8431d55..b02790966a0 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/` | 474 | +| `api/` | 475 | 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/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/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); }); From 274f09f98695a9be7e2e7e97dacfcf84c2a44c55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 16:16:24 +0000 Subject: [PATCH 3/7] docs(runtime): the route ledger's customizations note names the contributions list Co-Authored-By: Claude --- packages/runtime/src/route-ledger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 74b1d3cf29e..42bab952303 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 (#28722) 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`' }, From b4a36765eae2ce76d1362860806d2354b850cb47 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 16:53:59 +0000 Subject: [PATCH 4/7] docs(runtime): keep the tracker id out of the route ledger's note Co-Authored-By: Claude --- packages/runtime/src/route-ledger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 42bab952303..4ff21796900 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, the base\'s `active: false` activation-ledger rows, and (#28722) 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' }, + 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`' }, From 7edbf44b9d7d6cd13699f7c65e03230b1a4f9ec3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:10:16 +0000 Subject: [PATCH 5/7] chore(changeset): declare the contributions member as Metadata-protocol added, minor The new member moves the spec's authorable-surface shard, so the kind is added and spec and runtime take minor. Co-Authored-By: Claude --- .changeset/28722-customizations-contributions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/28722-customizations-contributions.md b/.changeset/28722-customizations-contributions.md index ec85a5de615..34848f898fc 100644 --- a/.changeset/28722-customizations-contributions.md +++ b/.changeset/28722-customizations-contributions.md @@ -1,12 +1,12 @@ --- -'@objectstack/spec': patch -'@objectstack/runtime': patch +'@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: none +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: From 8952f7ef8a023b69b595ed3f716c21490420756a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 18:17:35 +0000 Subject: [PATCH 6/7] chore(spec): regenerate the strictness ledger and authorable-surface shard on the merged tree Co-Authored-By: Claude --- .../api.md | 2 +- packages/spec/authorable-surface/api.json | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) 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 b02790966a0..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/` | 475 | +| `api/` | 478 | diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 39d8c6e7d37..fa42c7d26d0 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1093,6 +1093,17 @@ "api/MarkNotificationsReadRequest:ids", "api/MarkNotificationsReadResponse:readCount", "api/MarkNotificationsReadResponse:success", + "api/MePermissionsAnonymousResponse:authenticated", + "api/MePermissionsAuthenticatedResponse:authenticated", + "api/MePermissionsAuthenticatedResponse:fields", + "api/MePermissionsAuthenticatedResponse:hasAnyQueue", + "api/MePermissionsAuthenticatedResponse:objects", + "api/MePermissionsAuthenticatedResponse:permissionSets", + "api/MePermissionsAuthenticatedResponse:positions", + "api/MePermissionsAuthenticatedResponse:systemPermissions", + "api/MePermissionsAuthenticatedResponse:tabPermissions", + "api/MePermissionsAuthenticatedResponse:tenantId", + "api/MePermissionsAuthenticatedResponse:userId", "api/MetadataAuthoringClient:id", "api/MetadataAuthoringClient:name", "api/MetadataBulkRegisterRequest:continueOnError", From fb7fafd3d3e4b4c49b15955ce00c5f17323bdcb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 19:21:19 +0000 Subject: [PATCH 7/7] test(ui): carry the contributions member in the one-surface test's customization answers Co-Authored-By: Claude --- .../StudioDesignSurface.oneSurface-28572.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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>> = {