Skip to content

Commit 2d8dba3

Browse files
hotlongclaude
andauthored
feat(spec): sink the #7865 injected-column provenance derivation into @objectstack/spec/data and warn at lint time (#8342)
* feat(spec): sink the #7865 injected-column provenance derivation into @objectstack/spec/data and warn at lint time (#8116) Move the injected-system-column definition tables (AUDIT_FIELD_DEFS and siblings), injectedSystemColumnDefs, and the #7865 provenance trio (platformProvisionsStorage / resolveInjectedColumnProvenance / unprovisionedInjectedColumns) into packages/spec/src/data, per the 2026-08-12 maintainer ruling on #8116 (option 1 — the WHAT-half move #3786 anticipated, scoped to the provenance predicate). @objectstack/metadata-core re-exports every previously-public name, so its surface and every downstream import are unchanged; the served- document injection/strip pair (#6562) stays there, now consuming the spec tables plus the newly exported isInjectedColumnDefinition. @objectstack/lint (spec-only by contract) consumes the export: validate-expressions warns on record.<anchor> / previous.<anchor> reads, and validate-semantic-roles warns on stageField / highlightFields pointers, when the anchor is an injected system column on an ADR-0015 external object — registered, addressable, and backed by no storage. Warning severity per the #7219 criterion (no closed oracle over the remote schema); an author-declared column of the same name is 'author' provenance and never warned (#7859's security direction). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2 * test(lint): excuse the #8116 AST-walk locals in the #5017 receiver-completeness guard The new unprovisioned-anchor pass introduces five locals whose member reads are Map/Set/AST plumbing, never metadata keys; the meta-guard's completeness check requires them excused by name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2 * chore(spec): regenerate api-surface + export-origins for the #8116 data exports (10 added, 0 removed) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2 * test(lint): type warningsOf over ExprIssue — clears the 9 test-layer tsc errors the TEST_DEBT ratchet caught (#8116) The #8116 test block's helper took Array<{ severity?: string }>, so every .message/.where read off its result was TS2339 under the lifted-exclusion measurement (recorded 20 -> measured 29). Typed over the module's own ExprIssue, the lifted-exclusion count is back to exactly the recorded 20; the ledger entry is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2 * chore(spec): regenerate api-surface + export-origins from the merged tree (#8116 x #8341/#8335) Discharges the os-regen deferral from the preceding merge commit: the data.json shards regenerate with both sides present — this PR's 10 new provenance exports and main's landed entries (zodIssuesToFields intact in the api.json shards). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 84c07c3 commit 2d8dba3

16 files changed

Lines changed: 1050 additions & 404 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-core': patch
4+
'@objectstack/lint': minor
5+
---
6+
7+
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.

‎packages/lint/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ export {
162162
FIELD_GROUP_EMPTY,
163163
FIELD_GROUP_SHADOWED,
164164
SEMANTIC_ROLE_FIELD_UNKNOWN,
165+
SEMANTIC_ROLE_FIELD_UNPROVISIONED,
165166
} from './validate-semantic-roles.js';
166167
export type { SemanticRoleFinding, SemanticRoleSeverity } from './validate-semantic-roles.js';
167168

‎packages/lint/src/system-fields.test.ts‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

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

88
describe('SYSTEM_FIELDS (#4330)', () => {
99
it('contains every member of both spec declarations — the derivation is complete', () => {
@@ -37,3 +37,33 @@ describe('SYSTEM_FIELDS (#4330)', () => {
3737
}
3838
});
3939
});
40+
41+
describe('unprovisionedInjectedColumnsFor (#8116)', () => {
42+
const external = {
43+
name: 'ext_customer',
44+
external: { remoteName: 'customers' },
45+
fields: { email: { type: 'text', label: 'Email' } },
46+
};
47+
48+
it('is the spec derivation verbatim — never a hand-copied predicate', () => {
49+
// Delegation pin: the set is exactly what `@objectstack/spec/data` answers,
50+
// so the author-time warning and the runtime guards cannot disagree.
51+
expect([...unprovisionedInjectedColumnsFor(external)].sort()).toEqual(
52+
unprovisionedInjectedColumns(external).sort(),
53+
);
54+
});
55+
56+
it('is non-empty only for external objects, and excludes author-declared columns', () => {
57+
expect(unprovisionedInjectedColumnsFor(external).has('owner_id')).toBe(true);
58+
expect(unprovisionedInjectedColumnsFor(external).has('organization_id')).toBe(true);
59+
// Local twin: platform storage is real.
60+
expect(unprovisionedInjectedColumnsFor({ name: 'customer', fields: {} }).size).toBe(0);
61+
// #7859's security direction: a declared organization_id maps a real
62+
// remote column the author vouches for — never in the set.
63+
const declaredReal = {
64+
...external,
65+
fields: { ...external.fields, organization_id: { type: 'text', label: 'Remote Org Key' } },
66+
};
67+
expect(unprovisionedInjectedColumnsFor(declaredReal).has('organization_id')).toBe(false);
68+
});
69+
});

‎packages/lint/src/system-fields.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@
3232
* genuinely does not have.
3333
*/
3434

35-
import { FIELD_GROUP_SYSTEM_FIELDS, resolveInjectedSystemColumns } from '@objectstack/spec/data';
35+
import {
36+
FIELD_GROUP_SYSTEM_FIELDS,
37+
resolveInjectedSystemColumns,
38+
unprovisionedInjectedColumns,
39+
} from '@objectstack/spec/data';
3640
import { SystemFieldName } from '@objectstack/spec/system';
3741

3842
/**
@@ -69,3 +73,30 @@ export const SYSTEM_FIELDS: ReadonlySet<string> = new Set<string>([
6973
export function injectedColumnsFor(objectDef: unknown): ReadonlySet<string> {
7074
return resolveInjectedSystemColumns(objectDef).names;
7175
}
76+
77+
/**
78+
* The injected columns THIS object registers with NO storage behind them
79+
* (#8116) — the #7865 provenance marker, in the per-object set shape lint
80+
* rules consume.
81+
*
82+
* Non-empty only for an ADR-0015 `external` object: the remote database owns
83+
* its schema, so the platform's injected anchors (`owner_id`,
84+
* `organization_id`, the audit family, …) exist in the registered schema and
85+
* nowhere else. A reference to one is still ADDRESSABLE — it resolves, so
86+
* {@link injectedColumnsFor} rightly includes it and the existence rules stay
87+
* silent — but a predicate or pointer over it can never produce a real value:
88+
* on SQLite the query silently degrades to constant-false (HTTP 200, zero
89+
* rows, no error). Existence and provenance are different questions; rules
90+
* that RESOLVE a reference ask the first, and should ALSO ask this one to warn.
91+
*
92+
* Delegates to the spec's `unprovisionedInjectedColumns` — the same derivation
93+
* the runtime guards converge on (#7833 / #7859 / #7858) — so the author-time
94+
* warning and the runtime's storage verdict cannot disagree. ⛔ Never hand-copy
95+
* the `external` predicate or the anchor identity check here; the drift is the
96+
* exact shape #8116 moved the derivation into the spec to prevent. An
97+
* author-DECLARED column of the same name is the author's (it maps a remote
98+
* column they vouch for — #7859's security direction) and is never in the set.
99+
*/
100+
export function unprovisionedInjectedColumnsFor(objectDef: unknown): ReadonlySet<string> {
101+
return new Set(unprovisionedInjectedColumns(objectDef));
102+
}

‎packages/lint/src/validate-expressions.test.ts‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { FieldSchema, ObjectSchema, SelectOptionSchema } from '@objectstack/spec
1111
import { SharingRuleSchema } from '@objectstack/spec/security';
1212

1313
import { validateStackExpressions } from './validate-expressions.js';
14+
import type { ExprIssue } from './validate-expressions.js';
1415

1516
describe('validateStackExpressions (ADR-0032 build-time)', () => {
1617
const objects = [
@@ -2197,6 +2198,11 @@ describe('validateStackExpressions — reads only keys the spec declares (meta-t
21972198
'issues', 'idx', 'out', 'kept', 'seen', 'seenActions', 'nullable', 'nullableFields', 'nullableIndex',
21982199
'fieldIndex', 'fieldTypeIndex', 'fields', 'nodes', 'options', 'targets', 'retired', 'ref', 'roots',
21992200
'res', 'graph', 'found', 'e', 'w', 'p', 'n', 'issue', 'guards', 'config',
2201+
// [#8116] The unprovisioned-anchor pass: CEL AST walk locals (`celNode` /
2202+
// `celRecv` / `pending` — named to stay clear of the `node` metadata
2203+
// receiver above) and the provenance index (`unprovisionedIndex` /
2204+
// `anchors`), whose keys are Map/Set methods, never metadata keys.
2205+
'pending', 'celNode', 'celRecv', 'anchors', 'unprovisionedIndex',
22002206
]);
22012207
expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]);
22022208
});
@@ -2786,3 +2792,122 @@ describe('validateStackExpressions — injected system columns (#5378)', () => {
27862792
expect(withCondition(optedOut, 'has(record.id)')).toHaveLength(0);
27872793
});
27882794
});
2795+
2796+
// ---------------------------------------------------------------------------
2797+
// [#8116] Unprovisioned injected anchors on external objects WARN.
2798+
//
2799+
// The gap this pins: #5378 made injected anchors resolve (existence), so a
2800+
// predicate over `record.owner_id` on an ADR-0015 `external` object linted
2801+
// clean — while the platform registers that anchor WITHOUT provisioning
2802+
// storage (#7865), and the query silently degrades at runtime (on SQLite:
2803+
// constant-false, HTTP 200, zero rows, no error). The provenance derivation
2804+
// moved into `@objectstack/spec/data` precisely so this package could ask it
2805+
// (maintainer ruling on #8116, option 1); these tests pin the author-time
2806+
// warning built on it.
2807+
// ---------------------------------------------------------------------------
2808+
describe('validateStackExpressions — unprovisioned injected anchors (#8116)', () => {
2809+
const externalObject = (extra: Record<string, unknown> = {}) => ({
2810+
name: 'ext_customer',
2811+
external: { remoteName: 'customers' },
2812+
fields: { email: { type: 'email' }, region: { type: 'text' } },
2813+
...extra,
2814+
});
2815+
2816+
const withValidation = (object: Record<string, unknown>, condition: string) =>
2817+
validateStackExpressions({
2818+
objects: [{ ...object, validations: [{ name: 'r1', type: 'script', condition }] }],
2819+
});
2820+
2821+
const warningsOf = (issues: readonly ExprIssue[]): ExprIssue[] =>
2822+
issues.filter((i) => i.severity === 'warning');
2823+
2824+
it('warns on record.<anchor> in a validation rule on an external object', () => {
2825+
const issues = withValidation(externalObject(), 'record.owner_id != null');
2826+
const warnings = warningsOf(issues);
2827+
expect(warnings).toHaveLength(1);
2828+
expect(warnings[0].message).toContain('record.owner_id');
2829+
expect(warnings[0].message).toContain('NO storage');
2830+
expect(warnings[0].message).toContain('external');
2831+
expect(warnings[0].message).toContain('constant-false');
2832+
// Advisory, never build-breaking: the runtime degradation is non-fatal and
2833+
// the security-critical member of the class is fenced at runtime
2834+
// (#7859/#7858) — see the helper's doc for the #7219 criterion.
2835+
expect(issues.filter((i) => i.severity === 'error')).toHaveLength(0);
2836+
});
2837+
2838+
it('catches the has() guard form too — record.<anchor> inside a call argument', () => {
2839+
const warnings = warningsOf(withValidation(externalObject(), 'has(record.organization_id)'));
2840+
expect(warnings).toHaveLength(1);
2841+
expect(warnings[0].message).toContain('organization_id');
2842+
});
2843+
2844+
it('warns in a flow condition (flattened scope) — the root is explicit, so no bare-identifier guessing', () => {
2845+
const issues = validateStackExpressions({
2846+
objects: [externalObject()],
2847+
flows: [{
2848+
name: 'ext_flow',
2849+
nodes: [
2850+
{ id: 'start', type: 'start', config: { objectName: 'ext_customer', condition: 'record.owner_id != null' } },
2851+
],
2852+
edges: [],
2853+
}],
2854+
});
2855+
expect(warningsOf(issues)).toHaveLength(1);
2856+
});
2857+
2858+
it('never judges a bare identifier — a flow variable named like an anchor stays silent', () => {
2859+
const issues = validateStackExpressions({
2860+
objects: [externalObject()],
2861+
flows: [{
2862+
name: 'ext_flow',
2863+
nodes: [
2864+
{ id: 'start', type: 'start', config: { objectName: 'ext_customer', condition: 'owner_id != null' } },
2865+
],
2866+
edges: [],
2867+
}],
2868+
});
2869+
expect(warningsOf(issues)).toHaveLength(0);
2870+
});
2871+
2872+
it('is silent on the local twin — provenance, not existence, carries the verdict', () => {
2873+
const local = { name: 'ext_customer', fields: { email: { type: 'email' } } };
2874+
expect(warningsOf(withValidation(local, 'record.owner_id != null'))).toHaveLength(0);
2875+
});
2876+
2877+
it("is silent on an author-DECLARED column of the same name (#7859's security direction)", () => {
2878+
const declaredReal = externalObject({
2879+
fields: {
2880+
email: { type: 'email' },
2881+
organization_id: { type: 'text', label: 'Remote Org Key' },
2882+
},
2883+
});
2884+
expect(warningsOf(withValidation(declaredReal, 'record.organization_id != null'))).toHaveLength(0);
2885+
});
2886+
2887+
it('is silent for an anchor the injection plan withholds — the existence pass owns that (as an error)', () => {
2888+
// `ownership: 'none'` ⇒ no owner_id anywhere ⇒ the reference is an unknown
2889+
// field (error), not an unprovisioned anchor (warning). One defect, one
2890+
// finding, the right one.
2891+
const issues = withValidation(externalObject({ ownership: 'none' }), 'record.owner_id != null');
2892+
expect(warningsOf(issues)).toHaveLength(0);
2893+
expect(issues.filter((i) => i.severity === 'error')).toHaveLength(1);
2894+
});
2895+
2896+
it('rides the field-level slots and formulas too', () => {
2897+
const issues = validateStackExpressions({
2898+
objects: [externalObject({
2899+
fields: {
2900+
email: { type: 'email' },
2901+
vip: { type: 'boolean', readonlyWhen: 'record.owner_id == null' },
2902+
owner_label: { type: 'text', expression: "record.owner_id + ''" },
2903+
},
2904+
})],
2905+
});
2906+
const warnings = warningsOf(issues).filter(
2907+
(i) => i.message.includes('owner_id') && i.message.includes('NO storage'),
2908+
);
2909+
expect(warnings).toHaveLength(2);
2910+
expect(warnings.some((i) => i.where.includes('readonlyWhen'))).toBe(true);
2911+
expect(warnings.some((i) => i.where.includes('expression'))).toBe(true);
2912+
});
2913+
});

0 commit comments

Comments
 (0)