Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/28560-field-patch-slot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@objectstack/spec': minor
---

feat(spec): an object extension can carry a per-field display patch (`fieldPatches`), its keys derived from `BASE_FIELD_DISPLAY_WHITELIST`; not applied yet

Clause-②: yes (widening)
Metadata-protocol: added

ADR-0146 D10's second rule: a customer changes a vendor field's display properties without taking the field over, so the field's type, values and rules stay the vendor's and the vendor's next version still applies. `ObjectExtensionSchema` gains one optional key for it:

```ts
objectExtensions: [{
extend: 'crm_account',
fieldPatches: {
industry: {
label: 'Sector',
inlineHelpText: 'The market the account sells into',
options: { tech: { label: 'Technology' } },
},
},
}]
```

- **Keys.** A patch takes the field keys of `BASE_FIELD_DISPLAY_WHITELIST.field` (`label`, `description`, `inlineHelpText`, `placeholder`) plus `options`, a map from an option's stored `value` to that option's patch, which takes the keys of `BASE_FIELD_DISPLAY_WHITELIST.option` (`label`). Both sets are read from the constant: widening the whitelist widens the slot.
- **Refused at parse, each with a prescription.** Every other field key (`type`, `required`, `unique`, `reference`, `defaultValue`, `expression`, …) and every other option key (`color`, `default`, …) is refused by name, naming the whitelist and stating that it stays the vendor's (ADR-0146 D6). An option's `value` is refused: the option is addressed by it, and it cannot be renamed. A field's option list under `options` is refused as membership. A key spelled as an extension field (`region__c`) is refused with "declare it in `fields`; it is your own field".
- **Pure shape.** Whether the target object has the field or the option, and who may patch it, are the server's to judge.
- **Not enforced yet.** The slot's description reads `[EXPERIMENTAL — not enforced]`. The engine's extension registration does not carry the key, so a patch is parsed and then dropped until the registry merge applies it (ADR-0146 rollout card N-23d). Nothing reads it before then.

An extension without `fieldPatches` parses exactly as before.
4 changes: 2 additions & 2 deletions content/docs/references/api/package-api-assembled.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ Installed package row whose manifest is the assembled package body
| **functions** | `Record<string, string \| { handler?: string; effect?: Enum<'pure' \| 'writes'> }> \| { name: string; handler?: string; packageId?: string; effect?: Enum<'pure' \| 'writes'> }[]` | optional | Named handler functions, lowered to the refs a JSON document carries |
| **datasourceMapping** | `{ namespace?: string; package?: string; objectPattern?: string; default?: boolean; … }[]` | optional | Centralized datasource routing rules for packages/namespaces/objects |
| **translations** | `Record<string, { objects?: Record<string, object>; picklists?: Record<string, object>; apps?: Record<string, object>; messages?: Record<string, string>; … }>[]` | optional | I18n Translation Bundles |
| **objectExtensions** | `{ extend: string; fields?: Record<string, object>; label?: string; pluralLabel?: string; … }[]` | optional | Extensions to objects owned by other packages |
| **objectExtensions** | `{ extend: string; fields?: Record<string, object>; fieldPatches?: Record<string, object>; label?: string; … }[]` | optional | Extensions to objects owned by other packages |
| **picklists** | `{ name: string; label: string; description?: string; options: object[]; … }[]` | optional | Shared option lists that select fields reference by name |
| **picklistExtensions** | `{ extend: string; options: object[] }[]` | optional | Options added to picklists owned by other packages (additive only) |
| **apps** | `{ name: string; label: string \| Record<string, string>; description?: string \| Record<string, string>; icon?: string; … }[]` | optional | Applications |
Expand Down Expand Up @@ -362,7 +362,7 @@ This schema accepts one of the following structures:
| **functions** | `Record<string, string \| { handler?: string; effect?: Enum<'pure' \| 'writes'> }> \| { name: string; handler?: string; packageId?: string; effect?: Enum<'pure' \| 'writes'> }[]` | optional | Named handler functions, lowered to the refs a JSON document carries |
| **datasourceMapping** | `{ namespace?: string; package?: string; objectPattern?: string; default?: boolean; … }[]` | optional | Centralized datasource routing rules for packages/namespaces/objects |
| **translations** | `Record<string, { objects?: Record<string, object>; picklists?: Record<string, object>; apps?: Record<string, object>; messages?: Record<string, string>; … }>[]` | optional | I18n Translation Bundles |
| **objectExtensions** | `{ extend: string; fields?: Record<string, object>; label?: string; pluralLabel?: string; … }[]` | optional | Extensions to objects owned by other packages |
| **objectExtensions** | `{ extend: string; fields?: Record<string, object>; fieldPatches?: Record<string, object>; label?: string; … }[]` | optional | Extensions to objects owned by other packages |
| **picklists** | `{ name: string; label: string; description?: string; options: object[]; … }[]` | optional | Shared option lists that select fields reference by name |
| **picklistExtensions** | `{ extend: string; options: object[] }[]` | optional | Options added to picklists owned by other packages (additive only) |
| **apps** | `{ name: string; label: string \| Record<string, string>; description?: string \| Record<string, string>; icon?: string; … }[]` | optional | Applications |
Expand Down
11 changes: 11 additions & 0 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +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' } } } }`. |
| **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 Expand Up @@ -669,6 +670,16 @@ const result = ApiMethod.parse(data);
| **_packageVersion** | `string` | optional | Owning package version. |
| **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. |

### Nested Shape: `ObjectExtension.fieldPatches[string]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **label** | `string` | optional | Human readable label |
| **description** | `string` | optional | Tooltip/Help text |
| **inlineHelpText** | `string` | optional | Help text displayed below the field in forms |
| **placeholder** | `string` | optional | Placeholder text rendered inside the empty input (the HTML placeholder attribute); disappears once a value is entered. Distinct from `inlineHelpText` (always-visible help rendered beside/under the input) and `description` (tooltip/developer documentation). |
| **options** | `Record<string, { label?: string }>` | optional | Display patches of the base field's options, keyed by each option's stored `value` (which stays the vendor's); each entry takes the option display keys of BASE_FIELD_DISPLAY_WHITELIST. |

### Nested Shape: `ObjectExtension.indexes[number]`

| Property | Type | Required | Description |
Expand Down
10 changes: 5 additions & 5 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `data/` | 156 | 90 | 2 | 0 | 64 |
| `data/` | 158 | 92 | 2 | 0 | 64 |

## `data/` — sites

Expand Down Expand Up @@ -50,22 +50,22 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `hook-body.zod.ts` | 2 |
| `hook.zod.ts` | 8 |
| `mapping.zod.ts` | 5 |
| `object.zod.ts` | 23 |
| `object.zod.ts` | 25 |
| `picklist.zod.ts` | 3 |
| `query.zod.ts` | 5 |
| `seed-file.zod.ts` | 2 |
| `seed-loader.zod.ts` | 12 |
| `seed.zod.ts` | 2 |
| `validation.zod.ts` | 6 |
| **total** | **156** |
| **total** | **158** |

## `data/` — open

Per file, how many of its sites still silently discard unknown keys. The `Class`
column that decides the bucket split is hand-written in the ledger; the arithmetic
over it is here.

**64 strip of 156**, in 10 file(s).
**64 strip of 158**, in 10 file(s).

| File | Strip | Sites |
|---|---|---|
Expand All @@ -79,7 +79,7 @@ over it is here.
| `hook.zod.ts` | 5 | 8 |
| `query.zod.ts` | 4 | 5 |
| `seed-loader.zod.ts` | 12 | 12 |
| **total** | **64** | **156** |
| **total** | **64** | **158** |

| Bucket | Sites |
|---|---|
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,7 @@
"data/ObjectDependencyNode:references",
"data/ObjectExtension:description",
"data/ObjectExtension:extend",
"data/ObjectExtension:fieldPatches",
"data/ObjectExtension:fields",
"data/ObjectExtension:indexes",
"data/ObjectExtension:label",
Expand Down
120 changes: 119 additions & 1 deletion packages/spec/src/data/field-customization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

/**
* ADR-0146 D10 — the `__c` reservation and the base-field display-property
* whitelist, measured against `FieldSchema`.
* whitelist, measured against `FieldSchema`, and the extension slot that
* carries a patch (`ObjectExtensionSchema.fieldPatches`).
*/

import { describe, expect, it } from 'vitest';
import type { z } from 'zod';
import { BASE_FIELD_DISPLAY_WHITELIST, EXTENSION_FIELD_SUFFIX, isExtensionFieldName } from './field-customization';
import { FieldSchema, SelectOptionSchema } from './field.zod';
import { ObjectExtensionSchema } from './object.zod';

/** The object shape behind a (possibly optional) schema. */
function shapeOf(schema: unknown): Record<string, z.ZodTypeAny> {
Expand Down Expand Up @@ -100,3 +102,119 @@ describe('the base-field display-property whitelist', () => {
expect(BASE_FIELD_DISPLAY_WHITELIST.option.filter((k) => structuralOption.has(k))).toEqual([]);
});
});

// ---------------------------------------------------------------------------
// The slot that carries a patch: `ObjectExtensionSchema.fieldPatches`
// (ADR-0146 D10, second rule). Its key sets are derived from the whitelist;
// `field-patches-derived.test.ts` proves a widened whitelist widens it.
// ---------------------------------------------------------------------------

type Issue = { code: string; path: PropertyKey[]; message: string; keys?: string[] };

/** Parse an extension carrying `patch` for the base field `industry`; return the issues. */
function patchIssues(patch: unknown): Issue[] {
const r = ObjectExtensionSchema.safeParse({ extend: 'crm_account', fieldPatches: { industry: patch } });
return r.success ? [] : (r.error.issues as unknown as Issue[]);
}

describe('`ObjectExtensionSchema.fieldPatches` — the per-field display patch slot', () => {
it('accepts every field member of the whitelist, read from the constant', () => {
const patch = Object.fromEntries(BASE_FIELD_DISPLAY_WHITELIST.field.map((k) => [k, `Patched ${k}`]));
const parsed = ObjectExtensionSchema.parse({ extend: 'crm_account', fieldPatches: { industry: patch } });
expect(parsed.fieldPatches?.industry).toEqual(patch);
});

it.each(BASE_FIELD_DISPLAY_WHITELIST.field.map((k) => [k]))('accepts the field member `%s` on its own', (key) => {
expect(patchIssues({ [key]: 'Display text' })).toEqual([]);
});

it.each(BASE_FIELD_DISPLAY_WHITELIST.option.map((k) => [k]))('accepts the option member `%s`, the option keyed by its stored value', (key) => {
const parsed = ObjectExtensionSchema.parse({
extend: 'crm_account',
fieldPatches: { industry: { options: { tech: { [key]: 'Technology' } } } },
});
expect(parsed.fieldPatches?.industry?.options?.tech).toEqual({ [key]: 'Technology' });
});

it.each(STRUCTURAL_FIELD_KEYS.filter((k) => k !== 'options').map((k) => [k]))(
'refuses the structural key `%s` by name, with the D6 prescription naming the whitelist',
(key) => {
const issues = patchIssues({ label: 'Sector', [key]: true });
expect(issues).toHaveLength(1);
expect(issues[0]!.code).toBe('unrecognized_keys');
expect(issues[0]!.path).toEqual(['fieldPatches', 'industry']);
expect(issues[0]!.keys).toEqual([key]);
expect(issues[0]!.message).toContain('BASE_FIELD_DISPLAY_WHITELIST');
expect(issues[0]!.message).toContain('ADR-0146 D6');
},
);

it('refuses `options` membership: the field\'s option LIST is refused, the map keyed by stored value is the only form', () => {
const issues = patchIssues({ options: [{ label: 'Technology', value: 'tech' }] });
expect(issues).toHaveLength(1);
expect(issues[0]!.code).toBe('invalid_type');
expect(issues[0]!.path).toEqual(['fieldPatches', 'industry', 'options']);
expect(issues[0]!.message).toContain('ADR-0146 D6');
});

it('refuses every FieldSchema key the whitelist does not admit — the refused set is derived, not listed', () => {
const fieldKeys = Object.keys(shapeOf(FieldSchema));
const admitted = new Set<string>([...BASE_FIELD_DISPLAY_WHITELIST.field, 'options']);
const refused = fieldKeys.filter((k) => !admitted.has(k));
expect(refused).toEqual(expect.arrayContaining(STRUCTURAL_FIELD_KEYS.filter((k) => k !== 'options')));
for (const key of refused) {
const issues = patchIssues({ [key]: true });
expect(issues.map((i) => i.code), key).toEqual(['unrecognized_keys']);
expect(issues[0]!.message, key).toContain('BASE_FIELD_DISPLAY_WHITELIST');
}
});

it('an option patch takes `label` only: the stored `value` is refused with its own prescription', () => {
const issues = patchIssues({ options: { tech: { label: 'Technology', value: 'technology' } } });
expect(issues).toHaveLength(1);
expect(issues[0]!.code).toBe('unrecognized_keys');
expect(issues[0]!.path).toEqual(['fieldPatches', 'industry', 'options', 'tech']);
expect(issues[0]!.keys).toEqual(['value']);
expect(issues[0]!.message).toContain('stored value');
expect(issues[0]!.message).toContain('ADR-0146 D6');
});

it('an option patch refuses every other option key with the D6 prescription — derived from the option shape', () => {
const optionKeys = Object.keys(shapeOf(SelectOptionSchema));
const refused = optionKeys.filter((k) => !(BASE_FIELD_DISPLAY_WHITELIST.option as readonly string[]).includes(k) && k !== 'value');
expect(refused.length).toBeGreaterThan(0);
for (const key of refused) {
const issues = patchIssues({ options: { tech: { [key]: true } } });
expect(issues.map((i) => i.code), key).toEqual(['unrecognized_keys']);
expect(issues[0]!.message, key).toContain('BASE_FIELD_DISPLAY_WHITELIST');
}
});

it('refuses a key spelled as an extension field (`__c`): declare it in `fields`, it is your own field', () => {
const r = ObjectExtensionSchema.safeParse({ extend: 'crm_account', fieldPatches: { region__c: { label: 'Region' } } });
expect(r.success).toBe(false);
const issues = r.success ? [] : (r.error.issues as unknown as Issue[]);
expect(issues).toHaveLength(1);
expect(issues[0]!.code).toBe('custom');
expect(issues[0]!.path).toEqual(['fieldPatches', 'region__c']);
expect(issues[0]!.message).toContain('`fields`');
});

it('is pure shape: a field or option the target may not have still parses (the server judges it)', () => {
expect(patchIssues({ options: { no_such_option: { label: 'Whatever' } } })).toEqual([]);
expect(ObjectExtensionSchema.safeParse({ extend: 'crm_account', fieldPatches: { no_such_field: { label: 'X' } } }).success).toBe(true);
expect(patchIssues({})).toEqual([]);
});

it('is optional: an extension without it parses to the same output as before the slot', () => {
const parsed = ObjectExtensionSchema.parse({ extend: 'contact', fields: {} });
expect(parsed).toEqual({ extend: 'contact', fields: {}, priority: 200 });
expect('fieldPatches' in parsed).toBe(false);
});

it('reads `[EXPERIMENTAL — not enforced]` and names the merge card until the merge applies it', () => {
const description = shapeOf(ObjectExtensionSchema).fieldPatches!.description ?? '';
expect(description.startsWith('[EXPERIMENTAL — not enforced]')).toBe(true);
expect(description).toContain('N-23d');
});
});
7 changes: 4 additions & 3 deletions packages/spec/src/data/field-customization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
* {@link BASE_FIELD_DISPLAY_WHITELIST} is that whitelist: the closed constant
* the write gate reads, never a judgement it makes.
*
* Constants and one pure predicate, imports nothing: the shape that carries a
* per-field patch on an object extension, and the merge that applies it, are
* not declared here.
* Constants and one pure predicate, imports nothing. The shape that carries a
* per-field patch is `ObjectExtensionSchema.fieldPatches`
* (`data/object.zod.ts`), its keys derived from the whitelist below; the merge
* that applies it is not declared in `packages/spec`.
*/

/**
Expand Down
Loading
Loading