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
7 changes: 7 additions & 0 deletions .changeset/injected-provenance-into-spec.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
---
'@objectstack/spec': minor
'@objectstack/metadata-core': patch
'@objectstack/lint': minor
---

Author-time warning for unprovisioned injected anchors on external objects (#8116). The injected-system-column definition tables and the #7865 provenance derivation (`platformProvisionsStorage`, `resolveInjectedColumnProvenance`, `unprovisionedInjectedColumns`, plus the newly exported identity predicate `isInjectedColumnDefinition`) moved from `@objectstack/metadata-core` into `@objectstack/spec/data`; `@objectstack/metadata-core` re-exports every previously-public name unchanged, so no downstream import changes. Built on the spec export, `@objectstack/lint` now warns when an expression, field conditional rule, formula, `stageField` or `highlightFields` entry references an injected system column (`owner_id`, `organization_id`, the audit family, `owning_business_unit_id`) on an ADR-0015 `external` object: the platform registers the anchor but provisions no storage behind it, so the reference silently degrades at query time (on SQLite: constant-false, HTTP 200, zero rows, no error). New advisory rule id `semantic-role-field-unprovisioned`; the expression finding is warning-severity and never fails the build. An author-declared column of the same name is treated as the author's real remote column and never warned.
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -162,6 +162,7 @@ export {
FIELD_GROUP_EMPTY,
FIELD_GROUP_SHADOWED,
SEMANTIC_ROLE_FIELD_UNKNOWN,
SEMANTIC_ROLE_FIELD_UNPROVISIONED,
} from './validate-semantic-roles.js';
export type { SemanticRoleFinding, SemanticRoleSeverity } from './validate-semantic-roles.js';

Expand Down
34 changes: 32 additions & 2 deletions packages/lint/src/system-fields.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { FIELD_GROUP_SYSTEM_FIELDS } from '@objectstack/spec/data';
import { FIELD_GROUP_SYSTEM_FIELDS, unprovisionedInjectedColumns } from '@objectstack/spec/data';
import { SystemFieldName } from '@objectstack/spec/system';
import { SYSTEM_FIELDS } from './system-fields.js';
import { SYSTEM_FIELDS, unprovisionedInjectedColumnsFor } from './system-fields.js';

describe('SYSTEM_FIELDS (#4330)', () => {
it('contains every member of both spec declarations — the derivation is complete', () => {
Expand DownExpand Up@@ -37,3 +37,33 @@ describe('SYSTEM_FIELDS (#4330)', () => {
}
});
});

describe('unprovisionedInjectedColumnsFor (#8116)', () => {
const external = {
name: 'ext_customer',
external: { remoteName: 'customers' },
fields: { email: { type: 'text', label: 'Email' } },
};

it('is the spec derivation verbatim — never a hand-copied predicate', () => {
// Delegation pin: the set is exactly what `@objectstack/spec/data` answers,
// so the author-time warning and the runtime guards cannot disagree.
expect([...unprovisionedInjectedColumnsFor(external)].sort()).toEqual(
unprovisionedInjectedColumns(external).sort(),
);
});

it('is non-empty only for external objects, and excludes author-declared columns', () => {
expect(unprovisionedInjectedColumnsFor(external).has('owner_id')).toBe(true);
expect(unprovisionedInjectedColumnsFor(external).has('organization_id')).toBe(true);
// Local twin: platform storage is real.
expect(unprovisionedInjectedColumnsFor({ name: 'customer', fields: {} }).size).toBe(0);
// #7859's security direction: a declared organization_id maps a real
// remote column the author vouches for — never in the set.
const declaredReal = {
...external,
fields: { ...external.fields, organization_id: { type: 'text', label: 'Remote Org Key' } },
};
expect(unprovisionedInjectedColumnsFor(declaredReal).has('organization_id')).toBe(false);
});
});
33 changes: 32 additions & 1 deletion packages/lint/src/system-fields.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,11 @@
* genuinely does not have.
*/

import { FIELD_GROUP_SYSTEM_FIELDS, resolveInjectedSystemColumns } from '@objectstack/spec/data';
import {
FIELD_GROUP_SYSTEM_FIELDS,
resolveInjectedSystemColumns,
unprovisionedInjectedColumns,
} from '@objectstack/spec/data';
import { SystemFieldName } from '@objectstack/spec/system';

/**
Expand DownExpand Up@@ -69,3 +73,30 @@ export const SYSTEM_FIELDS: ReadonlySet<string> = new Set<string>([
export function injectedColumnsFor(objectDef: unknown): ReadonlySet<string> {
return resolveInjectedSystemColumns(objectDef).names;
}

/**
* The injected columns THIS object registers with NO storage behind them
* (#8116) — the #7865 provenance marker, in the per-object set shape lint
* rules consume.
*
* Non-empty only for an ADR-0015 `external` object: the remote database owns
* its schema, so the platform's injected anchors (`owner_id`,
* `organization_id`, the audit family, …) exist in the registered schema and
* nowhere else. A reference to one is still ADDRESSABLE — it resolves, so
* {@link injectedColumnsFor} rightly includes it and the existence rules stay
* silent — but a predicate or pointer over it can never produce a real value:
* on SQLite the query silently degrades to constant-false (HTTP 200, zero
* rows, no error). Existence and provenance are different questions; rules
* that RESOLVE a reference ask the first, and should ALSO ask this one to warn.
*
* Delegates to the spec's `unprovisionedInjectedColumns` — the same derivation
* the runtime guards converge on (#7833 / #7859 / #7858) — so the author-time
* warning and the runtime's storage verdict cannot disagree. ⛔ Never hand-copy
* the `external` predicate or the anchor identity check here; the drift is the
* exact shape #8116 moved the derivation into the spec to prevent. An
* author-DECLARED column of the same name is the author's (it maps a remote
* column they vouch for — #7859's security direction) and is never in the set.
*/
export function unprovisionedInjectedColumnsFor(objectDef: unknown): ReadonlySet<string> {
return new Set(unprovisionedInjectedColumns(objectDef));
}
125 changes: 125 additions & 0 deletions packages/lint/src/validate-expressions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,7 @@ import { FieldSchema, ObjectSchema, SelectOptionSchema } from '@objectstack/spec
import { SharingRuleSchema } from '@objectstack/spec/security';

import { validateStackExpressions } from './validate-expressions.js';
import type { ExprIssue } from './validate-expressions.js';

describe('validateStackExpressions (ADR-0032 build-time)', () => {
const objects = [
Expand DownExpand Up@@ -2197,6 +2198,11 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t
'issues', 'idx', 'out', 'kept', 'seen', 'seenActions', 'nullable', 'nullableFields', 'nullableIndex',
'fieldIndex', 'fieldTypeIndex', 'fields', 'nodes', 'options', 'targets', 'retired', 'ref', 'roots',
'res', 'graph', 'found', 'e', 'w', 'p', 'n', 'issue', 'guards', 'config',
// [#8116] The unprovisioned-anchor pass: CEL AST walk locals (`celNode` /
// `celRecv` / `pending` — named to stay clear of the `node` metadata
// receiver above) and the provenance index (`unprovisionedIndex` /
// `anchors`), whose keys are Map/Set methods, never metadata keys.
'pending', 'celNode', 'celRecv', 'anchors', 'unprovisionedIndex',
]);
expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]);
});
Expand DownExpand Up@@ -2786,3 +2792,122 @@ describe('validateStackExpressions — injected system columns (#5378)', () => {
expect(withCondition(optedOut, 'has(record.id)')).toHaveLength(0);
});
});

// ---------------------------------------------------------------------------
// [#8116] Unprovisioned injected anchors on external objects WARN.
//
// The gap this pins: #5378 made injected anchors resolve (existence), so a
// predicate over `record.owner_id` on an ADR-0015 `external` object linted
// clean — while the platform registers that anchor WITHOUT provisioning
// storage (#7865), and the query silently degrades at runtime (on SQLite:
// constant-false, HTTP 200, zero rows, no error). The provenance derivation
// moved into `@objectstack/spec/data` precisely so this package could ask it
// (maintainer ruling on #8116, option 1); these tests pin the author-time
// warning built on it.
// ---------------------------------------------------------------------------
describe('validateStackExpressions — unprovisioned injected anchors (#8116)', () => {
const externalObject = (extra: Record<string, unknown> = {}) => ({
name: 'ext_customer',
external: { remoteName: 'customers' },
fields: { email: { type: 'email' }, region: { type: 'text' } },
...extra,
});

const withValidation = (object: Record<string, unknown>, condition: string) =>
validateStackExpressions({
objects: [{ ...object, validations: [{ name: 'r1', type: 'script', condition }] }],
});

const warningsOf = (issues: readonly ExprIssue[]): ExprIssue[] =>
issues.filter((i) => i.severity === 'warning');

it('warns on record.<anchor> in a validation rule on an external object', () => {
const issues = withValidation(externalObject(), 'record.owner_id != null');
const warnings = warningsOf(issues);
expect(warnings).toHaveLength(1);
expect(warnings[0].message).toContain('record.owner_id');
expect(warnings[0].message).toContain('NO storage');
expect(warnings[0].message).toContain('external');
expect(warnings[0].message).toContain('constant-false');
// Advisory, never build-breaking: the runtime degradation is non-fatal and
// the security-critical member of the class is fenced at runtime
// (#7859/#7858) — see the helper's doc for the #7219 criterion.
expect(issues.filter((i) => i.severity === 'error')).toHaveLength(0);
});

it('catches the has() guard form too — record.<anchor> inside a call argument', () => {
const warnings = warningsOf(withValidation(externalObject(), 'has(record.organization_id)'));
expect(warnings).toHaveLength(1);
expect(warnings[0].message).toContain('organization_id');
});

it('warns in a flow condition (flattened scope) — the root is explicit, so no bare-identifier guessing', () => {
const issues = validateStackExpressions({
objects: [externalObject()],
flows: [{
name: 'ext_flow',
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'ext_customer', condition: 'record.owner_id != null' } },
],
edges: [],
}],
});
expect(warningsOf(issues)).toHaveLength(1);
});

it('never judges a bare identifier — a flow variable named like an anchor stays silent', () => {
const issues = validateStackExpressions({
objects: [externalObject()],
flows: [{
name: 'ext_flow',
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'ext_customer', condition: 'owner_id != null' } },
],
edges: [],
}],
});
expect(warningsOf(issues)).toHaveLength(0);
});

it('is silent on the local twin — provenance, not existence, carries the verdict', () => {
const local = { name: 'ext_customer', fields: { email: { type: 'email' } } };
expect(warningsOf(withValidation(local, 'record.owner_id != null'))).toHaveLength(0);
});

it("is silent on an author-DECLARED column of the same name (#7859's security direction)", () => {
const declaredReal = externalObject({
fields: {
email: { type: 'email' },
organization_id: { type: 'text', label: 'Remote Org Key' },
},
});
expect(warningsOf(withValidation(declaredReal, 'record.organization_id != null'))).toHaveLength(0);
});

it('is silent for an anchor the injection plan withholds — the existence pass owns that (as an error)', () => {
// `ownership: 'none'` ⇒ no owner_id anywhere ⇒ the reference is an unknown
// field (error), not an unprovisioned anchor (warning). One defect, one
// finding, the right one.
const issues = withValidation(externalObject({ ownership: 'none' }), 'record.owner_id != null');
expect(warningsOf(issues)).toHaveLength(0);
expect(issues.filter((i) => i.severity === 'error')).toHaveLength(1);
});

it('rides the field-level slots and formulas too', () => {
const issues = validateStackExpressions({
objects: [externalObject({
fields: {
email: { type: 'email' },
vip: { type: 'boolean', readonlyWhen: 'record.owner_id == null' },
owner_label: { type: 'text', expression: "record.owner_id + ''" },
},
})],
});
const warnings = warningsOf(issues).filter(
(i) => i.message.includes('owner_id') && i.message.includes('NO storage'),
);
expect(warnings).toHaveLength(2);
expect(warnings.some((i) => i.where.includes('readonlyWhen'))).toBe(true);
expect(warnings.some((i) => i.where.includes('expression'))).toBe(true);
});
});
Loading
Loading