Skip to content
Open
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
22 changes: 22 additions & 0 deletions .changeset/33216-extension-lookup-hop-and-rule-artifacts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/lint': minor
'@objectstack/spec': minor
---

fix(lint): `os build` and the runtime `object_extension` publish door judge the one-hop leaf through a lookup an object extension adds, and the regex and JSON-schema checks of each validation rule an extension adds, as they judge an object's own

Clause-②: no (narrowing) — `os build`, `os validate`, `os lint` and the `object_extension` publish door (Studio, `PUT /api/v1/meta/object_extension/TARGET`, the MCP and AI authoring doors) now refuse two shapes an extension could carry before: a validation rule or formula reading `record.REF.LEAF` through a lookup an extension adds where the lookup's target serves no `LEAF`, and a `format` / `json_schema` validation rule an extension adds whose regex or schema does not compile or names an unregistered format. No exported type or signature changes. Measured first-party: 0 `objectExtensions` entries add a lookup or a validation rule (`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 rule-table row now marks the extension door, and the object extension paragraph names both checks); `skills/objectstack-data` teaches one-hop reads, `format` / `json_schema` rules and `defineObjectExtension({ extend, fields })` without saying an extension is exempt from either check, so the build and the door now give the verdict the object's own declarations already get.

<!-- adr-0087: registered object-extension-added-lookup-hop-leaf-judged object-extension-validation-rule-artifacts-judged -->

**BREAKING** accept-set narrowing of `os build`, `os validate`, `os lint` and the runtime `object_extension` publish door.

**Before:** two checks the object's own declarations get skipped what an extension adds. The expression rule judged the leaf of a one-hop read (`record.REF.LEAF`) only when `REF` was declared on the object, so through a lookup an extension adds (`manager__c`) a misspelt leaf (`record.manager__c.daly_hours`) was accepted: a formula read null on every row, and a validation rule had no verdict and refused every save. And the regex and JSON-schema checks walked an object's own `validations` only, so an extension's `format` rule with a regex `new RegExp` throws on, or a `json_schema` rule whose schema ajv cannot compile or that names an unregistered format, was accepted and then skipped (or the format dropped) on every write: the rule protected nothing.

**Now:** the hop is judged on the object as served, a lookup an extension adds included, with the verdict a hop through a declared lookup gets (`expression-invalid`), located where the rule or formula is written (`object 'TARGET' · objectExtensions[0] · validation rule 'R' then → 'S'`, or `· field 'F' expression`); a hop through an added lookup into a stored-metadata-body table is refused as through a declared one. Each rule an extension adds, a nested `then` / `otherwise` branch included, gets the regex and JSON-schema checks with the same rule ids (`validation-rule-regex-uncompilable`, `validation-rule-json-schema-uncompilable`, `validation-rule-json-schema-unknown-format`), located at the entry: `object 'TARGET' · objectExtensions[0] · validation 'RULE'`, key path `objectExtensions[0].validations.RULE.regex` (or `.schema`). Both run at the `object_extension` door (`422 INVALID_METADATA`) as in `os build`.

**Unchanged:** the object save door and an object's own rules, formulas and hops, at the same locations; a draft save (never gated); a stored row (judged only when it is published again). A lookup two packages add at the same priority is not judged, since which definition the registry serves is registration order. At the extension door the target's own rules the door's live objects carry are judged in the gate's baseline too, so a broken rule of the target is not charged to the extension write.

**Fix:** before building or publishing again, read a field the lookup's target serves through the added lookup, and give each extension rule a regex that compiles (or a named `format`) and a schema ajv compiles, naming only registered formats, as you would on a rule declared on the same object.
9 changes: 7 additions & 2 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ orthogonal to both, and no cell here can carry it; it is written out in
| Security posture (ADR-0090 — e.g. every custom object declares `sharingModel`) | ✓ | ✓ | ✓ | ✓ˢᵖᵉᵇᵒ |
| Security vocabulary freeze (ADR-0090 D3 — the reserved word, replaced by `permission_set` / `position` / `business_unit`) | ✓ | ✓ | ✓ | ✓ᵒᵖᵉᵇᵖᵒᵃᵒˣ |
| Organization-axis red lines (ADR-0105 D6) | ✓ | ✓ | ✓ | — |
| Declared enforcement that cannot run, **declared on the object being written** — a validation rule's regex / JSON Schema (#4762) and its `format` names (#5178) | ✓ | ✓ | ✓ | ✓ᵒ |
| Declared enforcement that cannot run, **declared on the object being written** — a validation rule's regex / JSON Schema (#4762) and its `format` names (#5178), and (#33217) those of a rule an object extension adds | ✓ | ✓ | ✓ | ✓ᵒᵒˣ |
| Declared enforcement that cannot run, **declared on another collection** — sharing-rule conditions (#4698) | ✓ | ✓ | ✓ | — |
| Row-level-security predicates (#4983) — a permission set's policies, an object's `defaultRowScope`, a membership key's `filter` and a field permission entry's `when` (#25866); at the save door also a membership key nothing owns (#25234) | ✓ | ✓ | ✓ | ✓ᵖᵉᵒ |
| Membership key declarations — `userField` / `valueField` columns, one key on one object, a key the registered resolver also owns (save door only) (#25234) | ✓ | ✓ | ✓ | ✓ᵒ |
Expand Down Expand Up @@ -613,7 +613,12 @@ the write replaces, and refuses with the build's rule id (`expression-invalid`).
Each validation rule the entry adds is judged the same way (#33154), by the
object's own rule pass, at the door and in `os build` alike: the registry merges
an extension's `validations` onto the target, so every save of the target runs
them (`object 'TARGET' · objectExtensions[0] · validation 'RULE'`).
them (`object 'TARGET' · objectExtensions[0] · validation 'RULE'`). A one-hop
read through a lookup the entry adds (`record.manager__c.LEAF`) is judged as
one through a lookup the target declares (#33216), and a `format` rule's regex
and a `json_schema` rule's schema and format names are checked as on the
object's own rules (#33217), with the same rule ids, at the door and in
`os build` alike.

**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
14 changes: 12 additions & 2 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2732,8 +2732,16 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// The load it does take (~64 ms cold once, ~15 ms warm per publish
// carrying such a rule) is the measured, adjudicated price of refusing a
// validation rule that would otherwise ship compiled-by-nothing.
//
// [#33217] `object_extension` joins: the walk reads the `validations[]`
// an extension adds (the registry merges them onto the target, and every
// save runs them), so a regex or schema the build refuses on an extension
// is refused at that publish door too, same rule id, located at the entry
// (`object 'TARGET' · objectExtensions[0] · validation 'N'`). The target's
// own rules the door's live objects carry are judged in the baseline as
// well, so the differential charges the write with its entry's alone.
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['object'],
runtimeTypes: ['object', 'object_extension'],
run: (stack) => validateRuleCompilability(stack),
},
// #5178 — the residual half of #5029, which registering `ajv-formats` does
Expand All @@ -2758,8 +2766,10 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// the two judgements over one artifact stay on one side of the wall
// (#7220's family discipline). Same lazy-ajv contract: the registered
// format set is only enumerated once a schema actually names a format.
// [#33217] And it crosses on `object_extension` with its sibling, for the
// same reason: the shared walk reads the rules an extension adds.
surfaces: CLI_AND_RUNTIME,
runtimeTypes: ['object'],
runtimeTypes: ['object', 'object_extension'],
run: (stack) => validateRuleSchemaFormats(stack),
},
// [#25189] The email half of #25082 (ruling `6335348115`, F1 A2): a hole an
Expand Down
134 changes: 132 additions & 2 deletions packages/lint/src/runtime-gate.object-formula-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,8 +362,9 @@ describe('#33072 — the object_extension door judges an extension field\'s form
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', () => {
// [#33217] And the two validation-rule artifact checks (regex, JSON schema).
expect(runtimeAuthoringRulesFor('object_extension').map((r) => r.name))
.toEqual(['validateStackExpressions', 'validateSecurityRoleWord']);
.toEqual(['validateStackExpressions', 'validateSecurityRoleWord', 'validateRuleCompilability', 'validateRuleSchemaFormats']);
});

it('⭐ LIT — an extension formula calling an unregistered function (`sqrt`) is REFUSED, at the entry the build names', () => {
Expand Down Expand Up @@ -421,7 +422,7 @@ describe('#33072 — the object_extension door judges an extension field\'s form
const result = gateExtension(item);
expect(result.errors, dump(result)).toEqual([]);
expect(result.advisories, dump(result)).toEqual([]);
expect(result.rulesRun).toEqual(['validateStackExpressions', 'validateSecurityRoleWord']);
expect(result.rulesRun).toEqual(['validateStackExpressions', 'validateSecurityRoleWord', 'validateRuleCompilability', 'validateRuleSchemaFormats']);
}
});

Expand Down Expand Up @@ -598,3 +599,132 @@ describe('#33154 — an extension\'s validation rules are judged at the build an
expect(expressionFindings(result.errors).map((f) => f.where)).toEqual(["object 'fx_ticket' · validation 'own_typo'"]);
});
});

describe('#33216 — the one-hop leaf through a lookup an extension adds is judged as one through a declared lookup', () => {
/** The extended object, as the door's live universe carries it: one declared lookup. */
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' } },
};
/** The lookup the extension adds, to the same target as the declared one. */
const MANAGER = { manager__c: { type: 'lookup', label: 'Manager', reference: 'fx_staff' } };
/** A rule reading `record.REF.LEAF`, gated on the reference as the traversal check prescribes. */
const hopRule = (ref: string, leaf: string) => ({
name: 'long_day', type: 'conditional', message: 'Refused.',
when: `record.${ref} != null`,
then: { name: 'hours', type: 'script', condition: `record.${ref}.${leaf} > 12`, message: 'Refused.' },
});
const ruleExtension = (ref: string, leaf: string) =>
({ extend: 'fx_ticket', fields: { ...MANAGER }, validations: [hopRule(ref, leaf)] });
const formulaExtension = (ref: string, leaf: string) => ({
extend: 'fx_ticket',
fields: { ...MANAGER, hours__c: { type: 'formula', label: 'Hours', expression: `record.${ref}.${leaf}` } },
});
const RULE_WHERE = "object 'fx_ticket' · objectExtensions[0] · validation rule 'long_day' then → 'hours'";
const FORMULA_WHERE = "object 'fx_ticket' · objectExtensions[0] · field 'hours__c' expression";
const gateExtension = (item: unknown, objects: unknown[] = [target, staff]) =>
runRuntimeAuthoringRules({ type: 'object_extension', item, context: { objects } });
const buildFindings = (stack: Record<string, unknown>) =>
runAuthoringRules('build', { normalized: stack, parsed: stack }).filter((f) => f.where.includes('objectExtensions'));
/** The verdict with the reference's name taken out, so a declared and an added lookup compare. */
const anonymous = (message: string, ref: string) => message.split(ref).join('REF');

it('⭐ LIT — a RULE reading a misspelt leaf through the added lookup is REFUSED at the door and the build, with the declared-lookup verdict', () => {
const item = ruleExtension('manager__c', 'daly_hours');
const atDoor = gateExtension(item);
const errs = expressionFindings(atDoor.errors);
expect(errs, dump(atDoor)).toHaveLength(1);
expect(errs[0]).toMatchObject({
severity: 'error',
where: RULE_WHERE,
path: 'objectExtensions[0].validations[0].then.condition',
});
expect(errs[0]!.message).toContain('daly_hours');
expect(errs[0]!.message).toContain('fx_staff');

// The build gives the same finding, byte for byte.
expect(buildFindings({ objects: [target, staff], objectExtensions: [item] })).toEqual([...atDoor.errors, ...atDoor.advisories]);

// The same misspelling through the lookup the target DECLARES gets the same verdict.
const declared = expressionFindings(gateExtension(ruleExtension('employee', 'daly_hours')).errors);
expect(declared, dump(declared)).toHaveLength(1);
expect(anonymous(errs[0]!.message, 'manager__c')).toBe(anonymous(declared[0]!.message, 'employee'));
});

it('⭐ LIT — a FORMULA an extension adds reading a misspelt leaf through the added lookup is REFUSED at the door and the build', () => {
const item = formulaExtension('manager__c', 'daly_hours');
const atDoor = gateExtension(item);
const errs = expressionFindings(atDoor.errors);
expect(errs, dump(atDoor)).toHaveLength(1);
expect(errs[0]).toMatchObject({ severity: 'error', where: FORMULA_WHERE });
expect(errs[0]!.message).toContain('daly_hours');
expect(buildFindings({ objects: [target, staff], objectExtensions: [item] })).toEqual([...atDoor.errors, ...atDoor.advisories]);

const declared = expressionFindings(gateExtension(formulaExtension('employee', 'daly_hours')).errors);
expect(declared, dump(declared)).toHaveLength(1);
expect(anonymous(errs[0]!.message, 'manager__c')).toBe(anonymous(declared[0]!.message, 'employee'));
});

it('⭐ LIT — the object\'s OWN formula reading through a lookup a package\'s extension adds is refused at the build, at the object', () => {
const own = { ...target, fields: { ...target.fields, hours: { type: 'formula', label: 'Hours', expression: 'record.manager__c.daly_hours' } } };
const stack = { objects: [own, staff], objectExtensions: [{ extend: 'fx_ticket', fields: { ...MANAGER } }] };
const found = validateStackExpressions(stack).filter((i) => i.severity === 'error');
expect(found.map((i) => i.where), dump(found)).toEqual(["object 'fx_ticket' · field 'hours' expression"]);
expect(found[0]!.message).toContain('daly_hours');

// CONTROL — the leaf the target declares.
const ok = { ...own, fields: { ...own.fields, hours: { type: 'formula', label: 'Hours', expression: 'record.manager__c.daily_hours' } } };
expect(validateStackExpressions({ ...stack, objects: [ok, staff] }).filter((i) => i.severity === 'error')).toEqual([]);
});

it('⭐ LIT — a hop through an added lookup into a stored-metadata-body table is refused as through a declared one', () => {
const item = {
extend: 'fx_ticket',
fields: {
meta__c: { type: 'lookup', label: 'Meta', reference: 'sys_metadata' },
body__c: { type: 'formula', label: 'Body', expression: 'record.meta__c.metadata' },
},
};
const found = buildFindings({ objects: [target, staff], objectExtensions: [item] });
expect(found.map((f) => f.where), dump(found)).toEqual(["object 'fx_ticket' · objectExtensions[0] · field 'body__c' expression"]);
expect(found[0]!.message).toContain('sys_metadata');
});

it('⭐ CONTROL — the correct leaf through the added lookup is accepted at the door and the build, rule and formula alike', () => {
for (const item of [ruleExtension('manager__c', 'daily_hours'), formulaExtension('manager__c', 'daily_hours')]) {
const result = gateExtension(item);
expect(expressionFindings(result.errors), dump(result)).toEqual([]);
expect(buildFindings({ objects: [target, staff], objectExtensions: [item] }).filter((f) => f.severity === 'error'), dump(item)).toEqual([]);
}
});

it('a lookup two packages add at the same priority is left unjudged: which definition is served is registration order', () => {
const twin = { extend: 'fx_ticket', fields: { ...MANAGER } };
const stack = {
objects: [target, staff],
packages: [
{ manifest: { objects: [target, staff] } },
{ manifest: { objectExtensions: [twin] } },
{ manifest: { objectExtensions: [ruleExtension('manager__c', 'daly_hours')] } },
],
};
expect(buildFindings(stack).filter((f) => f.message.includes('daly_hours')), dump(buildFindings(stack))).toEqual([]);
});

it('CONTROL — the object\'s own hop through a declared lookup is unchanged, at the object save door and the build', () => {
const own = { ...target, validations: [hopRule('employee', 'daly_hours')] };
const atDoor = expressionFindings(runRuntimeAuthoringRules({ type: 'object', item: own, context: { objects: [staff] } }).errors);
expect(atDoor.map((f) => f.where), dump(atDoor)).toEqual(["object 'fx_ticket' · validation rule 'long_day' then → 'hours'"]);
expect(validateStackExpressions({ objects: [own, staff] }).filter((i) => i.severity === 'error').map((i) => i.where))
.toEqual(["object 'fx_ticket' · validation rule 'long_day' then → 'hours'"]);
});
});
Loading
Loading