Skip to content

Commit e62dcc5

Browse files
feat(objectql): an object extension's fieldPatches relabels the vendor field on every read, refused at install and os validate when it names nothing (#28565) (#28728)
domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XR1KAQEJUPapgHT2dsHnwQx)_ Fixes #28565 Clause-②: no Metadata-protocol: semantic Skills-docs: follow-up #28730 ## 维护者速读 - **改了什么**:对象扩展的「字段显示补丁」(`objectExtensions[].fieldPatches`,上一版只声明未生效)现在真的生效了:引擎注册扩展时带上它,注册表合并(`mergeObjectDefinitions`)在字段合并之后把白名单里的 label、description、帮助文字、占位符和选项 label 写到厂商字段上;字段类型、必填、选项取值不变。补丁指向目标对象没有的字段或选项值时,在市场安装口和 `os validate` 拒绝;启动时只记一次日志、不拦启动。 - **为什么改**:HotCRM 的 "Industry" 要在每个表单、列表和 API 读取里显示成 "Sector",而字段本身仍归厂商,厂商升级照样进来。指向不存在字段的补丁以前会悄悄什么都不做。 - **风险与回滚**:只影响写了 `fieldPatches` 的扩展;没写的扩展合并结果逐字节不变。租户自己改过的文字照旧优先(ADR-0029 D9.2a),两个扩展冲突时 priority 高者胜。回滚 = revert 本 PR。 - **先看**:① `packages/objectql/src/registry.ts` 的 `patchedField` / `applyFieldPatches` 与 `tenantPatchedDisplayKeys`(合并与让位);② `packages/metadata-core/src/extension-field-patch-targets.ts`(三处共用的目标检查);③ `packages/cloud-connection/src/marketplace-install-local-plugin.ts` 新增的 2e 步。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run src/registry-extension-field-patches.test.ts`,预期 9 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes - **Carry** — `packages/objectql/src/engine.ts`: the manifest loop's `extDef` carries `fieldPatches` beside `fields`, so the `'extend'` registration hands the slot to the registry. Re-measured at `2678600d`: still the only `'extend'` registration in `objectql`, `metadata-protocol` and `runtime` (`git grep "'extend'"`, one call site, `engine.ts:8996`). - **Apply** — `packages/objectql/src/registry.ts`: - `mergeObjectDefinitions` gains one step after the scalar overrides: `applyFieldPatches` writes each `BASE_FIELD_DISPLAY_WHITELIST.field` key onto the named field and each `BASE_FIELD_DISPLAY_WHITELIST.option` key onto the option whose stored `value` the patch names. Keys are read from the constant, so a widened whitelist widens the merge. The type of the slot is the spec's own (`ObjectExtensionParsed['fieldPatches']`); there is no second patch shape. - D9.2a yield: `tenantPatchedDisplayKeys` computes, once over the fold's base, which patched field/option keys the tenant authored away from the packaged owner's value, with the same two "no opinion" edges as `tenantAuthoredScalars` (no owner; base IS the owner by reference). The comparison is `scalarOverridesPackagedBase` itself, called through a one-line adapter because its exported key union names only the object scalars and `message`. - Priority: the fold already applies contributors in ascending `priority`, so the higher one writes last, exactly as for `label`. - Boot: `reportFieldPatchMisses` names, once per object/package/field/option, a patch the fold could not apply, at `warn`, worded as the observed state. Never a refusal: the fold runs on every read while a boot is still registering (AGENTS.md *Startup registry reads*), and the patch applies on the next read once the field arrives. - `subtractExtenderContributions` is untouched: patches are idempotent over an already-folded base (the base carries the patched text, reads as authored, and the patch yields to the same value), the way the scalars are. Pinned by the "idempotent over a folded body" assertion. - **By-name and layered reads** — no edit. `protocol-object-extender-fold.ts` already calls `registry.foldObjectExtendersOnto`, which runs `foldExtendersOntoDefinition` → `mergeObjectDefinitions`, the one apply implementation (measured; the order's assumption holds). The code layer (`getArtifactItem('object')` → `resolveOwnerLayer`) goes through the same fold. - **Target check** — `packages/metadata-core/src/extension-field-patch-targets.ts` (new): `fieldPatchTargetMisses` (one extension against one target), `fieldPatchTargetRefusals` (a manifest's `objectExtensions`, located) and `fieldPatchTargetRefusalMessage`. Misses: a field the target lacks, an option value its field lacks, and an option patch on a picklist-bound field (its options are written over the field after the fold, so an option patch there could never be served). `metadata-core` is the one package all three readers already depend on. One call line each: - `packages/cloud-connection/src/marketplace-install-local-plugin.ts#handleInstall`, new step **2e**, right after N-09's override write gate (2d) and before the hot-register (3): `400 VALIDATION_ERROR`, every refusal in `error.details.refusals`, nothing registered, written or synced. Targets: one the same artifact delivers, else the registry's resolved object. - `packages/cli/src/commands/validate.ts`, new step **3a-iv** after the override gate, over `packages/cli/src/utils/field-patch-targets.ts` (the walk: objects across the composed stack and every `packages[]` entry). - the registry fold's boot log (above). - **Marker flip** — `packages/spec/src/data/object.zod.ts`: `fieldPatches`' `.describe()` drops `[EXPERIMENTAL — not enforced]` and cites `mergeObjectDefinitions`; its TSDoc, the `@example` comment and the guidance code comment follow. No liveness ledger row exists for the slot (`grep -l ObjectExtension packages/spec/liveness/*.json`: none). `content/docs/references/data/object.mdx` regenerated (`gen:docs`, the one artifact `check:generated` named stale). The spec test that pinned the marker now pins its absence. - **Docs** — `content/docs/data-modeling/object-extensions.mdx`: `fieldPatches` in the contribution table, a "Relabelling a vendor field" section, and the D9.2a callout extended. - `packages/cli/test/validate-build-gate-parity.test.ts`: the new validate gate is classified in `VALIDATE_ONLY_GATES` with its reason (the closed roster requires it); the build-side gap is reported below. - `.changeset/28565-field-patch-applied.md`: minor on `objectql`, `metadata-core`, `cloud-connection`, `cli` and `spec` (the kind is `semantic`; each package's `files[]` ships `dist`, which carries the change after a build). ## Tests All readings at head `043407e6` unless named. Red-on-base means the change's source files restored to `2678600d` with the new tests kept. | Test file | Head | Base / ablation | |---|---|---| | `packages/objectql/src/registry-extension-field-patches.test.ts` (9) | 9/9 | `registry.ts` + `engine.ts` at base: 8 red (the ninth is the no-patch control). `engine.ts` alone at base (carry ablation): 1 red, the `ObjectQL.registerApp` carry test. `reportFieldPatchMisses` call removed: 1 red, the warned-once test. | | `packages/metadata-core/src/extension-field-patch-targets.test.ts` (10) | 10/10 | new module, absent on base | | `packages/cloud-connection/src/marketplace-install-local-field-patch-targets.test.ts` (5) | 5/5 | step 2e call line removed: 4 red (every negative pin); the lit control stays green | | `packages/cli/src/utils/field-patch-targets.test.ts` (5) | 5/5 | new module, absent on base | | `packages/cli/test/validate-field-patch-targets.test.ts` (2, spawns the CLI) | 2/2 | step 3a-iv call line removed: 1 red (the negative pin); the control stays green | | `packages/spec/src/data/field-customization.test.ts` | green | the marker assertion reds on base (it pins the `EXPERIMENTAL` marker's absence) | Each ablation was restored and the file's blob checked equal to HEAD before the next one. Existing extension fold tests stay green: the full suites after the last two main merges (whole-repo `pnpm build`, exit 0, then): | Suite | Result | |---|---| | `@objectstack/objectql` | 515 files, 9915 tests, exit 0 | | `@objectstack/metadata-core` | 22 files, 885 tests, exit 0 | | `@objectstack/cloud-connection` | 66 files, 801 tests, exit 0 | | `@objectstack/spec` `src/data` | 154 files, 4751 tests (1 todo), exit 0 | | `@objectstack/cli` `unit` tier | 365 files, 5172 tests, exit 0 | | `tsc --noEmit` objectql, cloud-connection, cli | exit 0 each | | `eslint` over the 15 changed TS files | exit 0 | After the final merge (`043407e6`, no file overlap with this diff) the five new test files were re-run: all green. One run of the CLI trio during that step exited 1 while other suites ran beside it; three re-runs alone exited 0, and its log was not kept, so it is reported here rather than explained. **Derived gates** (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived at head: identical list). `--ran` verdict: **114 derived, 114 run, 0 NOT-MEASURED, 0 UNRUN**, exit 0. First pass: 109 exit 0 and five that did not. Each was traced to the checkout, not the diff, and re-run green: - `test cli unit` exit 1: `setup-integrations-order.pin` read a `service-automation` dist from before #28651 came in with the merge; rebuilt, green. - `check-plugin-teardown-shape --self-test` exit 3: shallow clone; its pinned fixture commit was fetched, then 48 cases passed. - `check:skill-examples`, `check:dual-build-cjs-loads`, `check:i18n-coverage` exit 3 (PREREQUISITE NOT MET: built output missing); after a whole-repo `pnpm build`, exit 0 each. ## Acceptance notes - **The protocol install primitive is not a door a patch can reach.** The order rules a call line at `protocol-package-install.ts#installPackage`; measured, its manifest is parsed by `PackageInstallManifestSchema` (strict `ManifestSchema`), which refuses `objectExtensions` as an unknown key before anything else runs: `PackageInstallManifestSchema.safeParse({ id: 'com.acme.ext', name: 'E', type: 'app', version: '1.0.0', objectExtensions: [...] })` → `false`, "Unrecognized key(s) on this package manifest: `objectExtensions`" (spec dist built from `2678600d`). A call line there could never fire and could not be ablated, so none was written. The only install door that delivers an extension today is `POST /api/v1/marketplace/install-local` (including `os package install <file>`, which posts there); the card's "Not here" names the future runtime write door for an `object_extension` item (#28574). - **`os build` does not run the target check.** It still emits an artifact the install door then refuses; recorded as the `VALIDATE_ONLY_GATES` row. Wiring it into `compile.ts` is outside the card's three doors. - **Option patches on picklist-bound fields are refused**, not applied: the served options of such a field are the picklist's, written after the fold (`finishServedObject`), so an option label patched in the fold would be overwritten on every read. - **`ObjectExtensionSchema.guidance.actions`** still reads "the merge carries `fields`, `label`, `pluralLabel`, `description`, `validations` and `indexes` only". It is a refusal sentence about `actions`, so it is left as is under ruling 3 (only the slot's marker moves in `packages/spec`); it now omits `fieldPatches` from that list. - **Declaration lines** are the claim's, copied. The install and validate refusals are new refusals of a key that was declared `[EXPERIMENTAL — not enforced]`; the ruling records the kind as `semantic`. - #28564 (the `__c` judge at the same doors) has not landed at the time of this PR; whichever lands second sits its call line beside the other's. ## Verification `pnpm --filter @objectstack/objectql exec vitest run src/registry-extension-field-patches.test.ts` — 9/9 at head `043407e6`; 8 red with `registry.ts` and `engine.ts` restored to `2678600d` (the ninth is the no-patch control). --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5nSMgkteiemkoN31vf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5493b88 commit e62dcc5

18 files changed

Lines changed: 1335 additions & 33 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/metadata-core': minor
4+
'@objectstack/cloud-connection': minor
5+
'@objectstack/cli': minor
6+
'@objectstack/spec': minor
7+
---
8+
9+
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`
10+
11+
Clause-②: no
12+
Metadata-protocol: semantic
13+
14+
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:
15+
16+
```ts
17+
objectExtensions: [{
18+
extend: 'crm_account',
19+
fieldPatches: {
20+
industry: { label: 'Sector', inlineHelpText: 'The market the account sells into', options: { tech: { label: 'Technology' } } },
21+
},
22+
}]
23+
```
24+
25+
- **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.
26+
- **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.
27+
- **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.
28+
- **New export.** `@objectstack/metadata-core` exports the one judge the three readers share: `fieldPatchTargetMisses`, `fieldPatchTargetRefusals`, `fieldPatchTargetRefusalMessage`.
29+
30+
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.

‎content/docs/data-modeling/object-extensions.mdx‎

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Object extensions — extend without forking
33
navTitle: Object Extensions
4-
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.
4+
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.
55
---
66

77
Every object has exactly **one owning package**. That package defines the table, the
@@ -63,6 +63,7 @@ check that spelling first.
6363
| Key | Merge behaviour |
6464
|:---|:---|
6565
| `fields` | **Additive.** Merged into the target's field map. A name the target already has is **replaced**, not merged. |
66+
| `fieldPatches` | **Applied, field by field.** Changes the *display* of one of the target's own fields without replacing it — [below](#relabelling-a-vendor-field). |
6667
| `validations` | **Concatenated.** Your rules run alongside the owner's; neither replaces the other. |
6768
| `indexes` | **Concatenated**, same as validations. |
6869
| `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
7677

7778
### What an extension may **not** contribute
7879

79-
The merge carries the seven keys above and nothing else. These four are not
80+
The merge carries the eight keys above and nothing else. These four are not
8081
"unsupported yet" — there is no slot for them to arrive through, and the schema rejects
8182
each one by name with the alternative to use instead:
8283

@@ -92,6 +93,50 @@ fails at authoring time with a prescription rather than being dropped in silence
9293

9394
---
9495

96+
## Relabelling a vendor field
97+
98+
Replacing a field through `fields` takes it over whole: its type, its options and its
99+
rules become yours, and the owner's next version no longer reaches it. To change only
100+
what a person *reads* — "Industry" shown as "Sector", a better help text — patch the
101+
field instead:
102+
103+
{/* os:check */}
104+
```typescript
105+
import { defineObjectExtension } from '@objectstack/spec/data';
106+
107+
export const AccountWordingExtension = defineObjectExtension({
108+
extend: 'crm_account',
109+
fieldPatches: {
110+
industry: {
111+
label: 'Sector',
112+
inlineHelpText: 'The market the account sells into.',
113+
options: { tech: { label: 'Technology' } }, // addressed by the stored value
114+
},
115+
},
116+
});
117+
```
118+
119+
A patch sets `label`, `description`, `inlineHelpText` and `placeholder` on the field, and
120+
the `label` of an option it addresses by its stored `value` — the display whitelist
121+
(`BASE_FIELD_DISPLAY_WHITELIST`), and nothing else. The field's type, requiredness,
122+
default, formula and which options exist stay the owner's, so the owner's upgrades keep
123+
applying and stored values never change; any other key is refused by name with that
124+
reason.
125+
126+
- **Every read serves it.** The patch is applied in the same merge as the other keys,
127+
after the additive ones — so it can also reach a field a lower-priority extension
128+
added — and the list, the by-name read and the layered view agree. Uninstalling the
129+
extension serves the owner's text again.
130+
- **Conflicts resolve like `label`.** Two extensions patching the same key: the higher
131+
`priority` wins. A tenant who has edited that text keeps their own, [below](#extensions-and-tenant-customization).
132+
- **A patch must name something real.** A field the target does not have, or an option
133+
value its field does not have, is refused when the package installs and by
134+
`os validate` when the target is in the same stack. A field whose options come from a
135+
shared picklist has no option labels of its own to patch. At boot, an unmatched patch
136+
is not applied and is named once in the log; the boot carries on.
137+
138+
---
139+
95140
## Naming the fields you add
96141

97142
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.
140185
One deliberate asymmetry, ruled 2026-08-13: an extension's `label`, `pluralLabel` and
141186
`description` apply only while the base still carries the **packaged owner's** value.
142187
Once a tenant has renamed the object, the extension's packaged default yields and the
143-
tenant's name stands. Fields, validations and indexes are unaffected — they merge
188+
tenant's name stands. A `fieldPatches` key yields the same way, one field (and option)
189+
at a time: a field label the tenant edited keeps the tenant's text. Fields, validations and indexes are unaffected — they merge
144190
either way. There is no escape hatch: a package cannot relabel an object a tenant has
145191
deliberately renamed.
146192
</Callout>

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,7 @@ const result = ApiMethod.parse(data);
576576
| :--- | :--- | :--- | :--- |
577577
| **extend** | `string` | ✅ | Target object name (FQN) to extend |
578578
| **fields** | `Record<string, { name?: string; label?: string; type: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; description?: string; … }>` | optional | Fields to add/override |
579-
| **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' } } } }`. |
579+
| **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' } } } }`. |
580580
| **label** | `string` | optional | Override label for the extended object |
581581
| **pluralLabel** | `string` | optional | Override plural label for the extended object |
582582
| **description** | `string` | optional | Override description for the extended object |

‎packages/cli/src/commands/validate.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ import { findViewContainerNameRefusals } from '../utils/view-container-names.js'
7878
// door's own judge, walked over the artifact's packages.
7979
import { findOverrideDeclarationRefusals } from '../utils/override-declarations.js';
8080
import { findExtensionFieldSuffixRefusals } from '../utils/extension-field-suffix.js';
81+
// [ADR-0146 D10, second rule] The field-patch target check's walk; the verdict
82+
// is the marketplace install door's own judge.
83+
import { findFieldPatchTargetRefusals } from '../utils/field-patch-targets.js';
8184
// A field `picklist` that names no picklist the stack declares — refused, or
8285
// reported when the declaring package depends on packages outside the stack.
8386
// Walked the way the load path registers, like the refusal above.
@@ -719,6 +722,35 @@ export default class Validate extends Command {
719722
this.exit(1);
720723
}
721724

725+
// 3a-v. [ADR-0146 D10, second rule] The field-patch target check, at
726+
// author time: an object extension's `fieldPatches` naming a field
727+
// its target does not have, or an option value the field does not
728+
// have, when the target is in this composed stack. The verdict is the
729+
// install door's own judge (`fieldPatchTargetRefusals`), so a green
730+
// validate is a patch the install door admits; see
731+
// `utils/field-patch-targets.ts` for the walk.
732+
const patchTargetRefusals = findFieldPatchTargetRefusals(result.data as Record<string, unknown>);
733+
if (patchTargetRefusals.length > 0) {
734+
if (flags.json) {
735+
await emitJson({
736+
valid: false,
737+
errors: patchTargetRefusals,
738+
warnings: warningsSoFar(),
739+
conversions: conversionNotices,
740+
duration: timer.elapsed(),
741+
});
742+
this.exit(1);
743+
}
744+
const n = patchTargetRefusals.length;
745+
console.log('');
746+
printError(`${n} field patch${n > 1 ? 'es name' : ' names'} a field or option the extended object does not have (ADR-0146 D10)`);
747+
printBulletList(
748+
patchTargetRefusals.map((r) => `${r.path} ${r.message}`),
749+
{ noun: 'field-patch refusal(s)', remedy: JSON_FULL_LIST_REMEDY },
750+
);
751+
this.exit(1);
752+
}
753+
722754
// 3b. [#3366] Installable-provider preflight — the shift-left of the
723755
// `serve`-time capability check. `os validate` previously only checked
724756
// the `requires` tokens against the vocabulary (ADR-0066), never
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0146 D10, second rule] `os validate`'s walk for the field-patch target
5+
* check: which objects the composed stack declares and which extensions each
6+
* package carries. The verdict is `@objectstack/metadata-core`'s own judge.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { findFieldPatchTargetRefusals } from './field-patch-targets.js';
11+
12+
const account = {
13+
name: 'fpt_account',
14+
fields: {
15+
industry: { type: 'select', label: 'Industry', options: [{ value: 'tech', label: 'Tech' }] },
16+
},
17+
};
18+
19+
describe('findFieldPatchTargetRefusals', () => {
20+
it('judges an extension of an object the stack declares', () => {
21+
const refusals = findFieldPatchTargetRefusals({
22+
objects: [account],
23+
objectExtensions: [{ extend: 'fpt_account', fieldPatches: { sector: { label: 'Sector' }, industry: { options: { retail: { label: 'R' } } } } }],
24+
});
25+
expect(refusals.map((r) => r.path)).toEqual([
26+
'objectExtensions[0].fieldPatches.sector',
27+
'objectExtensions[0].fieldPatches.industry.options.retail',
28+
]);
29+
});
30+
31+
it('admits a patch whose field and option the target has', () => {
32+
expect(findFieldPatchTargetRefusals({
33+
objects: [account],
34+
objectExtensions: [{ extend: 'fpt_account', fieldPatches: { industry: { label: 'Sector', options: { tech: { label: 'Technology' } } } } }],
35+
})).toEqual([]);
36+
});
37+
38+
it('leaves a target outside the composed stack to the install door', () => {
39+
expect(findFieldPatchTargetRefusals({
40+
objectExtensions: [{ extend: 'crm_account', fieldPatches: { anything: { label: 'X' } } }],
41+
})).toEqual([]);
42+
});
43+
44+
it('reads a target another packages[] entry declares, and locates the refusal under its own entry', () => {
45+
const refusals = findFieldPatchTargetRefusals({
46+
packages: [
47+
{ manifest: { id: 'com.acme.core', objects: [account] } },
48+
{ manifest: { id: 'com.acme.wording', objectExtensions: [{ extend: 'fpt_account', fieldPatches: { sector: { label: 'Sector' } } }] } },
49+
],
50+
});
51+
expect(refusals.map((r) => r.path)).toEqual(['packages[1].manifest.objectExtensions[0].fieldPatches.sector']);
52+
});
53+
54+
it('reads a name-keyed objects map', () => {
55+
const { name: _name, ...body } = account;
56+
expect(findFieldPatchTargetRefusals({
57+
objects: { fpt_account: body },
58+
objectExtensions: [{ extend: 'fpt_account', fieldPatches: { sector: { label: 'Sector' } } }],
59+
})).toHaveLength(1);
60+
});
61+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os validate`'s half of the field-patch target check (ADR-0146 D10, second
5+
* rule): an object extension's `fieldPatches` naming a field its target does
6+
* not have, or an option value the field does not have, is refused when the
7+
* target is in the composed stack.
8+
*
9+
* ## One judge, not a second rule
10+
*
11+
* The verdict is `@objectstack/metadata-core`'s `fieldPatchTargetRefusals`, the
12+
* SAME judge the marketplace install door refuses by and the registry fold
13+
* names its boot-time misses by. What this module owns is the WALK: which
14+
* objects the composed stack declares — its own, or every `packages[]` entry's
15+
* (ADR-0130 D4) — and which extensions each package carries. A target outside
16+
* the stack is not judged here; the install door judges it against the
17+
* installed base.
18+
*/
19+
20+
import { fieldPatchTargetRefusals, type FieldPatchTargetRefusal } from '@objectstack/metadata-core';
21+
22+
import { artifactPackages } from './artifact-packages.js';
23+
24+
const isRecord = (v: unknown): v is Record<string, unknown> =>
25+
v !== null && typeof v === 'object' && !Array.isArray(v);
26+
27+
/** The objects a stack body declares, by name — a list of definitions or a name-keyed map. */
28+
function declaredObjects(body: Record<string, unknown>): Array<[string, unknown]> {
29+
const objects = body.objects;
30+
if (Array.isArray(objects)) {
31+
return objects.flatMap((o) => (isRecord(o) && typeof o.name === 'string' ? [[o.name, o] as [string, unknown]] : []));
32+
}
33+
return isRecord(objects) ? Object.entries(objects) : [];
34+
}
35+
36+
/** Every field patch in the parsed stack that names what its in-stack target lacks, located. */
37+
export function findFieldPatchTargetRefusals(parsed: Record<string, unknown>): FieldPatchTargetRefusal[] {
38+
const entries = artifactPackages(parsed);
39+
const bodies = entries.length > 0
40+
? entries.map(({ index, body }) => ({ body, prefix: `packages[${index}].manifest.` }))
41+
: [{ body: parsed, prefix: '' }];
42+
const composed = new Map<string, unknown>();
43+
for (const { body } of bodies) for (const [name, object] of declaredObjects(body)) composed.set(name, object);
44+
return bodies.flatMap(({ body, prefix }) =>
45+
fieldPatchTargetRefusals(body.objectExtensions, (name) => composed.get(name), prefix));
46+
}

‎packages/cli/test/validate-build-gate-parity.test.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,9 +237,9 @@ const BUILD_ONLY_GATES: Readonly<Record<string, string>> = {
237237
* EMPTY is this ledger's steady state: every row is a gap the build carries.
238238
* Two rows it has held moved to SHARED_NON_REGISTRY_GATES when `compile.ts`
239239
* gained the call: `findViewContainerNameRefusals` (#20331 → #20393) and
240-
* `findOverrideDeclarationRefusals` (#28410 → #28643). It holds one today,
241-
* `findExtensionFieldSuffixRefusals` (#28564), whose card did not name the
242-
* build.
240+
* `findOverrideDeclarationRefusals` (#28410 → #28643). It holds two today,
241+
* `findExtensionFieldSuffixRefusals` (#28564) and `findFieldPatchTargetRefusals`
242+
* (#28565), whose cards did not name the build.
243243
*/
244244
const VALIDATE_ONLY_GATES: Readonly<Record<string, string>> = {
245245
// [#28564] A real gap in the build's direction, reported and not closed —
@@ -251,6 +251,11 @@ const VALIDATE_ONLY_GATES: Readonly<Record<string, string>> = {
251251
// the save and publish doors refuse the same fields with the same judge.
252252
findExtensionFieldSuffixRefusals:
253253
'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.',
254+
findFieldPatchTargetRefusals:
255+
'[ADR-0146 D10, second rule] An object extension\'s `fieldPatches` naming a field or option value its ' +
256+
'in-stack target lacks, judged by the marketplace install door\'s own `fieldPatchTargetRefusals`. Wired ' +
257+
'where the rollout card asked for it (`os validate` and the install door); `os build` still emits such an ' +
258+
'artifact, which the install door then refuses — the build-side gap, reported rather than closed here.',
254259
};
255260

256261
/**

0 commit comments

Comments
 (0)