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
25 changes: 25 additions & 0 deletions .changeset/33072-object-extension-door-formula.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/lint': minor
'@objectstack/spec': minor
'@objectstack/metadata-protocol': patch
---

fix(lint): the runtime `object_extension` publish door judges a computed field an extension adds by the build's field-formula pass, against the extended object as served

Clause-②: no (narrowing) — the `object_extension` publish door (Studio's add-field on a `.custom` pair, `PUT /api/v1/meta/object_extension/TARGET`, the MCP and AI authoring doors) now refuses an extension whose computed field carries an `expression` `os build` refuses. No exported type or signature changes. Measured first-party: 0 extension entries with a computed field (`examples/app-showcase` one entry, three plain fields; `packages/plugins/plugin-auth` one, a `secret` field; HotCRM none), so nothing first-party is newly refused.
Metadata-protocol: narrowed
Skills-docs: none owed — this PR edits `content/docs/deployment/validating-metadata.mdx` (the CEL row's runtime column gains `ᵒˣ`, and the object extension door paragraph names the formula pass); `skills/objectstack-data` teaches `defineObjectExtension({ extend, fields })` and the computed-field rules without saying the runtime door is exempt, so the door now gives the verdict the build already gives.

<!-- adr-0087: registered object-extension-door-computed-field-expression-judged -->

**BREAKING** accept-set narrowing of the runtime `object_extension` publish door (`validateStackExpressions` joins its `runtimeTypes`).

`os build` and `os validate` judge each field an `objectExtensions` entry adds with the field-formula pass, but the publish door that writes an extension at runtime ran only the vocabulary freeze, so a computed field the build refuses published with a 200 and then read null, or failed, on every row.

- On an `object_extension` write the expression rule runs exactly one pass: the extension walk over each field the written entry adds, against the door's live objects with the entry folded in (the build's own fold), located as the build locates it for a package with one extension entry: `object 'crm_account' · objectExtensions[0] · field 'score__c' expression` (the door judges the written entry alone, so its index is always `[0]`; a build of a package with several entries names the entry's own index). A refusal is the gate's `422 INVALID_METADATA` with issue rule `expression-invalid`; a stored field that reads another record, the clock or the acting user is an advisory (`stored-expression-cross-record-input`, `stored-expression-volatile-input`), never a refusal.
- A republish is judged against the target without the package's own stored extension, which the write replaces, as the build sees the package's one entry: dropping a field the extension's own formula still reads is refused, and a formula over a lookup the same write re-targets is judged over the new target.
- An extension of an object the door does not carry is judged on what does not depend on that object's fields (grammar, registered functions, bare and unbound references).
- A package-wide draft publish (`publishPackageDrafts`) whose drafts depend on a sibling extension draft is refused, because that batch's pending set carries no extension drafts (#33152); publishing the extension first through the per-item door works.
- Unchanged: the object save door, `os build`, `os validate`, a draft save (never gated), and a stored row, which is judged only when it is published again.

Migration: before publishing an extension again, fix each refused `expression` the way you would on a declared field of the same object: read `record.FIELD` for a field the object serves, call only registered functions, and make a stored field that reads another record, the clock or the acting user a `formula` field.
7 changes: 5 additions & 2 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ orthogonal to both, and no cell here can carry it; it is written out in
| | `os validate` | `os build` | `os lint` | runtime publish |
|---|---|---|---|---|
| Protocol schema (Zod) | ✓ | ✓ | — | ✓ |
| CEL / predicate validation (ADR-0032) | ✓ | ✓ | ✓ | ✓ᶠᵃᶜʰᵒᵛᵖ |
| CEL / predicate validation (ADR-0032) | ✓ | ✓ | ✓ | ✓ᶠᵃᶜʰᵒˣᵒᵛᵖ |
| List-view navigation modes (ADR-0053) | ✓ | ✓ | ✓ | — |
| Zod-valid but functionally inert declarations — a `summary` with no operations (ADR-0078), a managed object advertising an API method its affordances refuse (#7521) | ✓ | ✓ | ✓ | ✓ᵒ |
| Retired object title template — an object that declares `titleFormat`, retired by ADR-0079 for `nameField` (`title-format-retired`, #29007) | ✓ | ✓ | ✓ | ✓ᵒ |
Expand Down Expand Up @@ -606,7 +606,10 @@ located at the entry (`objectExtensions[0].fields.FIELD.name`). The
`object_extension` publish door (Studio's add-field on a customization package,
`/api/v1/meta`) runs the same rule. A field an extension adds to a `sys_*`
object is judged too: the system-object exemption covers the platform's own
declarations, not words an app adds.
declarations, not words an app adds. The same door runs the build's
field-formula pass on each computed field the written entry adds (#33072),
against the target as served without the package's own stored extension, which
the write replaces, and refuses with the build's rule id (`expression-invalid`).

**Declared enforcement that cannot run** is two rows for the mirror-image
reason, and #4716 is where it split. That heading covers four rule ids, and
Expand Down
19 changes: 18 additions & 1 deletion packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -821,8 +821,25 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// shipped corpus at `6021b5e837`: 0 findings across examples/**,
// platform-objects and plugin-approvals, against one for a planted unbound
// hole; #25709 re-measured 0 sentences of the holeless `${…}` shape there.
//
// [#33072] `object_extension` joins, for ONE pass: the field-formula pass
// over each field the written extension adds — the extension walk #32875
// gave `os build` / `os validate`, which refuses a computed field there
// that this door published with a 200 (Studio's add-field on a `.custom`
// pair and the `/meta` doors write the item). On an extension write
// `runStackExpressionPasses` runs that walk alone, by construction, over
// the written entry (the sole member of `objectExtensions`) against the
// door's live objects as served, so a refusal carries the build's rule id
// and location (`object 'TARGET' · objectExtensions[0] · field 'F'
// expression`). MEASURED first: 0 first-party `objectExtensions` entries
// carry a computed field (examples/**, packages/**, HotCRM), so the
// crossing refuses nothing the repository ships. A package-wide draft
// publish (`publishPackageDrafts`) whose drafts depend on a sibling
// extension draft is refused, because that batch's pending set carries no
// extension drafts (#33152); publishing the extension first through the
// per-item door works.
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['flow', 'action', 'hook', 'object', 'view', 'page'],
runtimeTypes: ['flow', 'action', 'hook', 'object', 'view', 'page', 'object_extension'],
run: (stack, ctx) =>
runStackExpressionPasses(stack, { runtimeWriteType: ctx.runtimeWriteType }).map((i) => ({
severity: i.severity ?? 'error',
Expand Down
116 changes: 116 additions & 0 deletions packages/lint/src/runtime-gate.object-formula-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,3 +336,119 @@ describe('#32529 / #32554 — the object door judges a stored expression\'s inpu
expect(atDoor).toEqual(atBuild);
});
});

describe('#33072 — the object_extension door judges an extension field\'s formula as the build does', () => {
/** The extended object, as the door's live universe carries it. */
const target = {
name: 'fx_ticket',
label: 'Ticket',
sharingModel: 'private',
fields: {
employee: { type: 'lookup', label: 'Employee', reference: 'fx_staff' },
amount: { type: 'number', label: 'Amount' },
},
};
const staff = {
name: 'fx_staff', label: 'Staff', sharingModel: 'private',
fields: { daily_hours: { type: 'number', label: 'Daily Hours' } },
};
/** One extension of `fx_ticket` adding the given fields. */
const extension = (fields: Record<string, unknown>) => ({ extend: 'fx_ticket', fields });
const scoreOf = (expression: string) => extension({ score: { type: 'formula', label: 'Score', expression } });
const EXT_WHERE = (field: string) => `object 'fx_ticket' · objectExtensions[0] · field '${field}' expression`;
const gateExtension = (item: unknown, objects: unknown[] = [target, staff]) =>
runRuntimeAuthoringRules({ type: 'object_extension', item, context: { objects } });
const ofRule = <T extends { rule: string }>(rule: string, fs: readonly T[]): T[] => fs.filter((f) => f.rule === rule);

it('`validateStackExpressions` is on the object_extension door, beside the vocabulary freeze', () => {
expect(runtimeAuthoringRulesFor('object_extension').map((r) => r.name))
.toEqual(['validateStackExpressions', 'validateSecurityRoleWord']);
});

it('⭐ LIT — an extension formula calling an unregistered function (`sqrt`) is REFUSED, at the entry the build names', () => {
const result = gateExtension(scoreOf('sqrt(record.amount)'));

expect(result.rulesRun).toContain('validateStackExpressions');
const errs = expressionFindings(result.errors);
expect(errs, dump(result)).toHaveLength(1);
expect(errs[0]).toMatchObject({ severity: 'error', where: EXT_WHERE('score'), path: EXT_WHERE('score') });
});

it('⭐ LIT — an extension formula reading a field the extended object does not serve is REFUSED', () => {
const result = gateExtension(scoreOf('record.amont * 2'));

const errs = expressionFindings(result.errors);
expect(errs, dump(result)).toHaveLength(1);
expect(errs[0]!.where).toBe(EXT_WHERE('score'));
expect(errs[0]!.message).toContain('amont');
});

it('⭐ LIT — a stored extension field reading another record is an ADVISORY, never a 422', () => {
const result = gateExtension(extension({
hours: { type: 'number', label: 'Hours', expression: 'record.amount * record.employee.daily_hours' },
}));

const advised = ofRule(STORED_EXPRESSION_CROSS_RECORD_INPUT, result.advisories);
expect(advised, dump(result)).toHaveLength(1);
expect(advised[0]).toMatchObject({ severity: 'warning', where: EXT_WHERE('hours') });
expect(result.errors, dump(result)).toEqual([]);
});

it('⭐ PARITY — on one shared input, the door finding IS the build finding', () => {
for (const item of [
scoreOf('sqrt(record.amount)'),
extension({ hours: { type: 'number', label: 'Hours', expression: 'record.amount * record.employee.daily_hours' } }),
]) {
const atDoor = gateExtension(item);
const stack = { objects: [target, staff], objectExtensions: [item] };
const atBuild = runAuthoringRules('build', { normalized: stack, parsed: stack })
.filter((f) => f.where.includes('objectExtensions'));

expect(atBuild.length, dump(atBuild)).toBe(1);
expect([...atDoor.errors, ...atDoor.advisories]).toEqual(atBuild);
}
});

it('⭐ CONTROL — a valid extension formula, and one reading a field the same extension adds, publish clean', () => {
for (const item of [
scoreOf('floor(record.amount)'),
extension({
bonus: { type: 'number', label: 'Bonus' },
total: { type: 'formula', label: 'Total', expression: 'record.amount + record.bonus' },
}),
]) {
const result = gateExtension(item);
expect(result.errors, dump(result)).toEqual([]);
expect(result.advisories, dump(result)).toEqual([]);
expect(result.rulesRun).toEqual(['validateStackExpressions', 'validateSecurityRoleWord']);
}
});

it('an extension of an object the door does not carry is judged on what does not depend on its fields', () => {
expect(expressionFindings(gateExtension(scoreOf('sqrt(record.amount)'), []).errors)).toHaveLength(1);
const read = gateExtension(scoreOf('record.amount * 2'), []);
expect(read.errors, dump(read)).toEqual([]);
});

it('a stored object\'s own broken formula is not the extension write\'s to answer for (the differential)', () => {
const broken = { ...target, fields: { ...target.fields, bad: { type: 'formula', label: 'Bad', expression: 'sqrt(record.amount)' } } };
const result = gateExtension(scoreOf('floor(record.amount)'), [broken, staff]);

expect(result.errors, dump(result)).toEqual([]);
});

it('on an extension write only the extension walk judges: no pass over the objects the door carries', () => {
const broken = { ...target, fields: { ...target.fields, bad: { type: 'formula', label: 'Bad', expression: 'sqrt(record.amount)' } } };
const stack = { objects: [broken, staff], objectExtensions: [scoreOf('floor(record.amount)')] };
// The build judges the declared formula…
expect(validateStackExpressions(stack).map((i) => i.where)).toEqual(["object 'fx_ticket' · field 'bad' expression"]);
// …the extension door's pass reads only the written entry.
expect(runStackExpressionPasses(stack, { runtimeWriteType: 'object_extension' })).toEqual([]);
});

it('CONTROL — the object save door is unchanged: it judges the object\'s own formula and no extension field', () => {
const result = gateObject(fxSqrt('sqrt(record.amount)'));
expect(expressionFindings(result.errors).map((f) => f.where)).toEqual([WHERE]);
expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).toContain('validateStackExpressions');
});
});
5 changes: 5 additions & 0 deletions packages/lint/src/runtime-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,11 @@ const TYPE_TO_STACK_KEY: Readonly<Record<string, string>> = {
// written word on its own, and a write's own collection holds exactly one
// member — its own item (an extension carries `extend` and no `name`, so it is
// never name-keyed).
//
// [#33072] `validateStackExpressions` crosses with this row too, for one
// pass: the extension walk over the written entry's fields. It resolves
// `record.FIELD` into `objects`, which every snapshot already carries, so the
// row still needs no context collection of its own.
object_extension: 'objectExtensions',
};

Expand Down
35 changes: 33 additions & 2 deletions packages/lint/src/validate-expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2262,6 +2262,19 @@ export interface StackExpressionOptions {
* write. None of them is the object's own body, the object write's snapshot
* does not carry them, and each is judged at its own type's door; the claim
* holds by construction rather than by the snapshot's shape.
*
* [#33072] On an `object_extension` write exactly ONE pass judges: the
* field-formula pass over each field the written entry adds (#32875's
* extension walk, `judgeFieldFormula`), against the extended object as
* served — the door's live objects with the entry folded in by
* `objectsAsServed`, the build's own fold — and located at the entry, as the
* build locates it. The object walk reads an empty list (the carried
* objects are the universe the field resolves in, not the subject) and so
* does every other pass; the function returns after the extension walk.
* A package-wide draft publish (`publishPackageDrafts`) whose drafts depend
* on a sibling extension draft is refused, because that batch's pending set
* carries no extension drafts (#33152); publishing the extension first
* through the per-item door works.
*/
runtimeWriteType?: string;
/**
Expand Down Expand Up @@ -2321,6 +2334,13 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
// an empty list under it, so the claim holds by construction rather than by
// the shape of the snapshot the gate happens to build today.
const objectWrite = options.runtimeWriteType === 'object';
// [#33072] On an `object_extension` write exactly ONE pass judges: the
// extension-field walk below (#32875), over the written entry, against the
// extended object as served. Every other loop reads an empty list under it
// (the object walk and the flows included), and the function returns right
// after that walk, so the claim holds by construction. See
// {@link StackExpressionOptions.runtimeWriteType}.
const extensionWrite = options.runtimeWriteType === 'object_extension';
// [#32872] See {@link StackExpressionOptions.sharedValidatorRunByCaller}.
const validatorRunByCaller = options.sharedValidatorRunByCaller === true;
// [#24798] On a `view` write exactly ONE pass judges — the bulk `confirmText`
Expand Down Expand Up @@ -2827,7 +2847,7 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
const runtimeGateWrite = options.runtimeWriteType !== undefined;
const flowEntrances: ReadonlyMap<string, FlowCelEntrance> =
objectWrite || runtimeGateWrite ? new Map() : flowCelEntrances(stack, fieldIndex);
for (const flow of objectWrite ? [] : recordsOf(stack.flows)) {
for (const flow of objectWrite || extensionWrite ? [] : recordsOf(stack.flows)) {
const flowName = typeof flow.name === 'string' ? flow.name : '(unnamed flow)';
// [#26858] The flow's index in the authored list, for a finding's key path
// (`recordsOf` hands an array's own members back). A map-form `flows` has
Expand Down Expand Up @@ -3527,7 +3547,9 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
// [#32529] The object's position in `objects`, which is its position in
// `typedObjects` too (the served body the stored-expression member reads).
let objectPosition = -1;
for (const obj of objects) {
// [#33072] Fenced on an extension write: the objects the door carries are
// the universe the extension field is judged against, not its subject.
for (const obj of extensionWrite ? [] : objects) {
objectPosition += 1;
const objectName = typeof obj.name === 'string' ? obj.name : undefined;
// [#27643] The object's index in the authored list, for a validation-rule
Expand Down Expand Up @@ -4003,6 +4025,12 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
//
// Fenced on an object write: an extension is not the written object's
// body, and an object write's snapshot carries none.
//
// [#33072] The `object_extension` publish door runs this walk and nothing
// else: its snapshot carries the written entry as the sole member of
// `objectExtensions` and the door's live objects as `objects`, so the field
// is judged against its target as served and located where the build
// locates it (`objectExtensions[0]` for a one-entry stack).
for (const [target, entries] of objectWrite ? [] : objectExtensionsByTarget(stack)) {
// The target's position in `objects`, which is its position in
// `typedObjects` too (see `objectPosition` above); -1 when undeclared.
Expand Down Expand Up @@ -4044,6 +4072,9 @@ export function runStackExpressionPasses(stack: AnyRec, options: StackExpression
}
}

// [#33072] An extension write's one pass ends here.
if (extensionWrite) return issues;

// ── Action `visible` / `disabled` / `pressed` predicates ────────────
// Record-scoped, same as validation rules: a record-header / row action's
// `visible` is evaluated by ActionEngine against `{ record, recordId,
Expand Down
Loading
Loading