From d850be1f47bc3800ea516058d0954cc3bacada59 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:28:15 +0000 Subject: [PATCH 01/11] test(objectql): red pins for an extension's fieldPatches on every object read; the field-patch target judge Claude-Session: https://claude.ai/code/session_01FEkQ5nSMgkteiemkoN31vf Co-authored-by: Claude --- .../src/extension-field-patch-targets.ts | 184 +++++++++++++++ packages/metadata-core/src/index.ts | 6 + .../registry-extension-field-patches.test.ts | 222 ++++++++++++++++++ 3 files changed, 412 insertions(+) create mode 100644 packages/metadata-core/src/extension-field-patch-targets.ts create mode 100644 packages/objectql/src/registry-extension-field-patches.test.ts diff --git a/packages/metadata-core/src/extension-field-patch-targets.ts b/packages/metadata-core/src/extension-field-patch-targets.ts new file mode 100644 index 00000000000..4cbdcd4e424 --- /dev/null +++ b/packages/metadata-core/src/extension-field-patch-targets.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0146 D10, second rule] The TARGET CHECK of an object extension's + * `fieldPatches`: does the object it extends have every field it patches, and + * every option value it patches under `options`? + * + * The slot's SHAPE is the spec's (`ObjectExtensionSchema.fieldPatches`, keys + * derived from `BASE_FIELD_DISPLAY_WHITELIST`); whether the names it uses exist + * is a question about the target, so it is answered where the target is known. + * One judge, three readers: + * + * - the marketplace install door (`POST /api/v1/marketplace/install-local`) + * REFUSES a package whose patch names what its installed target lacks — + * ADR-0141 D2 admitted the base first, so the target is in the registry; + * - `os validate` REFUSES the same patch when the target is in the composed + * stack; + * - the registry fold (`@objectstack/objectql`, `SchemaRegistry`) does not + * apply such a patch and NAMES it once in the log. ⛔ Never a boot refusal: + * a boot is still filling the registry when the fold first runs (AGENTS.md + * *Startup registry reads*), and the patch applies the moment the field + * arrives. + * + * A patch that names a field the target does not have would otherwise change + * nothing, and say nothing: the author reads "Sector" in their package and the + * screen keeps "Industry". + * + * Pure: reads plain bodies and returns findings, so each reader decides what a + * finding means at its door. + */ + +/** One name a field patch uses that its target object does not have. */ +export interface FieldPatchTargetMiss { + /** The extended object, as the extension names it (`extend`). */ + object: string; + /** The patched field — the key under `fieldPatches`. */ + field: string; + /** The patched option's stored value — the key under the patch's `options` — when the miss is an option. */ + option?: string; + /** + * What is missing: `field` (no such field), `option` (no option with that + * stored value), or `picklist-option` (the field takes its options from a + * picklist, which no field patch reaches). + */ + reason: 'field' | 'option' | 'picklist-option'; + /** The sentence an author is shown: what is missing, and what to write instead. */ + message: string; +} + +/** A {@link FieldPatchTargetMiss} located in the manifest that declares it. */ +export interface FieldPatchTargetRefusal extends FieldPatchTargetMiss { + /** Where the patch sits, e.g. `objectExtensions[0].fieldPatches.industry.options.tech`. */ + path: string; +} + +const isRecord = (v: unknown): v is Record => + v !== null && typeof v === 'object' && !Array.isArray(v); + +/** A field of an object body, by name — a name-keyed record or a `name`-bearing array, the two spellings a body carries. */ +function fieldOf(target: unknown, name: string): Record | undefined { + if (!isRecord(target)) return undefined; + const fields = target.fields; + if (Array.isArray(fields)) { + const found = fields.find((f) => isRecord(f) && f.name === name); + return isRecord(found) ? found : undefined; + } + if (!isRecord(fields) || !Object.prototype.hasOwnProperty.call(fields, name)) return undefined; + const field = fields[name]; + return isRecord(field) ? field : undefined; +} + +/** The stored values of a field's own `options`, in order. */ +function optionValuesOf(field: Record): string[] { + const options = field.options; + if (!Array.isArray(options)) return []; + return options + .map((o) => (isRecord(o) ? o.value : undefined)) + .filter((v): v is string => typeof v === 'string'); +} + +/** + * Every name `fieldPatches` uses that `target` does not have. `[]` when the + * patch is absent, malformed (the schema's parse reports that), or fully + * matched. + * + * `target` is the extended object as the reader sees it: the registry's + * resolved object at the install door and in the fold, the composed stack's + * declaration at `os validate`. + */ +export function fieldPatchTargetMisses(object: string, fieldPatches: unknown, target: unknown): FieldPatchTargetMiss[] { + if (!isRecord(fieldPatches)) return []; + const misses: FieldPatchTargetMiss[] = []; + for (const [fieldName, patch] of Object.entries(fieldPatches)) { + const field = fieldOf(target, fieldName); + if (!field) { + misses.push({ + object, + field: fieldName, + reason: 'field', + message: + `\`fieldPatches.${fieldName}\` names a field object \`${object}\` does not have, so the patch would ` + + `change nothing. A field patch changes the display of one of the extended object's own fields: ` + + `use the name of a field \`${object}\` declares, or declare a field of your own in \`fields\`.`, + }); + continue; + } + const options = isRecord(patch) ? patch.options : undefined; + if (!isRecord(options)) continue; + const picklist = typeof field.picklist === 'string' && field.picklist !== '' ? field.picklist : undefined; + const values = optionValuesOf(field); + for (const value of Object.keys(options)) { + if (picklist !== undefined) { + misses.push({ + object, + field: fieldName, + option: value, + reason: 'picklist-option', + message: + `\`fieldPatches.${fieldName}.options.${value}\` patches an option of \`${object}.${fieldName}\`, ` + + `which takes its options from the picklist \`${picklist}\`: the option labels are the picklist's, ` + + `and a field patch never reaches them. Remove \`options\` from this patch.`, + }); + continue; + } + if (values.includes(value)) continue; + misses.push({ + object, + field: fieldName, + option: value, + reason: 'option', + message: + `\`fieldPatches.${fieldName}.options.${value}\` names an option \`${object}.${fieldName}\` does not ` + + `have: an option is addressed by its stored \`value\`, and this field's are ` + + `${values.length > 0 ? values.map((v) => `\`${v}\``).join(', ') : 'none'}. ` + + 'Which options exist stays the vendor\'s, so a patch can only relabel one of them.', + }); + } + } + return misses; +} + +/** + * Every patch in a manifest's `objectExtensions` that names what its target + * lacks, located. An entry whose target `getTarget` does not know is not + * judged: at the install door that target is ADR-0141 D2's to answer, and at + * `os validate` a target outside the composed stack is judged at install. + * + * `pathPrefix` locates the entries for a multi-package artifact + * (`packages[1].manifest.`); empty for a single stack. + */ +export function fieldPatchTargetRefusals( + objectExtensions: unknown, + getTarget: (name: string) => unknown, + pathPrefix = '', +): FieldPatchTargetRefusal[] { + if (!Array.isArray(objectExtensions)) return []; + const refusals: FieldPatchTargetRefusal[] = []; + objectExtensions.forEach((entry, index) => { + if (!isRecord(entry) || typeof entry.extend !== 'string' || !isRecord(entry.fieldPatches)) return; + const target = getTarget(entry.extend); + if (target === undefined || target === null) return; + for (const miss of fieldPatchTargetMisses(entry.extend, entry.fieldPatches, target)) { + const at = `${pathPrefix}objectExtensions[${index}].fieldPatches.${miss.field}`; + refusals.push({ ...miss, path: miss.option === undefined ? at : `${at}.options.${miss.option}` }); + } + }); + return refusals; +} + +/** + * The install door's refusal sentence for a non-empty + * {@link fieldPatchTargetRefusals} answer: how many, the first one whole, and + * that nothing was installed. Every refusal is listed in the answer's + * `details.refusals`. + */ +export function fieldPatchTargetRefusalMessage(packageId: string, refusals: readonly FieldPatchTargetRefusal[]): string { + const n = refusals.length; + return ( + `Package '${packageId}' cannot be installed: ${n} field patch${n > 1 ? 'es name' : ' names'} a field or option ` + + `the extended object does not have (ADR-0146 D10). ${refusals[0]?.path}: ${refusals[0]?.message}` + + (n > 1 ? ` (${n - 1} more in \`details.refusals\`.)` : '') + + ' Nothing was installed or written.' + ); +} diff --git a/packages/metadata-core/src/index.ts b/packages/metadata-core/src/index.ts index e5c279e79c5..501fdd5aee8 100644 --- a/packages/metadata-core/src/index.ts +++ b/packages/metadata-core/src/index.ts @@ -144,3 +144,9 @@ export * from './anonymous-form-intake.js'; // load-time diagnostics (`@objectstack/metadata-protocol`), so the diagnostics // judge a field as its author wrote it. export * from './resolved-picklist-options.js'; + +// [ADR-0146 D10, second rule] The target check of an object extension's +// `fieldPatches` — one judge for the marketplace install door, `os validate` +// and the registry fold's boot log, so the three cannot disagree about which +// patch names a field or option its target lacks. +export * from './extension-field-patch-targets.js'; diff --git a/packages/objectql/src/registry-extension-field-patches.test.ts b/packages/objectql/src/registry-extension-field-patches.test.ts new file mode 100644 index 00000000000..43c79e766ac --- /dev/null +++ b/packages/objectql/src/registry-extension-field-patches.test.ts @@ -0,0 +1,222 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0146 D10, second rule] An object extension's `fieldPatches` reaches + * every object read: the manifest loop carries the slot onto the extension + * def, and `mergeObjectDefinitions` applies it after the additive merges. + * + * A vendor package ships `crm_account.industry` labelled "Industry"; a second + * package patches its display text to "Sector". Every read — the resolved + * object (list and data doors), the by-name fold onto a MetadataService body + * and the code layer (`getArtifactItem`) — serves "Sector", while the field's + * type, requiredness and option VALUES stay the vendor's. Removing the + * extender serves the vendor's text again; a tenant's own label keeps winning + * (ADR-0029 D9.2a); two patching extenders resolve by `priority`; and a patch + * naming what the target lacks is not applied and is named once in the log, + * never a boot refusal. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { SchemaRegistry } from './registry.js'; +import { ObjectQL } from './engine.js'; + +const OWNER_PKG = 'com.vendor.crm'; +const EXT_PKG = 'com.acme.crm-display'; +const OBJ = 'crm_account'; + +/** The vendor's declaration — the packaged text a patch changes and an uninstall restores. */ +function vendorAccount(): any { + return { + name: OBJ, + label: 'Account', + fields: { + name: { name: 'name', type: 'text', label: 'Name' }, + industry: { + name: 'industry', + type: 'select', + label: 'Industry', + description: 'The industry the account belongs to.', + inlineHelpText: 'Pick the closest industry.', + placeholder: 'Select an industry', + required: true, + options: [ + { value: 'tech', label: 'Tech' }, + { value: 'retail', label: 'Retail' }, + ], + }, + }, + }; +} + +/** The patch the card's first test names: four field keys and one option label. */ +const PATCH = { + industry: { + label: 'Sector', + description: 'The market the account sells into.', + inlineHelpText: 'Pick the closest sector.', + placeholder: 'Select a sector', + options: { tech: { label: 'Technology' } }, + }, +}; + +function registry(): SchemaRegistry { + const reg = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never); + reg.logLevel = 'silent'; + return reg; +} + +function withVendor(reg = registry()): SchemaRegistry { + reg.registerObject(vendorAccount(), OWNER_PKG, undefined, 'own'); + return reg; +} + +function extend(reg: SchemaRegistry, fieldPatches: unknown, pkg = EXT_PKG, priority = 200): void { + reg.registerObject({ name: OBJ, fieldPatches } as any, pkg, undefined, 'extend', priority); +} + +const industryOf = (body: any) => body?.fields?.industry; + +/** The vendor field's structure: everything a patch must NOT move. */ +const structureOf = (field: any) => ({ + type: field?.type, + required: field?.required, + values: (field?.options ?? []).map((o: any) => o.value), +}); + +afterEach(() => vi.restoreAllMocks()); + +describe('ADR-0146 D10 — fieldPatches is applied by mergeObjectDefinitions on every object read', () => { + it('patches label, description, help text, placeholder and one option label; type, required and values stay the vendor\'s', () => { + const reg = withVendor(); + extend(reg, PATCH); + + const served = industryOf(reg.getObject(OBJ)); + expect(served.label).toBe('Sector'); + expect(served.description).toBe('The market the account sells into.'); + expect(served.inlineHelpText).toBe('Pick the closest sector.'); + expect(served.placeholder).toBe('Select a sector'); + expect(served.options).toEqual([ + { value: 'tech', label: 'Technology' }, + { value: 'retail', label: 'Retail' }, + ]); + expect(structureOf(served)).toEqual({ type: 'select', required: true, values: ['tech', 'retail'] }); + // The patch is applied, never carried: the served object has no slot of its own. + expect('fieldPatches' in (reg.getObject(OBJ) as any)).toBe(false); + }); + + it('serves the same text on the list read, the by-name fold onto a MetadataService body, and the code layer', () => { + const reg = withVendor(); + extend(reg, PATCH); + + const list = reg.getAllObjects().find((o: any) => o.name === OBJ); + const byName = reg.foldObjectExtendersOnto(OBJ, vendorAccount()); + const code = reg.getArtifactItem('object', OBJ); + for (const body of [list, byName, code]) { + expect(industryOf(body).label).toBe('Sector'); + expect(industryOf(body).options[0]).toEqual({ value: 'tech', label: 'Technology' }); + } + // The fold is idempotent over a body that is already folded (#8027). + expect(industryOf(reg.foldObjectExtendersOnto(OBJ, reg.getObject(OBJ))).label).toBe('Sector'); + }); + + it('does not touch the vendor\'s registered declaration: removing the extender serves the vendor\'s text again', () => { + const reg = withVendor(); + extend(reg, PATCH); + expect(industryOf(reg.getObject(OBJ)).label).toBe('Sector'); + + reg.unregisterObjectsByPackage(EXT_PKG); + + const served = industryOf(reg.getObject(OBJ)); + expect(served.label).toBe('Industry'); + expect(served.description).toBe('The industry the account belongs to.'); + expect(served.inlineHelpText).toBe('Pick the closest industry.'); + expect(served.placeholder).toBe('Select an industry'); + expect(served.options[0]).toEqual({ value: 'tech', label: 'Tech' }); + }); + + it('[ADR-0029 D9.2a] yields to a tenant-authored label and option label, and still applies the keys the tenant left alone', () => { + const reg = withVendor(); + extend(reg, PATCH); + const overlay = vendorAccount(); + overlay.fields.industry.label = 'Branche'; + overlay.fields.industry.options[0].label = 'Hightech'; + reg.registerObject({ ...overlay, _provenance: 'org' }, 'sys_metadata', undefined, 'overlay'); + + const served = industryOf(reg.getObject(OBJ)); + expect(served.label).toBe('Branche'); + expect(served.options[0]).toEqual({ value: 'tech', label: 'Hightech' }); + // The tenant left these at the vendor's value, so the extension's patch applies. + expect(served.description).toBe('The market the account sells into.'); + expect(served.placeholder).toBe('Select a sector'); + }); + + it('two extenders patching one key: the higher priority wins, whichever registers first', () => { + for (const order of ['low-first', 'high-first'] as const) { + const reg = withVendor(); + const low = () => extend(reg, { industry: { label: 'Sector (low)' } }, 'com.acme.low', 210); + const high = () => extend(reg, { industry: { label: 'Sector (high)' } }, 'com.acme.high', 250); + if (order === 'low-first') { low(); high(); } else { high(); low(); } + expect(industryOf(reg.getObject(OBJ)).label).toBe('Sector (high)'); + } + }); + + it('applies after the additive merges: a patch reaches a field another extender adds at a lower priority', () => { + const reg = withVendor(); + reg.registerObject( + { name: OBJ, fields: { region: { name: 'region', type: 'text', label: 'Region' } } } as any, + 'com.acme.region', undefined, 'extend', 150, + ); + extend(reg, { region: { label: 'Territory' } }); + expect(reg.getObject(OBJ)?.fields?.region?.label).toBe('Territory'); + }); + + it('a patch naming a missing field or option is not applied and is named once in the log — never a boot refusal', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const reg = withVendor(); + expect(() => extend(reg, { + no_such_field: { label: 'Ghost' }, + industry: { label: 'Sector', options: { no_such_value: { label: 'Ghost' } } }, + })).not.toThrow(); + + const served = reg.getObject(OBJ) as any; + expect(served.fields.no_such_field).toBeUndefined(); + expect(industryOf(served).label).toBe('Sector'); + expect(industryOf(served).options).toEqual(vendorAccount().fields.industry.options); + + reg.invalidateAll(); + reg.getObject(OBJ); + const lines = warn.mock.calls.map((c) => String(c[0])).filter((l) => l.includes('fieldPatches')); + expect(lines.filter((l) => l.includes('no_such_field'))).toHaveLength(1); + expect(lines.filter((l) => l.includes('no_such_value'))).toHaveLength(1); + expect(lines.every((l) => l.includes(EXT_PKG))).toBe(true); + }); + + it('an extension without a patch folds exactly as before', () => { + const reg = withVendor(); + reg.registerObject( + { name: OBJ, label: 'Customer', fields: { tier: { name: 'tier', type: 'text', label: 'Tier' } } } as any, + EXT_PKG, undefined, 'extend', 200, + ); + const served = reg.getObject(OBJ) as any; + expect(served.label).toBe('Customer'); + expect(industryOf(served)).toEqual(vendorAccount().fields.industry); + }); +}); + +describe('ADR-0146 D10 — the manifest loop carries fieldPatches onto the extension def', () => { + it('a package\'s objectExtensions entry patches the target through ObjectQL.registerApp', () => { + const ql = new ObjectQL(); + (ql.registry as SchemaRegistry).logLevel = 'silent'; + ql.registerApp({ id: OWNER_PKG, name: 'CRM', version: '1.0.0', objects: [vendorAccount()] }); + ql.registerApp({ + id: EXT_PKG, + name: 'CRM display', + version: '1.0.0', + objectExtensions: [{ extend: OBJ, fieldPatches: PATCH, priority: 220 }], + }); + + const served = industryOf(ql.registry.getObject(OBJ)); + expect(served.label).toBe('Sector'); + expect(served.options[0]).toEqual({ value: 'tech', label: 'Technology' }); + }); +}); From e566e4cf5d5a1cce65a0a5ca3f499c84892aea76 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:33:56 +0000 Subject: [PATCH 02/11] feat(objectql): carry an object extension's fieldPatches and apply it in mergeObjectDefinitions ADR-0146 D10 second rule: whitelisted display keys and option labels by stored value, after the additive merges; ADR-0029 D9.2a yield; priority order; a patch naming a missing target is skipped and named once at the fold. Claude-Session: https://claude.ai/code/session_01FEkQ5nSMgkteiemkoN31vf Co-authored-by: Claude --- packages/objectql/src/engine.ts | 5 + packages/objectql/src/registry.ts | 247 +++++++++++++++++++++++++++++- 2 files changed, 247 insertions(+), 5 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 86e1517badd..81154459dc0 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -8986,6 +8986,11 @@ export class ObjectQL implements IObjectQLEngine { const extDef = { name: targetFqn, // Use the target FQN as name fields: ext.fields, + // [ADR-0146 D10, second rule] The per-field display patches, + // the slot as `ObjectExtensionSchema.fieldPatches` declares + // it — applied by the registry's `mergeObjectDefinitions`, + // whose fold every object read shares. + fieldPatches: ext.fieldPatches, label: ext.label, pluralLabel: ext.pluralLabel, description: ext.description, diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 8d59bfc5fed..bb473270b1f 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveInjectedSystemColumns, resolveInjectedColumnProvenance as injectedColumnProvenance, isInjectedColumnDefinition, isTenancyDisabled, checkManagedApiMethodAffordances, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS, materializeTreeSelfReferences } from '@objectstack/spec/data'; +import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveInjectedSystemColumns, resolveInjectedColumnProvenance as injectedColumnProvenance, isInjectedColumnDefinition, isTenancyDisabled, checkManagedApiMethodAffordances, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS, materializeTreeSelfReferences, BASE_FIELD_DISPLAY_WHITELIST, type ObjectExtensionParsed } from '@objectstack/spec/data'; // [#4513] The audit-family governance table, and [#6562] the injected-column // DEFINITION tables it governs — see the re-exports below for why both live in a // package `objectql` and `metadata-protocol` both depend on. @@ -37,6 +37,10 @@ import { OBJECT_FIELD_TYPE_REFUSED_ERROR_NAME, type ObjectFieldTypeRefusal, type ObjectFieldTypeViolation, + // [ADR-0146 D10, second rule] The field-patch target check — the install + // door's and `os validate`'s judge, asked here so the fold names a patch it + // cannot apply by the same verdict those doors refuse it by. + fieldPatchTargetMisses, } from '@objectstack/metadata-core'; // [ADR-0029 D9.2a] `scalarOverridesPackagedBase` is the #8284 comparison, imported rather // than re-spelled: the object FOLD asks the same question one layer down (has @@ -495,6 +499,129 @@ const OBJECT_FOLD_SCALAR_KEYS = ['label', 'pluralLabel', 'description'] as const type ObjectFoldScalarKey = (typeof OBJECT_FOLD_SCALAR_KEYS)[number]; +/** + * [ADR-0146 D10, second rule] An object extension's per-field display patches: + * the slot `ObjectExtensionSchema.fieldPatches` declares, carried onto the + * extension def by the manifest loop (`engine.ts`) and applied by + * {@link mergeObjectDefinitions}. ⛔ No second shape: the type IS the spec's, + * and the keys applied are read from `BASE_FIELD_DISPLAY_WHITELIST`, so a + * widened whitelist widens what the merge applies too. + */ +type ObjectFieldPatches = NonNullable; + +/** An `extend` contributor's body: a partial object that may carry {@link ObjectFieldPatches}. */ +type ObjectExtensionBody = Partial & { fieldPatches?: ObjectFieldPatches }; + +const isPlainRecord = (v: unknown): v is Record => + v !== null && typeof v === 'object' && !Array.isArray(v); + +/** + * The address of one patched display key in a fold's D9.2a yield set + * ({@link SchemaRegistry.tenantPatchedDisplayKeys}): a field key, or one key of + * the option addressed by its stored value. + */ +function fieldPatchYieldKey(field: string, key: string, option?: string): string { + return option === undefined ? `${field}\u0000${key}` : `${field}\u0000options\u0000${option}\u0000${key}`; +} + +/** A field of an object body's name-keyed `fields`, when it is a record. */ +function recordFieldOf(body: unknown, name: string): Record | undefined { + const fields = isPlainRecord(body) ? body.fields : undefined; + if (!isPlainRecord(fields) || !Object.prototype.hasOwnProperty.call(fields, name)) return undefined; + const field = fields[name]; + return isPlainRecord(field) ? field : undefined; +} + +/** The option of a field's own `options` whose stored `value` is `value`. */ +function optionByValue(field: Record, value: string): Record | undefined { + const options = field.options; + if (!Array.isArray(options)) return undefined; + const found = options.find((o) => isPlainRecord(o) && o.value === value); + return isPlainRecord(found) ? found : undefined; +} + +/** + * [ADR-0029 D9.2a] The scalar yield's comparison, asked of one display key of a + * field or of one of its options. It IS {@link scalarOverridesPackagedBase} — + * the one comparison behind every D9.2a yield, keyed by any attribute (its own + * docblock: the exported key union names the keys its callers ask about, and + * the implementation underneath reads any) — ⛔ never a second copy of it. + */ +function displayKeyOverridesPackagedBase(packaged: unknown, key: string, value: unknown): boolean { + return scalarOverridesPackagedBase(packaged, key as Parameters[1], value); +} + +/** + * [ADR-0146 D10, second rule] One base field with an extension's patch applied: + * the whitelisted field keys, then each option the patch addresses by its + * stored `value`. A key the tenant authored on the fold's base yields + * (`tenantPatched`, ADR-0029 D9.2a). Returns `field` BY REFERENCE when nothing + * applies. + * + * A field bound to a picklist is left alone under `options`: the served options + * are the picklist's, written over the field after the fold + * ({@link SchemaRegistry.finishServedObject}), so an option label written here + * would never be served. The target check names that patch instead. + */ +function patchedField( + name: string, + field: Record, + patch: ObjectFieldPatches[string], + tenantPatched: ReadonlySet | undefined, +): Record { + let next: Record | undefined; + const patchKeys = patch as Record; + for (const key of BASE_FIELD_DISPLAY_WHITELIST.field) { + const value = patchKeys[key]; + if (value === undefined || tenantPatched?.has(fieldPatchYieldKey(name, key))) continue; + (next ??= { ...field })[key] = value; + } + const optionPatches = patch.options; + const options = field.options; + const picklistBound = typeof field.picklist === 'string' && field.picklist !== ''; + if (optionPatches && Array.isArray(options) && !picklistBound) { + let patchedOptions: unknown[] | undefined; + options.forEach((option, index) => { + if (!isPlainRecord(option) || typeof option.value !== 'string') return; + if (!Object.prototype.hasOwnProperty.call(optionPatches, option.value)) return; + const optionPatch = optionPatches[option.value] as Record | undefined; + if (!optionPatch) return; + let patchedOption: Record | undefined; + for (const key of BASE_FIELD_DISPLAY_WHITELIST.option) { + const value = optionPatch[key]; + if (value === undefined || tenantPatched?.has(fieldPatchYieldKey(name, key, option.value))) continue; + (patchedOption ??= { ...option })[key] = value; + } + if (patchedOption) (patchedOptions ??= [...options])[index] = patchedOption; + }); + if (patchedOptions) (next ??= { ...field }).options = patchedOptions; + } + return next ?? field; +} + +/** + * [ADR-0146 D10, second rule] `fields` with every patch of `patches` applied + * that names a field `fields` has. A patch naming a field the object lacks is + * skipped here, and named by the fold ({@link SchemaRegistry.reportFieldPatchMisses}). + * Returns `fields` BY REFERENCE when nothing applies. + */ +function applyFieldPatches( + fields: ServiceObject['fields'], + patches: ObjectFieldPatches, + tenantPatched: ReadonlySet | undefined, +): ServiceObject['fields'] { + if (!isPlainRecord(fields)) return fields; + let out: Record | undefined; + for (const [name, patch] of Object.entries(patches)) { + if (!patch) continue; + const field = recordFieldOf({ fields: out ?? fields }, name); + if (!field) continue; + const next = patchedField(name, field, patch, tenantPatched); + if (next !== field) (out ??= { ...fields })[name] = next; + } + return (out ?? fields) as ServiceObject['fields']; +} + /** * [#28356] Which layer an object fold serves: `'served'`, what every read * answers (the bound actions the registry serves, runtime-authored included), @@ -516,6 +643,12 @@ type ServedObjectLayer = 'served' | 'code'; * - The three {@link OBJECT_FOLD_SCALAR_KEYS} (`label`, `pluralLabel`, * `description`) are overridden last-writer-wins, subject to the * `tenantAuthored` yield rule below. + * - `fieldPatches` [ADR-0146 D10, second rule] is APPLIED, never carried: + * after the merges above, each patch writes its `BASE_FIELD_DISPLAY_WHITELIST` + * keys onto the named field and its option labels onto the options whose + * stored `value` it names, each key yielding where `tenantPatched` says the + * tenant authored it — the same D9.2a rule as the scalars. A patch naming a + * field the merged object lacks is skipped here; the fold names it. * * **Every other top-level prop on `extension` is silently discarded.** * `merged` starts as `{ ...base }` and nothing outside the list above is ever @@ -527,8 +660,8 @@ type ServedObjectLayer = 'served' | 'code'; * * Declarative authors never reach that silence, though: `ObjectExtensionSchema` * (`packages/spec/src/data/object.zod.ts`) is `.strict()` — its shape is - * exactly `extend` / `fields` / `label` / `pluralLabel` / `description` / - * `validations` / `indexes` / `priority`, so an `objectExtensions` entry + * exactly `extend` / `fields` / `fieldPatches` / `label` / `pluralLabel` / + * `description` / `validations` / `indexes` / `priority`, so an `objectExtensions` entry * naming `tenancy`, `permissions`, or any other key outside that list fails * LOUDLY at authoring time with a prescription (#4001; the same rule is * documented at `content/docs/data-modeling/object-extensions.mdx`). This @@ -558,9 +691,10 @@ type ServedObjectLayer = 'served' | 'code'; */ function mergeObjectDefinitions( base: ServiceObject, - extension: Partial, + extension: ObjectExtensionBody, tenantAuthored?: ReadonlySet, tenantRules?: ReadonlyMap, + tenantPatched?: ReadonlySet, ): ServiceObject { const merged = { ...base }; @@ -594,6 +728,16 @@ function mergeObjectDefinitions( merged.description = extension.description; } + // [ADR-0146 D10, second rule] The extension's display patches of the base's + // fields — AFTER the additive merges, so a patch reaches a field a + // lower-priority extender added, and with each key yielding where the tenant + // authored it (`tenantPatched`, ADR-0029 D9.2a), as the scalars above yield. + // Two extenders patching one key resolve as the scalars do: the fold applies + // contributors in ascending `priority`, so the higher one is written last. + if (extension.fieldPatches) { + merged.fields = applyFieldPatches(merged.fields, extension.fieldPatches, tenantPatched); + } + return merged; } @@ -4160,6 +4304,10 @@ export class SchemaRegistry { // tenant's `message` and `active`. Computed once, over the base, as the // scalars above are. const tenantRules = this.tenantRuleSettings(contributors, baseDefinition); + // [ADR-0146 D10, second rule; ADR-0029 D9.2a] The display keys the tenant + // authored on the fields the extenders patch — computed once, over the + // base, for the scalars' reason above. + const tenantPatched = this.tenantPatchedDisplayKeys(contributors, baseDefinition); let merged = tenantRules ? this.withPackagedOwnerRules(contributors, baseDefinition, tenantRules) : { ...baseDefinition }; @@ -4167,7 +4315,8 @@ export class SchemaRegistry { if (contrib.ownership === 'extend') { let extension = governAudit ? applyAuditFieldGovernance(contrib.definition) : contrib.definition; if (governQueue) extension = this.withoutExtensionQueueField(extension, contrib.packageId); - merged = mergeObjectDefinitions(merged, extension, tenantAuthored, tenantRules); + merged = mergeObjectDefinitions(merged, extension, tenantAuthored, tenantRules, tenantPatched); + this.reportFieldPatchMisses(merged, extension, contrib.packageId); } } if (tenantRules) this.reportChangedRuleCopies(contributors, baseDefinition); @@ -4177,6 +4326,45 @@ export class SchemaRegistry { return this.finishServedObject(merged, layer); } + /** + * [ADR-0146 D10, second rule] Field-patch targets already named, keyed + * `object|package|field|option`. Per registry, for the reason + * {@link shadowedExtensionQueueWarned} gives. + */ + private fieldPatchMissesWarned = new Set(); + + /** + * [ADR-0146 D10, second rule] Name, once, each patch of `extension` the fold + * could not apply: a field the object does not have, an option value its + * field does not have, or an option of a picklist-bound field. The verdict + * is the install door's and `os validate`'s own judge + * (`fieldPatchTargetMisses`), so the boot names exactly what those doors + * refuse. + * + * ⛔ Never a refusal, and worded as the state it observes: the fold runs on + * every read while a boot is still registering packages, so a field another + * extender adds later makes the patch apply on the next read, with nothing + * recorded to undo (AGENTS.md *Startup registry reads*). At `warn`: the + * object is visibly unpatched, and nothing durable is lost. + */ + private reportFieldPatchMisses( + target: ServiceObject, + extension: ObjectExtensionBody, + packageId: string | undefined, + ): void { + if (!extension.fieldPatches) return; + const object = String(target.name ?? extension.name ?? ''); + for (const miss of fieldPatchTargetMisses(object, extension.fieldPatches, target)) { + const key = `${object}|${packageId ?? ''}|${miss.field}|${miss.option ?? ''}`; + if (this.fieldPatchMissesWarned.has(key)) continue; + this.fieldPatchMissesWarned.add(key); + console.warn( + `[Registry] An extension of object "${object}"${packageId ? ` from package "${packageId}"` : ''} ` + + `carries a field patch that is not applied while the object has no such target: ${miss.message}`, + ); + } + } + /** * Extension `queue` declarations already named, keyed `object|package`. Per * registry, not module-level, for the reason {@link navGroupDiagnostics} @@ -4483,6 +4671,55 @@ export class SchemaRegistry { return authored; } + /** + * [ADR-0146 D10, second rule; ADR-0029 D9.2a] The display keys a fold's base + * has authored away from the packaged owner's value, on the fields and + * options the `extend` contributors patch — each a {@link fieldPatchYieldKey}. + * A patch yields on those keys, as an extender's scalar yields on + * {@link tenantAuthoredScalars}, and by the same comparison + * ({@link displayKeyOverridesPackagedBase}). + * + * The same two "no opinion" edges as {@link tenantAuthoredScalars}, for its + * reasons: no `own` contributor, or a base that IS the owner's body (by + * reference). Idempotent over a folded base for its reason too: a base that + * already carries a patch's text reads as authored, so the patch yields and + * the value stays what the first fold produced. + */ + private tenantPatchedDisplayKeys( + contributors: ObjectContributor[], + baseDefinition: ServiceObject, + ): ReadonlySet | undefined { + const owner = contributors.find((c) => c.ownership === 'own')?.definition; + if (!owner || owner === baseDefinition) return undefined; + let authored: Set | undefined; + for (const contrib of contributors) { + if (contrib.ownership !== 'extend') continue; + const patches = (contrib.definition as ObjectExtensionBody).fieldPatches; + if (!patches) continue; + for (const [name, patch] of Object.entries(patches)) { + const packagedField = recordFieldOf(owner, name); + const baseField = recordFieldOf(baseDefinition, name); + if (!patch || !packagedField || !baseField) continue; + for (const key of BASE_FIELD_DISPLAY_WHITELIST.field) { + if (displayKeyOverridesPackagedBase(packagedField, key, baseField[key])) { + (authored ??= new Set()).add(fieldPatchYieldKey(name, key)); + } + } + for (const value of Object.keys(patch.options ?? {})) { + const packagedOption = optionByValue(packagedField, value); + const baseOption = optionByValue(baseField, value); + if (!packagedOption || !baseOption) continue; + for (const key of BASE_FIELD_DISPLAY_WHITELIST.option) { + if (displayKeyOverridesPackagedBase(packagedOption, key, baseOption[key])) { + (authored ??= new Set()).add(fieldPatchYieldKey(name, key, value)); + } + } + } + } + } + return authored; + } + /** * [ADR-0029 D9.2a, 2026-10-30 note] The `message` / `active` a TENANT base * sets on its rules, by name — `undefined` when the fold's base is a code From 5e34f3e7c8d98e1405b947ab6c991bf32a2cfe8d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:35:15 +0000 Subject: [PATCH 03/11] test(metadata-core): the field-patch target judge Claude-Session: https://claude.ai/code/session_01FEkQ5nSMgkteiemkoN31vf Co-authored-by: Claude --- .../src/extension-field-patch-targets.test.ts | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 packages/metadata-core/src/extension-field-patch-targets.test.ts diff --git a/packages/metadata-core/src/extension-field-patch-targets.test.ts b/packages/metadata-core/src/extension-field-patch-targets.test.ts new file mode 100644 index 00000000000..5600c56cc09 --- /dev/null +++ b/packages/metadata-core/src/extension-field-patch-targets.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0146 D10, second rule] The field-patch target check: the one judge the + * marketplace install door, `os validate` and the registry fold's boot log ask. + */ + +import { describe, it, expect } from 'vitest'; +import { + fieldPatchTargetMisses, + fieldPatchTargetRefusals, + fieldPatchTargetRefusalMessage, +} from './extension-field-patch-targets.js'; + +const ACCOUNT = { + name: 'crm_account', + fields: { + industry: { + type: 'select', + label: 'Industry', + options: [{ value: 'tech', label: 'Tech' }, { value: 'retail', label: 'Retail' }], + }, + segment: { type: 'select', label: 'Segment', picklist: 'crm_segments' }, + notes: { type: 'textarea', label: 'Notes' }, + }, +}; + +describe('fieldPatchTargetMisses', () => { + it('finds nothing to say about a patch whose fields and option values the target has', () => { + expect(fieldPatchTargetMisses('crm_account', { + industry: { label: 'Sector', options: { tech: { label: 'Technology' } } }, + notes: { placeholder: 'Anything else' }, + }, ACCOUNT)).toEqual([]); + }); + + it('names a field the target does not have', () => { + const [miss, ...rest] = fieldPatchTargetMisses('crm_account', { sector: { label: 'Sector' } }, ACCOUNT); + expect(rest).toEqual([]); + expect(miss).toMatchObject({ object: 'crm_account', field: 'sector', reason: 'field' }); + expect(miss!.option).toBeUndefined(); + }); + + it('names an option value the field does not have, and lists the values it does', () => { + const misses = fieldPatchTargetMisses('crm_account', { industry: { options: { finance: { label: 'Finance' } } } }, ACCOUNT); + expect(misses).toMatchObject([{ field: 'industry', option: 'finance', reason: 'option' }]); + expect(misses[0]!.message).toContain('`tech`, `retail`'); + }); + + it('names an option patch on a field without options, and on a picklist-bound field', () => { + expect(fieldPatchTargetMisses('crm_account', { notes: { options: { a: { label: 'A' } } } }, ACCOUNT)) + .toMatchObject([{ field: 'notes', option: 'a', reason: 'option' }]); + expect(fieldPatchTargetMisses('crm_account', { segment: { options: { smb: { label: 'SMB' } } } }, ACCOUNT)) + .toMatchObject([{ field: 'segment', option: 'smb', reason: 'picklist-option' }]); + }); + + it('reads a target whose fields are a name-bearing array', () => { + const arrayTarget = { name: 'crm_account', fields: [{ name: 'industry', type: 'text', label: 'Industry' }] }; + expect(fieldPatchTargetMisses('crm_account', { industry: { label: 'Sector' } }, arrayTarget)).toEqual([]); + expect(fieldPatchTargetMisses('crm_account', { sector: { label: 'Sector' } }, arrayTarget)).toHaveLength(1); + }); + + it('does not read an inherited name as a field', () => { + expect(fieldPatchTargetMisses('crm_account', { toString: { label: 'X' } }, ACCOUNT)).toHaveLength(1); + }); + + it('judges nothing when the patch is absent or malformed', () => { + expect(fieldPatchTargetMisses('crm_account', undefined, ACCOUNT)).toEqual([]); + expect(fieldPatchTargetMisses('crm_account', ['industry'], ACCOUNT)).toEqual([]); + }); +}); + +describe('fieldPatchTargetRefusals', () => { + const extensions = [ + { extend: 'crm_account', fieldPatches: { industry: { label: 'Sector' } } }, + { extend: 'crm_account', fieldPatches: { sector: { label: 'X' }, industry: { options: { finance: { label: 'F' } } } } }, + { extend: 'crm_lead', fieldPatches: { anything: { label: 'Y' } } }, + { extend: 'crm_account', fields: { tier: { type: 'text' } } }, + ]; + const getTarget = (name: string) => (name === 'crm_account' ? ACCOUNT : undefined); + + it('locates each miss under its entry, and skips a target the reader does not know', () => { + expect(fieldPatchTargetRefusals(extensions, getTarget, 'packages[1].manifest.').map((r) => r.path)).toEqual([ + 'packages[1].manifest.objectExtensions[1].fieldPatches.sector', + 'packages[1].manifest.objectExtensions[1].fieldPatches.industry.options.finance', + ]); + }); + + it('answers [] for a manifest with no objectExtensions', () => { + expect(fieldPatchTargetRefusals(undefined, getTarget)).toEqual([]); + }); + + it('the install sentence names the package and locates the first refusal', () => { + const refusals = fieldPatchTargetRefusals(extensions, getTarget); + const message = fieldPatchTargetRefusalMessage('com.acme.display', refusals); + expect(message).toContain('com.acme.display'); + expect(message).toContain('objectExtensions[1].fieldPatches.sector'); + }); +}); From 2aa9af9ba22f21e699db75e8dcfee04f6ea0f32e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:35:54 +0000 Subject: [PATCH 04/11] =?UTF-8?q?feat(spec):=20fieldPatches=20is=20live=20?= =?UTF-8?q?=E2=80=94=20the=20EXPERIMENTAL=20marker=20flips,=20citing=20mer?= =?UTF-8?q?geObjectDefinitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01FEkQ5nSMgkteiemkoN31vf Co-authored-by: Claude --- .../spec/src/data/field-customization.test.ts | 6 ++-- packages/spec/src/data/object.zod.ts | 36 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/spec/src/data/field-customization.test.ts b/packages/spec/src/data/field-customization.test.ts index d48b220112d..b95ab055042 100644 --- a/packages/spec/src/data/field-customization.test.ts +++ b/packages/spec/src/data/field-customization.test.ts @@ -212,9 +212,9 @@ describe('`ObjectExtensionSchema.fieldPatches` — the per-field display patch s expect('fieldPatches' in parsed).toBe(false); }); - it('reads `[EXPERIMENTAL — not enforced]` and names the merge card until the merge applies it', () => { + it('is live: no `[EXPERIMENTAL — not enforced]` marker, and it names the merge that applies it', () => { const description = shapeOf(ObjectExtensionSchema).fieldPatches!.description ?? ''; - expect(description.startsWith('[EXPERIMENTAL — not enforced]')).toBe(true); - expect(description).toContain('N-23d'); + expect(description).not.toContain('EXPERIMENTAL'); + expect(description).toContain('mergeObjectDefinitions'); }); }); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index f0999e8f4b1..10b52d10a3c 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -4877,8 +4877,8 @@ function refuseExtensionFieldPatchKeys(fieldPatches: unknown, ctx: z.RefinementC * objectExtensions: [{ * extend: 'contact', // target object FQN * fields: { sales_stage: Field.select([...]) }, - * // A base field's display properties only (ADR-0146 D10). Not applied - * // until the registry merge carries the slot — see `fieldPatches`. + * // A base field's display properties only (ADR-0146 D10), applied by the + * // registry merge (`mergeObjectDefinitions`) — see `fieldPatches`. * fieldPatches: { industry: { label: 'Sector', options: { tech: { label: 'Technology' } } } }, * }] * ``` @@ -4900,12 +4900,9 @@ export const ObjectExtensionSchema = lazySchema(() => strictObject({ }, guidance: { // The merge in `objectql/src/engine.ts` copies EXACTLY the declared keys - // (extend / fields / label / pluralLabel / description / validations / - // indexes / priority) onto the extension def. Anything else is not - // "unsupported yet" — there is no slot for it to arrive through. - // `fieldPatches` is the one declared key that copy does not carry yet: - // it is parsed and marked not enforced until the merge card (N-23d, - // #28565) carries it through the engine into `mergeObjectDefinitions`. + // (extend / fields / fieldPatches / label / pluralLabel / description / + // validations / indexes / priority) onto the extension def. Anything else + // is not "unsupported yet" — there is no slot for it to arrive through. actions: '`actions` cannot be contributed through an object extension — the merge carries ' + '`fields`, `label`, `pluralLabel`, `description`, `validations` and `indexes` only. ' + @@ -4953,18 +4950,21 @@ export const ObjectExtensionSchema = lazySchema(() => strictObject({ * fields. Keys are DERIVED from `BASE_FIELD_DISPLAY_WHITELIST`; structural * keys are refused by name with the D6 prescription; an `__c` key is refused * by the root refinement below. Pure shape: whether the target has the field - * or the option, and who may patch, are the server's (N-23d, #28565), which - * also carries the slot through `engine.ts`'s extension copy — dropped there - * until then — and flips the marker below. + * or the option is the server's — `@objectstack/metadata-core`'s + * `fieldPatchTargetMisses`, refused at the marketplace install door and at + * `os validate`, named in the boot log — and the merge that applies the patch + * is `@objectstack/objectql`'s `mergeObjectDefinitions`. */ fieldPatches: z.record(z.string(), FieldDisplayPatchSchema).optional().describe( - '[EXPERIMENTAL — not enforced] Display patches of the target object\'s own fields, keyed by field ' + - 'name: change a vendor field\'s display properties (BASE_FIELD_DISPLAY_WHITELIST: label, description, ' + - 'help text, placeholder, option labels) without taking the field over, so its type, values and rules ' + - 'stay the vendor\'s and its upgrades still apply (ADR-0146 D10). Nothing applies a patch yet: the ' + - 'registry merge that does is rollout card N-23d, and until it lands the engine drops this slot at ' + - 'registration. Example: `fieldPatches: { industry: { label: \'Sector\', inlineHelpText: \'The market ' + - 'the account sells into\', options: { tech: { label: \'Technology\' } } } }`.', + 'Display patches of the target object\'s own fields, keyed by field name: change a vendor field\'s ' + + 'display properties (BASE_FIELD_DISPLAY_WHITELIST: label, description, help text, placeholder, option ' + + 'labels) without taking the field over, so its type, values and rules stay the vendor\'s and its ' + + 'upgrades still apply (ADR-0146 D10). Applied by the registry merge (`mergeObjectDefinitions`) after ' + + 'the additive merges, on every object read: a tenant\'s own text keeps winning, and between two ' + + 'extensions the higher `priority` wins. A patch naming a field or option value the target does not ' + + 'have is refused at install and by `os validate`. Example: `fieldPatches: { industry: { label: ' + + '\'Sector\', inlineHelpText: \'The market the account sells into\', options: { tech: { label: ' + + '\'Technology\' } } } }`.', ), /** Override label */ From 26ce8d6ffdb1bf9a1fd34b0fb9964921b6c3e718 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:40:25 +0000 Subject: [PATCH 05/11] feat(cloud-connection): install-local refuses a field patch naming a field or option its target lacks (step 2e) Claude-Session: https://claude.ai/code/session_01FEkQ5nSMgkteiemkoN31vf Co-authored-by: Claude --- ...-install-local-field-patch-targets.test.ts | 169 ++++++++++++++++++ .../src/marketplace-install-local-plugin.ts | 56 ++++++ 2 files changed, 225 insertions(+) create mode 100644 packages/cloud-connection/src/marketplace-install-local-field-patch-targets.test.ts diff --git a/packages/cloud-connection/src/marketplace-install-local-field-patch-targets.test.ts b/packages/cloud-connection/src/marketplace-install-local-field-patch-targets.test.ts new file mode 100644 index 00000000000..fa20a1253cd --- /dev/null +++ b/packages/cloud-connection/src/marketplace-install-local-field-patch-targets.test.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0146 D10, second rule] `POST /api/v1/marketplace/install-local` step + * 2e: a package whose `objectExtensions[].fieldPatches` names a field its + * target does not have, or an option value the field does not have, is refused + * `400 VALIDATION_ERROR` before anything is registered, written or synced. + * + * The target is the registry's resolved object (ADR-0141 D2 admitted the base + * first), or one the same artifact delivers. A patch that matches installs. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { BaseResponseSchema, ApiErrorSchema, envelopeViolations } from '@objectstack/spec/api'; +// An install binds the package's handlers through `@objectstack/runtime` (a +// lazy `import()` inside the plugin); its first load is paid here, at module +// top, never inside a clocked `it`. +import '@objectstack/runtime'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; + +type Handler = (c: any) => Promise; + +const ROUTE = '/api/v1/marketplace/install-local'; +const BASE = 'com.example.crm'; + +/** The installed vendor object, as the registry serves it. */ +const ACCOUNT = { + name: 'crm_account', + fields: { + industry: { + type: 'select', label: 'Industry', + options: [{ value: 'tech', label: 'Tech' }, { value: 'retail', label: 'Retail' }], + }, + }, +}; + +const extensionPackage = (fieldPatches: Record) => ({ + id: 'com.example.crm-wording', name: 'CRM wording', version: '1.0.0', scope: 'project', type: 'module', + dependencies: { [BASE]: '1.x' }, + objectExtensions: [{ extend: 'crm_account', fieldPatches }], +}); + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + }; +} + +function makeC(body: any) { + const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + return { + req: { + url: `http://localhost:3000${ROUTE}`, + raw: new Request(`http://localhost:3000${ROUTE}`), + json: async () => body, + param: () => undefined, + }, + json, + }; +} + +async function mount(dir: string) { + const rawApp = makeRawApp(); + const hooks = new Map(); + const register = vi.fn(); + const syncSchemas = vi.fn(async () => undefined); + const registry = { + getAllPackages: () => [], + getPackage: (id: string) => (id === BASE ? { manifest: { id, version: '1.2.0' } } : undefined), + getObject: (name: string) => (name === ACCOUNT.name ? structuredClone(ACCOUNT) : undefined), + }; + const services: Record = { + manifest: { register }, + auth: installerAuthService(), + objectql: withInstallerGrants({ syncSchemas, registry }), + }; + const ctx = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no ${name}`); + return svc; + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + const plugin = new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir: dir }); + await plugin.start(ctx as any); + await hooks.get('kernel:ready')?.(); + const registeredAtBoot = register.mock.calls.length; + return { + install: (body: unknown) => rawApp.routes.get(`POST ${ROUTE}`)!(makeC(body)), + installRegistrations: () => register.mock.calls.slice(registeredAtBoot), + syncs: () => syncSchemas.mock.calls.length, + }; +} + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-field-patch-')); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); }); + +function expectRefusal(res: { payload: any; status: number }, paths: string[]) { + expect(res.status, JSON.stringify(res.payload)).toBe(400); + expect(BaseResponseSchema.safeParse(res.payload).success).toBe(true); + expect(envelopeViolations(res.payload)).toEqual([]); + expect(ApiErrorSchema.safeParse(res.payload.error).success).toBe(true); + expect(res.payload.error.code).toBe('VALIDATION_ERROR'); + expect(res.payload.error.details.refusals.map((r: any) => r.path)).toEqual(paths); +} + +describe('install-local 2e — the field-patch target check (ADR-0146 D10)', () => { + it('refuses a patch naming a field the installed target does not have, and installs nothing', async () => { + const h = await mount(dir); + const res = await h.install({ manifest: extensionPackage({ sector: { label: 'Sector' } }) }); + expectRefusal(res, ['objectExtensions[0].fieldPatches.sector']); + expect(h.installRegistrations()).toEqual([]); + expect(h.syncs()).toBe(0); + expect(readdirSync(dir)).toEqual([]); + }); + + it('refuses a patch naming an option value the field does not have', async () => { + const h = await mount(dir); + const res = await h.install({ manifest: extensionPackage({ industry: { options: { finance: { label: 'Finance' } } } }) }); + expectRefusal(res, ['objectExtensions[0].fieldPatches.industry.options.finance']); + expect(res.payload.error.details.refusals[0]).toMatchObject({ object: 'crm_account', field: 'industry', option: 'finance', reason: 'option' }); + expect(h.installRegistrations()).toEqual([]); + }); + + it('judges a compiled artifact as `os package install ` sends it (the nested manifest flattened)', async () => { + const h = await mount(dir); + const pkg = extensionPackage({ sector: { label: 'Sector' } }); + const { objectExtensions, ...manifest } = pkg; + const res = await h.install({ manifest: { manifest, objectExtensions, id: manifest.id, version: manifest.version } }); + expectRefusal(res, ['objectExtensions[0].fieldPatches.sector']); + }); + + it('judges a target the same artifact delivers, under its packages[] entry', async () => { + const h = await mount(dir); + const res = await h.install({ + manifest: { + id: 'com.example.multi', name: 'Multi', version: '1.0.0', scope: 'project', type: 'app', + packages: [ + { manifest: { id: 'com.example.multi.core', name: 'Core', version: '1.0.0', type: 'app', + objects: [{ name: 'multi_ticket', fields: { status: { type: 'text', label: 'Status' } } }] } }, + { manifest: { id: 'com.example.multi.wording', name: 'Wording', version: '1.0.0', type: 'module', + objectExtensions: [{ extend: 'multi_ticket', fieldPatches: { state: { label: 'State' } } }] } }, + ], + }, + }); + expectRefusal(res, ['packages[1].manifest.objectExtensions[0].fieldPatches.state']); + }); + + it('lit control: a patch whose field and option the target has installs', async () => { + const h = await mount(dir); + const res = await h.install({ + manifest: extensionPackage({ industry: { label: 'Sector', options: { tech: { label: 'Technology' } } } }), + }); + expect(res.status, JSON.stringify(res.payload)).toBe(200); + expect(h.installRegistrations()).toHaveLength(1); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 934c5ee20e2..70d25cb5fe9 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -207,6 +207,11 @@ import { // [ADR-0131 D3 · D12 items 7–8] The walled refusal's one predicate: the // same sentence, the same rung, as every environment-metadata write door. walledCatalogWriteVerdict, + // [ADR-0146 D10, second rule] The field-patch target check — the judge + // `os validate` asks and the registry fold names its misses by. + fieldPatchTargetRefusals, + fieldPatchTargetRefusalMessage, + type FieldPatchTargetRefusal, } from '@objectstack/metadata-core'; import { resolveCloudUrl } from './cloud-url.js'; import { resolveMarketplacePublicBaseUrl } from './marketplace-public-url.js'; @@ -2064,6 +2069,26 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }, 403); } + // 2e. [ADR-0146 D10, second rule] ⭐ THE FIELD-PATCH TARGET CHECK, + // beside the override gate and before any write: a patch naming a + // field its target does not have, or an option value its field does + // not have, would change nothing and say nothing. Refused `400 + // VALIDATION_ERROR` on both branches, every refusal located in + // `details.refusals`. The target is the registry's resolved object + // (ADR-0141 D2 admitted the base first) or one this artifact + // delivers. + const patchTargetRefusals = this.fieldPatchTargetRefusalsOf(ctx, converted); + if (patchTargetRefusals.length > 0) { + return c.json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: fieldPatchTargetRefusalMessage(manifestId, patchTargetRefusals), + details: { refusals: patchTargetRefusals }, + }, + }, 400); + } + // 3. Hot-register FIRST so a malformed inline manifest fails the // install loudly rather than persisting a broken record that // would also fail on every subsequent rehydrate. @@ -3232,6 +3257,37 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * Reads nothing it cannot: with no `objectql` registry or no protocol * listing, the gate has no closure to judge and answers empty. */ + /** + * [ADR-0146 D10, second rule] Step 2e of {@link handleInstall}: every + * `objectExtensions[].fieldPatches` entry the converted body carries — + * each `packages[]` entry's, or the body's own — judged against its target + * by `@objectstack/metadata-core`'s `fieldPatchTargetRefusals`. A target + * this artifact delivers is read off its body; any other is the registry's + * resolved object. A target neither knows is not judged here: the + * extension then registers against an owner that is not loaded, which the + * registry already reports. + */ + private fieldPatchTargetRefusalsOf = (ctx: PluginContext, converted: Record): FieldPatchTargetRefusal[] => { + const multi = Array.isArray(converted.packages); + const bodies: Array<{ body: Record; prefix: string }> = multi + ? converted.packages.flatMap((entry: unknown, index: number) => + isPlainObject(entry) && isPlainObject(entry.manifest) + ? [{ body: entry.manifest as Record, prefix: `packages[${index}].manifest.` }] + : []) + : [{ body: flattenPackageBody(converted), prefix: '' }]; + if (!bodies.some(({ body }) => Array.isArray(body.objectExtensions))) return []; + const delivered = new Map(); + for (const { body } of bodies) { + const objects = body.objects; + const list = Array.isArray(objects) ? objects : isPlainObject(objects) ? Object.entries(objects).map(([name, o]) => (isPlainObject(o) ? { name, ...o } : o)) : []; + for (const object of list) if (isPlainObject(object) && typeof object.name === 'string') delivered.set(object.name, object); + } + let registry: { getObject?(name: string): unknown } | undefined; + try { registry = ctx.getService('objectql')?.registry as typeof registry; } catch { /* no data engine — only delivered targets are known */ } + const getTarget = (name: string): unknown => delivered.get(name) ?? registry?.getObject?.(name); + return bodies.flatMap(({ body, prefix }) => fieldPatchTargetRefusals(body.objectExtensions, getTarget, prefix)); + }; + private evaluateOverrideWriteGate = async ( ctx: PluginContext, manifestId: string, From d108bd048f86c010b2d8c19ebe1159af2b6679f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:42:02 +0000 Subject: [PATCH 06/11] feat(cli): os validate refuses a field patch naming a field or option its in-stack target lacks; changeset Claude-Session: https://claude.ai/code/session_01FEkQ5nSMgkteiemkoN31vf Co-authored-by: Claude --- .changeset/28565-field-patch-applied.md | 30 +++++ packages/cli/src/commands/validate.ts | 32 ++++++ .../cli/src/utils/field-patch-targets.test.ts | 61 ++++++++++ packages/cli/src/utils/field-patch-targets.ts | 46 ++++++++ .../test/validate-field-patch-targets.test.ts | 104 ++++++++++++++++++ 5 files changed, 273 insertions(+) create mode 100644 .changeset/28565-field-patch-applied.md create mode 100644 packages/cli/src/utils/field-patch-targets.test.ts create mode 100644 packages/cli/src/utils/field-patch-targets.ts create mode 100644 packages/cli/test/validate-field-patch-targets.test.ts diff --git a/.changeset/28565-field-patch-applied.md b/.changeset/28565-field-patch-applied.md new file mode 100644 index 00000000000..ed7d5d1e605 --- /dev/null +++ b/.changeset/28565-field-patch-applied.md @@ -0,0 +1,30 @@ +--- +'@objectstack/objectql': minor +'@objectstack/metadata-core': minor +'@objectstack/cloud-connection': minor +'@objectstack/cli': minor +'@objectstack/spec': minor +--- + +feat(objectql): an object extension's `fieldPatches` is applied on every object read; a patch naming a field or option its target lacks is refused at install and by `os validate` + +Clause-②: no +Metadata-protocol: semantic + +ADR-0146 D10's second rule, live. The per-field display patch an object extension declares (`objectExtensions[].fieldPatches`, added unenforced in the previous release) now reaches the screen: + +```ts +objectExtensions: [{ + extend: 'crm_account', + fieldPatches: { + industry: { label: 'Sector', inlineHelpText: 'The market the account sells into', options: { tech: { label: 'Technology' } } }, + }, +}] +``` + +- **Applied, never carried.** The engine's extension registration carries the slot, and the registry merge (`mergeObjectDefinitions`) applies it after the additive merges: the `BASE_FIELD_DISPLAY_WHITELIST` keys (`label`, `description`, `inlineHelpText`, `placeholder`) onto the named field, and `label` onto each option addressed by its stored `value`. The field's type, requiredness, options and values stay the owner's. The list read, the by-name read and the layered view serve the same text, and uninstalling the extension serves the owner's text again. +- **Precedence.** A key the tenant has edited keeps the tenant's text (ADR-0029 D9.2a, as for an extension's object `label`). Two extensions patching one key: the higher `priority` wins. +- **Refused when it names nothing.** A patch naming a field the target does not have, an option value its field does not have, or an option of a picklist-bound field is refused at `POST /api/v1/marketplace/install-local` (`400 VALIDATION_ERROR`, every refusal located in `error.details.refusals`, nothing installed) and by `os validate` when the target is in the same stack. At boot such a patch is not applied and is named once in the log; the boot carries on. +- **New export.** `@objectstack/metadata-core` exports the one judge the three readers share: `fieldPatchTargetMisses`, `fieldPatchTargetRefusals`, `fieldPatchTargetRefusalMessage`. + +What changes for an existing package: a package that declared `fieldPatches` since the slot landed now sees them applied, and one whose patch names a missing field or option is now refused at install. An extension without `fieldPatches` folds exactly as before. diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 75bd3b08a0c..6bb65e45fb2 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -77,6 +77,9 @@ import { findViewContainerNameRefusals } from '../utils/view-container-names.js' // [ADR-0146 D2, #28410] The override write gate's author-time half: the publish // door's own judge, walked over the artifact's packages. import { findOverrideDeclarationRefusals } from '../utils/override-declarations.js'; +// [ADR-0146 D10, second rule] The field-patch target check's walk; the verdict +// is the marketplace install door's own judge. +import { findFieldPatchTargetRefusals } from '../utils/field-patch-targets.js'; // A field `picklist` that names no picklist the stack declares — refused, or // reported when the declaring package depends on packages outside the stack. // Walked the way the load path registers, like the refusal above. @@ -690,6 +693,35 @@ export default class Validate extends Command { this.exit(1); } + // 3a-iv. [ADR-0146 D10, second rule] The field-patch target check, at + // author time: an object extension's `fieldPatches` naming a field + // its target does not have, or an option value the field does not + // have, when the target is in this composed stack. The verdict is the + // install door's own judge (`fieldPatchTargetRefusals`), so a green + // validate is a patch the install door admits; see + // `utils/field-patch-targets.ts` for the walk. + const patchTargetRefusals = findFieldPatchTargetRefusals(result.data as Record); + if (patchTargetRefusals.length > 0) { + if (flags.json) { + await emitJson({ + valid: false, + errors: patchTargetRefusals, + warnings: warningsSoFar(), + conversions: conversionNotices, + duration: timer.elapsed(), + }); + this.exit(1); + } + const n = patchTargetRefusals.length; + console.log(''); + printError(`${n} field patch${n > 1 ? 'es name' : ' names'} a field or option the extended object does not have (ADR-0146 D10)`); + printBulletList( + patchTargetRefusals.map((r) => `${r.path} ${r.message}`), + { noun: 'field-patch refusal(s)', remedy: JSON_FULL_LIST_REMEDY }, + ); + this.exit(1); + } + // 3b. [#3366] Installable-provider preflight — the shift-left of the // `serve`-time capability check. `os validate` previously only checked // the `requires` tokens against the vocabulary (ADR-0066), never diff --git a/packages/cli/src/utils/field-patch-targets.test.ts b/packages/cli/src/utils/field-patch-targets.test.ts new file mode 100644 index 00000000000..2d5dfc00ea5 --- /dev/null +++ b/packages/cli/src/utils/field-patch-targets.test.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0146 D10, second rule] `os validate`'s walk for the field-patch target + * check: which objects the composed stack declares and which extensions each + * package carries. The verdict is `@objectstack/metadata-core`'s own judge. + */ + +import { describe, it, expect } from 'vitest'; +import { findFieldPatchTargetRefusals } from './field-patch-targets.js'; + +const account = { + name: 'fpt_account', + fields: { + industry: { type: 'select', label: 'Industry', options: [{ value: 'tech', label: 'Tech' }] }, + }, +}; + +describe('findFieldPatchTargetRefusals', () => { + it('judges an extension of an object the stack declares', () => { + const refusals = findFieldPatchTargetRefusals({ + objects: [account], + objectExtensions: [{ extend: 'fpt_account', fieldPatches: { sector: { label: 'Sector' }, industry: { options: { retail: { label: 'R' } } } } }], + }); + expect(refusals.map((r) => r.path)).toEqual([ + 'objectExtensions[0].fieldPatches.sector', + 'objectExtensions[0].fieldPatches.industry.options.retail', + ]); + }); + + it('admits a patch whose field and option the target has', () => { + expect(findFieldPatchTargetRefusals({ + objects: [account], + objectExtensions: [{ extend: 'fpt_account', fieldPatches: { industry: { label: 'Sector', options: { tech: { label: 'Technology' } } } } }], + })).toEqual([]); + }); + + it('leaves a target outside the composed stack to the install door', () => { + expect(findFieldPatchTargetRefusals({ + objectExtensions: [{ extend: 'crm_account', fieldPatches: { anything: { label: 'X' } } }], + })).toEqual([]); + }); + + it('reads a target another packages[] entry declares, and locates the refusal under its own entry', () => { + const refusals = findFieldPatchTargetRefusals({ + packages: [ + { manifest: { id: 'com.acme.core', objects: [account] } }, + { manifest: { id: 'com.acme.wording', objectExtensions: [{ extend: 'fpt_account', fieldPatches: { sector: { label: 'Sector' } } }] } }, + ], + }); + expect(refusals.map((r) => r.path)).toEqual(['packages[1].manifest.objectExtensions[0].fieldPatches.sector']); + }); + + it('reads a name-keyed objects map', () => { + const { name: _name, ...body } = account; + expect(findFieldPatchTargetRefusals({ + objects: { fpt_account: body }, + objectExtensions: [{ extend: 'fpt_account', fieldPatches: { sector: { label: 'Sector' } } }], + })).toHaveLength(1); + }); +}); diff --git a/packages/cli/src/utils/field-patch-targets.ts b/packages/cli/src/utils/field-patch-targets.ts new file mode 100644 index 00000000000..78ccd56d980 --- /dev/null +++ b/packages/cli/src/utils/field-patch-targets.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os validate`'s half of the field-patch target check (ADR-0146 D10, second + * rule): an object extension's `fieldPatches` naming a field its target does + * not have, or an option value the field does not have, is refused when the + * target is in the composed stack. + * + * ## One judge, not a second rule + * + * The verdict is `@objectstack/metadata-core`'s `fieldPatchTargetRefusals`, the + * SAME judge the marketplace install door refuses by and the registry fold + * names its boot-time misses by. What this module owns is the WALK: which + * objects the composed stack declares — its own, or every `packages[]` entry's + * (ADR-0130 D4) — and which extensions each package carries. A target outside + * the stack is not judged here; the install door judges it against the + * installed base. + */ + +import { fieldPatchTargetRefusals, type FieldPatchTargetRefusal } from '@objectstack/metadata-core'; + +import { artifactPackages } from './artifact-packages.js'; + +const isRecord = (v: unknown): v is Record => + v !== null && typeof v === 'object' && !Array.isArray(v); + +/** The objects a stack body declares, by name — a list of definitions or a name-keyed map. */ +function declaredObjects(body: Record): Array<[string, unknown]> { + const objects = body.objects; + if (Array.isArray(objects)) { + return objects.flatMap((o) => (isRecord(o) && typeof o.name === 'string' ? [[o.name, o] as [string, unknown]] : [])); + } + return isRecord(objects) ? Object.entries(objects) : []; +} + +/** Every field patch in the parsed stack that names what its in-stack target lacks, located. */ +export function findFieldPatchTargetRefusals(parsed: Record): FieldPatchTargetRefusal[] { + const entries = artifactPackages(parsed); + const bodies = entries.length > 0 + ? entries.map(({ index, body }) => ({ body, prefix: `packages[${index}].manifest.` })) + : [{ body: parsed, prefix: '' }]; + const composed = new Map(); + for (const { body } of bodies) for (const [name, object] of declaredObjects(body)) composed.set(name, object); + return bodies.flatMap(({ body, prefix }) => + fieldPatchTargetRefusals(body.objectExtensions, (name) => composed.get(name), prefix)); +} diff --git a/packages/cli/test/validate-field-patch-targets.test.ts b/packages/cli/test/validate-field-patch-targets.test.ts new file mode 100644 index 00000000000..d7f69a4365c --- /dev/null +++ b/packages/cli/test/validate-field-patch-targets.test.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0146 D10, second rule] `os validate` asks the field-patch target check — + * the install door's own judge (`fieldPatchTargetRefusals`), over the walk + * `utils/field-patch-targets.ts` takes, whose own file pins which targets it + * reads. What is pinned here is the command: a stack whose object extension + * patches a field or option value its in-stack target lacks exits 1, each + * refusal located on `errors`; the same stack patching what exists validates + * clean. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +const stack = (fieldPatches: string) => ` +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + manifest: { id: 'com.acme.fpt', name: 'fpt', version: '1.0.0', type: 'app', namespace: 'fpt' }, + objects: [{ + name: 'fpt_account', + label: 'Account', + fields: { + name: { type: 'text', label: 'Name' }, + industry: { type: 'select', label: 'Industry', options: [{ value: 'tech', label: 'Tech' }, { value: 'retail', label: 'Retail' }] }, + }, + }], + objectExtensions: [{ extend: 'fpt_account', fieldPatches: ${fieldPatches} }], +}, { strict: false }); +`; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runValidate(cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + [CLI, 'validate', '--json'], + // `childEnv()`, never a bulk `process.env` copy (check:cli-test-child-env). + { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, stdout, stderr) => { + resolvePromise({ + code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }, + ); + }); +} + +let missingDir: string; +let matchingDir: string; + +beforeAll(() => { + missingDir = mkdtempSync(join(tmpdir(), 'os-validate-fpt-missing-')); + writeFileSync(join(missingDir, 'objectstack.config.ts'), + stack(`{ sector: { label: 'Sector' }, industry: { options: { finance: { label: 'Finance' } } } }`)); + linkSpec(missingDir); + matchingDir = mkdtempSync(join(tmpdir(), 'os-validate-fpt-matching-')); + writeFileSync(join(matchingDir, 'objectstack.config.ts'), + stack(`{ industry: { label: 'Sector', options: { tech: { label: 'Technology' } } } }`)); + linkSpec(matchingDir); +}); + +afterAll(() => { + rmSync(missingDir, { recursive: true, force: true }); + rmSync(matchingDir, { recursive: true, force: true }); +}); + +describe('os validate — the field-patch target check (ADR-0146 D10)', () => { + it('exits 1 with each patch naming a missing field or option value located on errors', async () => { + const run = await runValidate(missingDir); + expect(run.code, run.stderr).toBe(1); + const report = JSON.parse(run.stdout); + expect(report.valid).toBe(false); + expect(report.errors.map((e: { path: string }) => e.path)).toEqual([ + 'objectExtensions[0].fieldPatches.sector', + 'objectExtensions[0].fieldPatches.industry.options.finance', + ]); + expect(report.errors.map((e: { reason: string }) => e.reason)).toEqual(['field', 'option']); + }); + + it('validates clean when the patch names a field and option value the target has', async () => { + const run = await runValidate(matchingDir); + expect(run.code, `${run.stdout}\n${run.stderr}`).toBe(0); + expect(JSON.parse(run.stdout).valid).toBe(true); + }); +}); From 2b566c738551c1b4a920997407f7c3179862fa3a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:42:02 +0000 Subject: [PATCH 07/11] docs: an object extension's fieldPatches on the extensions page; regenerated object reference Claude-Session: https://claude.ai/code/session_01FEkQ5nSMgkteiemkoN31vf Co-authored-by: Claude --- .../docs/data-modeling/object-extensions.mdx | 52 +++++++++++++++++-- content/docs/references/data/object.mdx | 2 +- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/content/docs/data-modeling/object-extensions.mdx b/content/docs/data-modeling/object-extensions.mdx index e177b38acb0..63134d17f74 100644 --- a/content/docs/data-modeling/object-extensions.mdx +++ b/content/docs/data-modeling/object-extensions.mdx @@ -1,7 +1,7 @@ --- title: Object extensions — extend without forking navTitle: Object Extensions -description: Add fields, validations and indexes to an object another package owns, without forking it — declaration, merge order, and what an extension may not contribute. +description: Add fields, validations and indexes to an object another package owns, or relabel its fields, without forking it — declaration, merge order, and what an extension may not contribute. --- Every object has exactly **one owning package**. That package defines the table, the @@ -63,6 +63,7 @@ check that spelling first. | Key | Merge behaviour | |:---|:---| | `fields` | **Additive.** Merged into the target's field map. A name the target already has is **replaced**, not merged. | +| `fieldPatches` | **Applied, field by field.** Changes the *display* of one of the target's own fields without replacing it — [below](#relabelling-a-vendor-field). | | `validations` | **Concatenated.** Your rules run alongside the owner's; neither replaces the other. | | `indexes` | **Concatenated**, same as validations. | | `label` | Replaces the target's label — with one exception, [below](#extensions-and-tenant-customization). | @@ -76,7 +77,7 @@ Added fields are ordinary fields, so everything on the ### What an extension may **not** contribute -The merge carries the seven keys above and nothing else. These four are not +The merge carries the eight keys above and nothing else. These four are not "unsupported yet" — there is no slot for them to arrive through, and the schema rejects each one by name with the alternative to use instead: @@ -92,6 +93,50 @@ fails at authoring time with a prescription rather than being dropped in silence --- +## Relabelling a vendor field + +Replacing a field through `fields` takes it over whole: its type, its options and its +rules become yours, and the owner's next version no longer reaches it. To change only +what a person *reads* — "Industry" shown as "Sector", a better help text — patch the +field instead: + +{/* os:check */} +```typescript +import { defineObjectExtension } from '@objectstack/spec/data'; + +export const AccountWordingExtension = defineObjectExtension({ + extend: 'crm_account', + fieldPatches: { + industry: { + label: 'Sector', + inlineHelpText: 'The market the account sells into.', + options: { tech: { label: 'Technology' } }, // addressed by the stored value + }, + }, +}); +``` + +A patch sets `label`, `description`, `inlineHelpText` and `placeholder` on the field, and +the `label` of an option it addresses by its stored `value` — the display whitelist +(`BASE_FIELD_DISPLAY_WHITELIST`), and nothing else. The field's type, requiredness, +default, formula and which options exist stay the owner's, so the owner's upgrades keep +applying and stored values never change; any other key is refused by name with that +reason. + +- **Every read serves it.** The patch is applied in the same merge as the other keys, + after the additive ones — so it can also reach a field a lower-priority extension + added — and the list, the by-name read and the layered view agree. Uninstalling the + extension serves the owner's text again. +- **Conflicts resolve like `label`.** Two extensions patching the same key: the higher + `priority` wins. A tenant who has edited that text keeps their own, [below](#extensions-and-tenant-customization). +- **A patch must name something real.** A field the target does not have, or an option + value its field does not have, is refused when the package installs and by + `os validate` when the target is in the same stack. A field whose options come from a + shared picklist has no option labels of its own to patch. At boot, an unmatched patch + is not applied and is named once in the log; the boot carries on. + +--- + ## Naming the fields you add Field names in an extension are **not** namespace-prefixed by the platform. The @@ -140,7 +185,8 @@ survive a customized object rather than being dropped by it. One deliberate asymmetry, ruled 2026-08-13: an extension's `label`, `pluralLabel` and `description` apply only while the base still carries the **packaged owner's** value. Once a tenant has renamed the object, the extension's packaged default yields and the - tenant's name stands. Fields, validations and indexes are unaffected — they merge + tenant's name stands. A `fieldPatches` key yields the same way, one field (and option) + at a time: a field label the tenant edited keeps the tenant's text. Fields, validations and indexes are unaffected — they merge either way. There is no escape hatch: a package cannot relabel an object a tenant has deliberately renamed. diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 88ee48748dd..63d4de9bd19 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -576,7 +576,7 @@ const result = ApiMethod.parse(data); | :--- | :--- | :--- | :--- | | **extend** | `string` | ✅ | Target object name (FQN) to extend | | **fields** | `Record; description?: string; … }>` | optional | Fields to add/override | -| **fieldPatches** | `Record` | optional | [EXPERIMENTAL — not enforced] Display patches of the target object's own fields, keyed by field name: change a vendor field's display properties (BASE_FIELD_DISPLAY_WHITELIST: label, description, help text, placeholder, option labels) without taking the field over, so its type, values and rules stay the vendor's and its upgrades still apply (ADR-0146 D10). Nothing applies a patch yet: the registry merge that does is rollout card N-23d, and until it lands the engine drops this slot at registration. Example: `fieldPatches: { industry: { label: 'Sector', inlineHelpText: 'The market the account sells into', options: { tech: { label: 'Technology' } } } }`. | +| **fieldPatches** | `Record` | optional | Display patches of the target object's own fields, keyed by field name: change a vendor field's display properties (BASE_FIELD_DISPLAY_WHITELIST: label, description, help text, placeholder, option labels) without taking the field over, so its type, values and rules stay the vendor's and its upgrades still apply (ADR-0146 D10). Applied by the registry merge (`mergeObjectDefinitions`) after the additive merges, on every object read: a tenant's own text keeps winning, and between two extensions the higher `priority` wins. A patch naming a field or option value the target does not have is refused at install and by `os validate`. Example: `fieldPatches: { industry: { label: 'Sector', inlineHelpText: 'The market the account sells into', options: { tech: { label: 'Technology' } } } }`. | | **label** | `string` | optional | Override label for the extended object | | **pluralLabel** | `string` | optional | Override plural label for the extended object | | **description** | `string` | optional | Override description for the extended object | From 24c0eecf5daf9f963290ee8b6db665ce63f5190e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:46:53 +0000 Subject: [PATCH 08/11] test(cli): os validate field-patch fixture declares its sharingModel Claude-Session: https://claude.ai/code/session_01FEkQ5nSMgkteiemkoN31vf Co-authored-by: Claude --- packages/cli/test/validate-field-patch-targets.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/test/validate-field-patch-targets.test.ts b/packages/cli/test/validate-field-patch-targets.test.ts index d7f69a4365c..b795ada3547 100644 --- a/packages/cli/test/validate-field-patch-targets.test.ts +++ b/packages/cli/test/validate-field-patch-targets.test.ts @@ -31,6 +31,7 @@ export default defineStack({ objects: [{ name: 'fpt_account', label: 'Account', + sharingModel: 'private', fields: { name: { type: 'text', label: 'Name' }, industry: { type: 'select', label: 'Industry', options: [{ value: 'tech', label: 'Tech' }, { value: 'retail', label: 'Retail' }] }, From e1583ebe4896fc04bcf709ca637c338e41fac894 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:49:55 +0000 Subject: [PATCH 09/11] test(cli): classify the field-patch target check as validate-only in the build/validate parity roster Claude-Session: https://claude.ai/code/session_01FEkQ5nSMgkteiemkoN31vf Co-authored-by: Claude --- packages/cli/test/validate-build-gate-parity.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index 6c21bb4acee..21ec9fa7d5d 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -241,7 +241,13 @@ const BUILD_ONLY_GATES: Readonly> = { * empty, because it is the only honest place the closed roster has for the * next validate-only gate. */ -const VALIDATE_ONLY_GATES: Readonly> = {}; +const VALIDATE_ONLY_GATES: Readonly> = { + findFieldPatchTargetRefusals: + '[ADR-0146 D10, second rule] An object extension\'s `fieldPatches` naming a field or option value its ' + + 'in-stack target lacks, judged by the marketplace install door\'s own `fieldPatchTargetRefusals`. Wired ' + + 'where the rollout card asked for it (`os validate` and the install door); `os build` still emits such an ' + + 'artifact, which the install door then refuses — the build-side gap, reported rather than closed here.', +}; /** * Everything else the two commands call, and the reason each one is NOT an From f82fd682510fca9bd7ab9727ba50ae299558048b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 15:48:05 +0000 Subject: [PATCH 10/11] refactor(cloud-connection): place fieldPatchTargetRefusalsOf above the override write gate's docblock The new step-2e helper sat between evaluateOverrideWriteGate and its own docblock, stacking two docblocks and leaving the gate undocumented. Moved unchanged; no behaviour change. Claude-Session: https://claude.ai/code/session_01FEkQ5nSMgkteiemkoN31vf Co-authored-by: Claude --- .../src/marketplace-install-local-plugin.ts | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 8b191db1c6c..7532f96447a 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -3247,32 +3247,6 @@ export class MarketplaceInstallLocalPlugin implements Plugin { return null; }; - /** - * [ADR-0146 D2, ruling A on #28390; #28410] THE OVERRIDE WRITE GATE at the - * managed install — the door a package's items enter through as code - * (`manifest.register`), so the publish doors never see them. The rule is - * the publish doors' own (`judgeOverrideWrite`), over the walk `os - * validate` takes (`overrideWriteItemsOf`): - * - * - `refusals` — the incoming package's items the gate refuses: an - * undeclared same name a read-only package in its dependency closure - * ships, or an `overrides` declaration the gate does not admit (a - * dependent declaring, a base outside the closure, an excluded type, - * an object, a flow or action while their channel waits). A `.custom` - * package's admitted declarations install as at publish. - * - `collisions` — on a (re)install of a base, the names an installed - * dependent (a package whose closure holds this one) already holds, - * undeclared, that this version now ships. Listed on the answer, never - * refused (see {@link OverrideUpgradeCollision}). - * - * The card placed this gate point at `installPackage`; that door takes a - * manifest and no items (`PackageInstallManifestSchema`), and the - * unmanaged install copies through the save door (already gated), so the - * managed install is where a package's items arrive unjudged. - * - * Reads nothing it cannot: with no `objectql` registry or no protocol - * listing, the gate has no closure to judge and answers empty. - */ /** * [ADR-0146 D10, second rule] Step 2e of {@link handleInstall}: every * `objectExtensions[].fieldPatches` entry the converted body carries — @@ -3304,6 +3278,32 @@ export class MarketplaceInstallLocalPlugin implements Plugin { return bodies.flatMap(({ body, prefix }) => fieldPatchTargetRefusals(body.objectExtensions, getTarget, prefix)); }; + /** + * [ADR-0146 D2, ruling A on #28390; #28410] THE OVERRIDE WRITE GATE at the + * managed install — the door a package's items enter through as code + * (`manifest.register`), so the publish doors never see them. The rule is + * the publish doors' own (`judgeOverrideWrite`), over the walk `os + * validate` takes (`overrideWriteItemsOf`): + * + * - `refusals` — the incoming package's items the gate refuses: an + * undeclared same name a read-only package in its dependency closure + * ships, or an `overrides` declaration the gate does not admit (a + * dependent declaring, a base outside the closure, an excluded type, + * an object, a flow or action while their channel waits). A `.custom` + * package's admitted declarations install as at publish. + * - `collisions` — on a (re)install of a base, the names an installed + * dependent (a package whose closure holds this one) already holds, + * undeclared, that this version now ships. Listed on the answer, never + * refused (see {@link OverrideUpgradeCollision}). + * + * The card placed this gate point at `installPackage`; that door takes a + * manifest and no items (`PackageInstallManifestSchema`), and the + * unmanaged install copies through the save door (already gated), so the + * managed install is where a package's items arrive unjudged. + * + * Reads nothing it cannot: with no `objectql` registry or no protocol + * listing, the gate has no closure to judge and answers empty. + */ private evaluateOverrideWriteGate = async ( ctx: PluginContext, manifestId: string, From d4414410c797989ecdf4983e7633b32df94619d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:26:16 +0000 Subject: [PATCH 11/11] test(cli): give the field-patch validate spawn tests the 120 s budget their siblings carry Both cases spawn the real CLI; under a loaded box the 5 s default timed out (5014 ms / 5001 ms on the post-merge run). The sibling validate spawn tests (validate-extension-field-suffix, validate-override-declarations) pass 120_000. Claude-Session: https://claude.ai/code/session_01FEkQ5nSMgkteiemkoN31vf Co-authored-by: Claude --- packages/cli/test/validate-field-patch-targets.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/validate-field-patch-targets.test.ts b/packages/cli/test/validate-field-patch-targets.test.ts index b795ada3547..4845d043a6e 100644 --- a/packages/cli/test/validate-field-patch-targets.test.ts +++ b/packages/cli/test/validate-field-patch-targets.test.ts @@ -95,11 +95,11 @@ describe('os validate — the field-patch target check (ADR-0146 D10)', () => { 'objectExtensions[0].fieldPatches.industry.options.finance', ]); expect(report.errors.map((e: { reason: string }) => e.reason)).toEqual(['field', 'option']); - }); + }, 120_000); it('validates clean when the patch names a field and option value the target has', async () => { const run = await runValidate(matchingDir); expect(run.code, `${run.stdout}\n${run.stderr}`).toBe(0); expect(JSON.parse(run.stdout).valid).toBe(true); - }); + }, 120_000); });