Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/28565-field-patch-applied.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 49 additions & 3 deletions content/docs/data-modeling/object-extensions.mdx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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). |
Expand All @@ -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:

Expand All @@ -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
Expand Down Expand Up @@ -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.
</Callout>
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,7 @@ const result = ApiMethod.parse(data);
| :--- | :--- | :--- | :--- |
| **extend** | `string` | ✅ | Target object name (FQN) to extend |
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | optional | Fields to add/override |
| **fieldPatches** | `Record<string, { label?: string; description?: string; inlineHelpText?: string; placeholder?: string; … }>` | 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<string, { label?: string; description?: string; inlineHelpText?: string; placeholder?: string; … }>` | 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 |
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ import { findViewContainerNameRefusals } from '../utils/view-container-names.js'
// door's own judge, walked over the artifact's packages.
import { findOverrideDeclarationRefusals } from '../utils/override-declarations.js';
import { findExtensionFieldSuffixRefusals } from '../utils/extension-field-suffix.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.
Expand Down Expand Up @@ -719,6 +722,35 @@ export default class Validate extends Command {
this.exit(1);
}

// 3a-v. [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<string, unknown>);
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
Expand Down
61 changes: 61 additions & 0 deletions packages/cli/src/utils/field-patch-targets.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
46 changes: 46 additions & 0 deletions packages/cli/src/utils/field-patch-targets.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> =>
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<string, unknown>): 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<string, unknown>): 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<string, unknown>();
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));
}
11 changes: 8 additions & 3 deletions packages/cli/test/validate-build-gate-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,9 @@ const BUILD_ONLY_GATES: Readonly<Record<string, string>> = {
* EMPTY is this ledger's steady state: every row is a gap the build carries.
* Two rows it has held moved to SHARED_NON_REGISTRY_GATES when `compile.ts`
* gained the call: `findViewContainerNameRefusals` (#20331 → #20393) and
* `findOverrideDeclarationRefusals` (#28410 → #28643). It holds one today,
* `findExtensionFieldSuffixRefusals` (#28564), whose card did not name the
* build.
* `findOverrideDeclarationRefusals` (#28410 → #28643). It holds two today,
* `findExtensionFieldSuffixRefusals` (#28564) and `findFieldPatchTargetRefusals`
* (#28565), whose cards did not name the build.
*/
const VALIDATE_ONLY_GATES: Readonly<Record<string, string>> = {
// [#28564] A real gap in the build's direction, reported and not closed —
Expand All @@ -251,6 +251,11 @@ const VALIDATE_ONLY_GATES: Readonly<Record<string, string>> = {
// the save and publish doors refuse the same fields with the same judge.
findExtensionFieldSuffixRefusals:
'card #28564 placed the __c reservation at the save/restore/publish doors, os validate, the install doors and duplicate; os build is a further point it did not name (as #28410 left the override gate until #28643). The install and publish doors refuse the same fields with the same judge.',
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.',
};

/**
Expand Down
Loading
Loading