From 39a1e07544198baca7f287cadcbd33c0b33ac2dd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 05:02:55 +0000 Subject: [PATCH 1/5] feat(spec): sink the #7865 injected-column provenance derivation into @objectstack/spec/data and warn at lint time (#8116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. / previous. 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 Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2 --- .changeset/injected-provenance-into-spec.md | 7 + packages/lint/src/index.ts | 1 + packages/lint/src/system-fields.test.ts | 34 +- packages/lint/src/system-fields.ts | 33 +- .../lint/src/validate-expressions.test.ts | 119 +++++ packages/lint/src/validate-expressions.ts | 137 +++++- .../lint/src/validate-semantic-roles.test.ts | 80 ++++ packages/lint/src/validate-semantic-roles.ts | 35 +- packages/metadata-core/src/index.ts | 16 +- .../src/injected-system-columns.ts | 434 ++---------------- packages/spec/src/data/index.ts | 11 +- .../injected-system-column-provenance.test.ts | 123 +++++ .../data/injected-system-column-provenance.ts | 389 ++++++++++++++++ .../spec/src/data/injected-system-columns.ts | 9 +- 14 files changed, 1024 insertions(+), 404 deletions(-) create mode 100644 .changeset/injected-provenance-into-spec.md create mode 100644 packages/spec/src/data/injected-system-column-provenance.test.ts create mode 100644 packages/spec/src/data/injected-system-column-provenance.ts diff --git a/.changeset/injected-provenance-into-spec.md b/.changeset/injected-provenance-into-spec.md new file mode 100644 index 0000000000..bdb1daeba3 --- /dev/null +++ b/.changeset/injected-provenance-into-spec.md @@ -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. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index a9780107d3..f39ebc168f 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -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'; diff --git a/packages/lint/src/system-fields.test.ts b/packages/lint/src/system-fields.test.ts index c8409d770f..f3f4734e9d 100644 --- a/packages/lint/src/system-fields.test.ts +++ b/packages/lint/src/system-fields.test.ts @@ -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', () => { @@ -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); + }); +}); diff --git a/packages/lint/src/system-fields.ts b/packages/lint/src/system-fields.ts index 7f0a982c6f..3b95fc58ea 100644 --- a/packages/lint/src/system-fields.ts +++ b/packages/lint/src/system-fields.ts @@ -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'; /** @@ -69,3 +73,30 @@ export const SYSTEM_FIELDS: ReadonlySet = new Set([ export function injectedColumnsFor(objectDef: unknown): ReadonlySet { 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 { + return new Set(unprovisionedInjectedColumns(objectDef)); +} diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 496fdc4a73..c9a9f202ce 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -2786,3 +2786,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 = {}) => ({ + name: 'ext_customer', + external: { remoteName: 'customers' }, + fields: { email: { type: 'email' }, region: { type: 'text' } }, + ...extra, + }); + + const withValidation = (object: Record, condition: string) => + validateStackExpressions({ + objects: [{ ...object, validations: [{ name: 'r1', type: 'script', condition }] }], + }); + + const warningsOf = (issues: Array<{ severity?: string }>) => + issues.filter((i) => i.severity === 'warning'); + + it('warns on record. 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. 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); + }); +}); diff --git a/packages/lint/src/validate-expressions.ts b/packages/lint/src/validate-expressions.ts index 784dd2bb13..8c2b92be1c 100644 --- a/packages/lint/src/validate-expressions.ts +++ b/packages/lint/src/validate-expressions.ts @@ -78,11 +78,11 @@ * `validate-expressions.test.ts` pins that with no tracked exceptions left. */ -import { validateExpression, collectCelRootIdentifiers, SCOPE_ROOTS } from '@objectstack/formula'; +import { validateExpression, collectCelRootIdentifiers, parseCelToAst, SCOPE_ROOTS } from '@objectstack/formula'; import { collectFlowGraphs, resolveFlowNodeExpressions } from '@objectstack/spec/automation'; import type { FlowNodeParsed } from '@objectstack/spec/automation'; -import { injectedColumnsFor } from './system-fields.js'; +import { injectedColumnsFor, unprovisionedInjectedColumnsFor } from './system-fields.js'; import { findUnguardedNullableOperands, nullGuardMessage } from './validate-null-guards.js'; import type { NullGuardOutcome } from './validate-null-guards.js'; @@ -144,6 +144,73 @@ function buildFieldIndex(objects: AnyRec[]): Map { return idx; } +/** + * [#8116] The roots this file's unprovisioned-anchor warning resolves against + * the bound object — the same pair the #4763 null-guard gate resolves, for the + * same reason: both bind the object's record shape, so a single-segment member + * read off either names one of the object's columns. + */ +const BOUND_RECORD_ROOTS = ['record', 'previous'] as const; + +/** Minimal structural view of a `parseCelToAst` node (`{ op, args }`). */ +type CelNodeLike = { op?: string; args?: unknown }; + +function isCelNode(v: unknown): v is CelNodeLike { + return !!v && typeof v === 'object' && typeof (v as CelNodeLike).op === 'string'; +} + +/** + * [#8116] Every single-segment `record.` / `previous.` member read in + * the source, as `field → operand-as-written` (first spelling wins). Empty + * when the source does not parse — the syntax pass owns that defect, and one + * broken predicate must produce one finding, not two. + * + * Deliberately NEVER a bare identifier: in a flattened flow scope a bare name + * may be a flow variable, and a false finding here is the trust-killer + * ADR-0072 D1 names. A `has(record.x)` guard is covered for free — `record.x` + * is an ordinary member-read node inside the call's argument list. Nested + * traversal (`record.owner_id.name`) contributes its FIRST segment, which is + * the column the query engine must resolve on this object. + */ +function collectBoundRecordReads(source: string): Map { + const out = new Map(); + const ast = parseCelToAst(source); + if (!ast) return out; + // NB: local names are chosen to stay out of the #5017 meta-guard's receiver + // scan (`node` is a METADATA receiver there — flow `nodes[]` — and this walk + // reads AST internals, not metadata keys). + const pending: unknown[] = [ast]; + while (pending.length > 0) { + const celNode = pending.pop(); + if (!isCelNode(celNode)) continue; + if ( + (celNode.op === '.' || celNode.op === '.?') && + Array.isArray(celNode.args) && + celNode.args.length >= 2 + ) { + const [celRecv, seg] = celNode.args as [unknown, unknown]; + if ( + typeof seg === 'string' && + isCelNode(celRecv) && + celRecv.op === 'id' && + typeof celRecv.args === 'string' && + (BOUND_RECORD_ROOTS as readonly string[]).includes(celRecv.args) + ) { + if (!out.has(seg)) out.set(seg, `${celRecv.args}.${seg}`); + } + } + const celArgs = celNode.args; + if (isCelNode(celArgs)) pending.push(celArgs); + else if (Array.isArray(celArgs)) { + for (const a of celArgs) { + if (isCelNode(a)) pending.push(a); + else if (Array.isArray(a)) for (const b of a) if (isCelNode(b)) pending.push(b); + } + } + } + return out; +} + /** * object name → (field name → field type), for the #1928 tier-4 type-soundness * check. Handles both `fields` shapes (array of `{name, type}` and name-keyed @@ -345,6 +412,64 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { const fieldTypeIndex = buildFieldTypeIndex(objects); const nullableIndex = buildNullableFieldIndex(objects); + // [#8116] object name → the injected anchors registered with NO storage + // behind them (non-empty only for ADR-0015 `external` objects). The other + // half of {@link buildFieldIndex}'s #5378 widening: the anchors ARE + // addressable — the field-existence pass rightly resolves them — but on a + // federated object nothing backs them, so a predicate over one silently + // matches nothing. Existence stays green; provenance warns. + const unprovisionedIndex = new Map>(); + for (const obj of objects) { + const name = typeof obj.name === 'string' ? obj.name : undefined; + if (!name) continue; + const anchors = unprovisionedInjectedColumnsFor(obj); + if (anchors.size > 0) unprovisionedIndex.set(name, anchors); + } + + /** + * [#8116] The unprovisioned-anchor warning — the author-time half of the + * #7865 provenance marker (runtime halves: #7833 / #7859 / #7858). Fires on + * a `record.` / `previous.` read where the anchor is an + * injected system column the platform registers on this `external` object + * but provisions no storage for. + * + * `warning`, not `error`, on the #7219 family's criterion: an error needs a + * closed oracle, and this pass cannot see the remote table — it knows the + * anchor has no PLATFORM storage, not what the deployment's remote schema or + * a runtime plugin might resolve. The runtime degradation is also non-fatal + * (silently empty results), the class this package reports advisorily; and + * the security-critical member of the class is already fenced at runtime by + * the #7859/#7858 guards. An author-DECLARED column of the same name is the + * author's (`'author'` provenance — it maps a remote column they vouch for) + * and never enters the index, so the tenant-wall case #7859 protects stays + * silent here too. + */ + const warnUnprovisionedAnchors = (where: string, raw: unknown, objectName?: string): void => { + if (!objectName) return; + const anchors = unprovisionedIndex.get(objectName); + if (!anchors) return; + const source = celSourceOf(raw); + if (!source) return; + for (const [field, operand] of collectBoundRecordReads(source)) { + if (!anchors.has(field)) continue; + issues.push({ + where, + message: + `\`${operand}\` reads '${field}', an injected system column with NO storage behind it: ` + + `'${objectName}' is an external object (ADR-0015), so the remote database owns its schema ` + + `and the platform registers this anchor without provisioning a column. The predicate can ` + + `never match a real value — on SQLite it silently degrades to constant-false (HTTP 200, ` + + `zero rows, no error). If the remote table really carries this column, declare '${field}' ` + + `in the object's own fields (mapped through the external binding's columnMap) so the ` + + `reference resolves to a column you vouch for; otherwise drop the reference, or opt the ` + + `object out of the injection (\`ownership: 'none'\` for the ownership anchors, ` + + `\`systemFields: { audit: false }\` for the audit family).`, + source, + severity: 'warning', + }); + } + }; + /** * The #4763 null-guard gate. Wired to exactly those surfaces whose predicates * CEL evaluates over a record made **total** for every declared field @@ -406,6 +531,11 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { objectName ? { objectName, fields, fieldTypes, scope } : { scope }); for (const e of res.errors) issues.push({ where, message: e.message, source: e.source, severity: 'error' }); for (const w of res.warnings) issues.push({ where, message: w.message, source: w.source, severity: 'warning' }); + // [#8116] Provenance rides every object-bound predicate this helper + // validates, whichever scope: the `record`/`previous` roots are explicit + // in the source, so flattened flow conditions are covered without ever + // judging a bare identifier. + warnUnprovisionedAnchors(where, raw, objectName); }; /** @@ -1030,6 +1160,9 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { const fieldWhere = `object '${objectName}' · field '${fname}' expression`; for (const e of res.errors) issues.push({ where: fieldWhere, message: e.message, source: e.source, severity: 'error' }); for (const w of res.warnings) issues.push({ where: fieldWhere, message: w.message, source: w.source, severity: 'warning' }); + // [#8116] Formulas read the same record binding the predicates do, so + // a `record.` read inside one degrades identically. + warnUnprovisionedAnchors(fieldWhere, f.expression, objectName); } } } diff --git a/packages/lint/src/validate-semantic-roles.test.ts b/packages/lint/src/validate-semantic-roles.test.ts index 33baebde91..90782065ad 100644 --- a/packages/lint/src/validate-semantic-roles.test.ts +++ b/packages/lint/src/validate-semantic-roles.test.ts @@ -7,6 +7,7 @@ import { FIELD_GROUP_EMPTY, FIELD_GROUP_SHADOWED, SEMANTIC_ROLE_FIELD_UNKNOWN, + SEMANTIC_ROLE_FIELD_UNPROVISIONED, } from './validate-semantic-roles'; const stack = (objects: unknown) => ({ objects }); @@ -325,3 +326,82 @@ describe('validateSemanticRoles — injected system columns (#5378)', () => { expect(findings).toEqual([]); }); }); + +// --------------------------------------------------------------------------- +// [#8116] Semantic-role pointers at UNPROVISIONED injected anchors warn. +// +// The #5378 widening above makes an injected anchor a legal pointer target — +// right for every platform-provisioned object, and exactly wrong on an +// ADR-0015 `external` one, where the anchor is registered with no storage +// behind it (#7865): the pointer resolves, and every consumer renders a blank. +// The provenance derivation moved to `@objectstack/spec/data` so this package +// can tell the two apart (maintainer ruling on #8116, option 1). +// --------------------------------------------------------------------------- +describe('validateSemanticRoles — unprovisioned anchors on external objects (#8116)', () => { + const externalObject = (extra: Record = {}) => ({ + name: 'ext_customer', + external: { remoteName: 'customers' }, + fields: { email: { type: 'email' }, status: { type: 'select' } }, + ...extra, + }); + + it('warns on a highlightFields entry naming an injected anchor', () => { + const findings = validateSemanticRoles(stack([ + externalObject({ highlightFields: ['email', 'owner_id'] }), + ])); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'warning', + rule: SEMANTIC_ROLE_FIELD_UNPROVISIONED, + path: 'objects[0].highlightFields', + }); + expect(findings[0].message).toContain('owner_id'); + expect(findings[0].message).toContain('external'); + expect(findings[0].hint).toContain('columnMap'); + }); + + it('warns on a stageField naming an injected anchor', () => { + const findings = validateSemanticRoles(stack([ + externalObject({ stageField: 'created_by' }), + ])); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'warning', + rule: SEMANTIC_ROLE_FIELD_UNPROVISIONED, + path: 'objects[0].stageField', + }); + }); + + it('stays silent on the local twin — provenance, not existence, carries the verdict', () => { + const findings = validateSemanticRoles(stack([{ + name: 'customer', + highlightFields: ['email', 'owner_id'], + stageField: 'created_by', + fields: { email: { type: 'email' } }, + }])); + expect(findings).toEqual([]); + }); + + it("stays silent on an author-DECLARED column of the same name (#7859's security direction)", () => { + const findings = validateSemanticRoles(stack([ + externalObject({ + highlightFields: ['organization_id'], + fields: { + email: { type: 'email' }, + organization_id: { type: 'text', label: 'Remote Org Key' }, + }, + }), + ])); + expect(findings).toEqual([]); + }); + + it('a withheld anchor still gets the UNKNOWN finding, never the provenance one', () => { + // `ownership: 'none'` ⇒ no owner_id anywhere ⇒ rule (c)'s existence warning + // owns the defect. One pointer, one finding, the right one. + const findings = validateSemanticRoles(stack([ + externalObject({ ownership: 'none', highlightFields: ['owner_id'] }), + ])); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(SEMANTIC_ROLE_FIELD_UNKNOWN); + }); +}); diff --git a/packages/lint/src/validate-semantic-roles.ts b/packages/lint/src/validate-semantic-roles.ts index 391a301f54..e3fed006b6 100644 --- a/packages/lint/src/validate-semantic-roles.ts +++ b/packages/lint/src/validate-semantic-roles.ts @@ -18,12 +18,13 @@ * staring at an unchanged page. */ -import { injectedColumnsFor } from './system-fields.js'; +import { injectedColumnsFor, unprovisionedInjectedColumnsFor } from './system-fields.js'; export const FIELD_GROUP_UNDECLARED = 'field-group-undeclared'; export const FIELD_GROUP_EMPTY = 'field-group-empty'; export const FIELD_GROUP_SHADOWED = 'field-group-shadowed'; export const SEMANTIC_ROLE_FIELD_UNKNOWN = 'semantic-role-field-unknown'; +export const SEMANTIC_ROLE_FIELD_UNPROVISIONED = 'semantic-role-field-unprovisioned'; export type SemanticRoleSeverity = 'error' | 'warning'; @@ -88,6 +89,30 @@ export function validateSemanticRoles(stack: AnyRec): SemanticRoleFinding[] { // (`FIELD_GROUP_SYSTEM_FIELDS`), and must not count as a group member or a // title candidate. const fieldNames = new Set([...Object.keys(fields), ...injectedColumnsFor(obj)]); + // [#8116] The provenance half of the same #5378 widening: on an ADR-0015 + // `external` object the injected anchors resolve — so rule (c) rightly + // stays silent — but the platform provisions no storage behind them, so a + // pointer at one drives its consumers (default columns, cards, previews, + // the highlight strip, stage detection) off a column that is blank on + // every record. Existence and provenance are different questions; the set + // is empty everywhere except external objects, and an author-DECLARED + // column of the same name is the author's and never in it. + const unprovisioned = unprovisionedInjectedColumnsFor(obj); + const unprovisionedPointer = (slot: string, entry: string): SemanticRoleFinding => ({ + severity: 'warning', + rule: SEMANTIC_ROLE_FIELD_UNPROVISIONED, + where, + path: `${path}.${slot}`, + message: + `${objName}: ${slot} points at "${entry}", an injected system column with no storage ` + + `behind it — this object is external (ADR-0015), so the platform registers the anchor ` + + `but the remote schema owns the table and no column backs it. Every consumer renders ` + + `it empty on every record.`, + hint: + `If the remote table really carries "${entry}", declare it in the object's own fields ` + + `(mapped through the external binding's columnMap); otherwise point ${slot} at a real ` + + `remote column.`, + }); // ── (a) Field.group → declared fieldGroups[].key ── const declaredGroups = new Set( @@ -150,6 +175,8 @@ export function validateSemanticRoles(stack: AnyRec): SemanticRoleFinding[] { `Point stageField at an existing select/status field, or set ` + `stageField: false to declare the object has no linear lifecycle.`, }); + } else if (typeof stage === 'string' && unprovisioned.has(stage)) { + findings.push(unprovisionedPointer('stageField', stage)); } const highlights = Array.isArray(obj.highlightFields) @@ -158,7 +185,11 @@ export function validateSemanticRoles(stack: AnyRec): SemanticRoleFinding[] { ? obj.compactLayout : []; for (const entry of highlights) { - if (typeof entry !== 'string' || entry.length === 0 || fieldNames.has(entry)) continue; + if (typeof entry !== 'string' || entry.length === 0) continue; + if (fieldNames.has(entry)) { + if (unprovisioned.has(entry)) findings.push(unprovisionedPointer('highlightFields', entry)); + continue; + } findings.push({ severity: 'warning', rule: SEMANTIC_ROLE_FIELD_UNKNOWN, diff --git a/packages/metadata-core/src/index.ts b/packages/metadata-core/src/index.ts index 2163601e84..79056e6ab6 100644 --- a/packages/metadata-core/src/index.ts +++ b/packages/metadata-core/src/index.ts @@ -36,13 +36,15 @@ export * from './engine-update-dispatch.js'; // reporting two. export * from './audit-field-governance.js'; -// [#6562] The injected-system-column DEFINITION table and the served-document -// injection/strip pair built on it, sunk here by the same criterion and for the -// same cycle as the governance table above. `resolveInjectedSystemColumns` -// (spec, #5378) says WHICH columns an object carries; this says WHAT each one -// looks like — the half that used to exist only inside `applySystemFields`, one -// import away from every `/meta` read exit and unreachable from all of them. -// `@objectstack/objectql` now reads this table instead of its own literals. +// [#6562] The served-document injection/strip pair over the injected-system- +// column definition tables, sunk here by the same criterion and for the same +// cycle as the governance table above. The DEFINITION tables themselves and the +// #7865 provenance derivation moved one package further down — into +// `@objectstack/spec/data` (#8116) — so the author-time surface +// (`@objectstack/lint`, spec-only by contract) can read them too; this module +// re-exports every moved name, so this package's public surface is unchanged. +// `@objectstack/objectql` reads the tables (via this re-export) instead of its +// own literals. export * from './injected-system-columns.js'; // [ADR-0106 / #3682] The metadata-plane FLS projection — one masking function diff --git a/packages/metadata-core/src/injected-system-columns.ts b/packages/metadata-core/src/injected-system-columns.ts index d7e331c1a1..6def43fa01 100644 --- a/packages/metadata-core/src/injected-system-columns.ts +++ b/packages/metadata-core/src/injected-system-columns.ts @@ -1,30 +1,38 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The **one** table of injected-system-column DEFINITIONS, and the served-document - * injection / strip pair built on it (objectstack#6562, ruled Option B). + * The served-document injection / strip pair over the injected-system-column + * definition tables (objectstack#6562, ruled Option B) — and the re-export shim + * for the tables and the #7865 provenance derivation, which moved to + * `@objectstack/spec/data` in #8116. * - * ## The split this completes + * ## Where the pieces live now, and why * * `resolveInjectedSystemColumns` (`@objectstack/spec/data`, #5378) is the one * answer to *"WHICH columns does the platform provision on THIS object without - * the author declaring them?"*. It deliberately owns only the names — #3786's - * split leaves *"WHAT does each one look like?"* to the runtime. Until now the - * only copy of that second half lived inside `applySystemFields` - * (`@objectstack/objectql`), reachable only by running the registry. - * - * That is the same wall #4513 hit and recorded one file over - * ({@link applyAuditFieldGovernance}): `@objectstack/objectql` **depends on** - * `@objectstack/metadata-protocol`, so the `/meta` read path cannot import from - * the registry that owns the answer, and the reverse import closes a cycle turbo - * rejects outright. The honest way out is the one this package already carries - * twice — sink the contract into a package **both** sides depend on. This - * package's own dependencies are `{ @objectstack/spec, zod }`, so there is no - * new edge and no new cycle. `applySystemFields` now reads this table instead of - * its own literals; the read path reads it too, and the two cannot drift because - * there is nothing left for them to disagree about. - * - * ## Why a `/meta` read needs it at all (#6562) + * the author declaring them?"*. The column DEFINITIONS (WHAT each one looks + * like — `AUDIT_FIELD_DEFS` and its three siblings), the derived per-object + * `injectedSystemColumnDefs`, and the #7865 PROVENANCE derivation over them + * (`platformProvisionsStorage` / `resolveInjectedColumnProvenance` / + * `unprovisionedInjectedColumns`) live beside it in + * `injected-system-column-provenance.ts` since #8116: `@objectstack/lint`'s + * package contract is "depends on `@objectstack/spec`; never on a runtime", so + * a marker only this package exported was structurally unreachable from the + * author-time surface, and an expression over an unprovisioned anchor on an + * ADR-0015 `external` object linted clean while degrading silently at query + * time. The maintainer ruling on #8116 (2026-08-12) sank the derivation into + * the contract package (option 1) rather than grant lint a dependency + * exception; **this module re-exports every moved name, so its public surface + * — and every downstream import — is unchanged.** + * + * What stays HERE is the served-document pair below (#6562): it is runtime + * document transformation for the `/meta` read/write path, not contract, and + * moving it was explicitly out of #8116's scope. History of the earlier hops + * (objectql → metadata-core by #6562, for the same sink-into-a-shared-package + * reason; the retired `indexed` key, #6810) is preserved in the moved module's + * docs and in #6562/#8115. + * + * ## Why a `/meta` read needs the pair at all (#6562) * * `GET /api/v1/meta/object/:name` answered a **different set of fields** * depending on which link of its resolution chain produced the answer: @@ -43,200 +51,29 @@ * orderable and enforced read-only on write. The maintainer's ruling * (2026-08-08) is Option B: the read serves the EFFECTIVE runtime schema, and * the overlay-backed minority path converges on the registry-backed majority. - * - * ## The key that used to sit beside this table: `indexed` (#6810, closed) - * - * `applySystemFields` used to stamp `indexed: ` on top of - * {@link TENANT_SCOPE_FIELD_DEF}, for the MongoDB driver's schema builder — the - * only consumer. `indexed` is **not a `FieldSchema` key**: it was removed in the - * 16.x line (#2377, ADR-0049) and `FieldSchema` is `strictObject`, so an object - * document carrying it is rejected BY NAME: - * - * ``` - * Unrecognized key(s) on this field: `indexed`. - * • never a FieldSchema key; a field-level index flag built no index (#2377). - * ``` - * - * Measured on `origin/main` (2026-08-08): a registry-backed `/meta` object read - * therefore answered `_diagnostics: { valid: false }` on exactly that key, in - * BOTH multiTenant modes — filed as #6810, and deliberately not inherited here, - * since converging the overlay-backed exit onto a key the object schema refuses - * would have spread that defect rather than closed #6562's. - * - * #6810 closed it at the injection site rather than here: the tenant index is - * declared in the object's `indexes[]` — the one surface an index is declared on - * — and no served field carries `indexed` on either exit any more. What this - * table carries is unchanged; there is simply nothing spread on top of it now. - * - * `multiTenant` was the *only* thing that key depended on, which is still why - * nothing in this module takes a `multiTenant` input: per - * `resolveInjectedSystemColumns`' own measurement, the flag changes whether - * `organization_id` is INDEXED, never whether it EXISTS. */ import { - AUDIT_PROVENANCE_FIELDS, - resolveInjectedSystemColumns, - type AuditProvenanceField, + injectedSystemColumnDefs, + isInjectedColumnDefinition, } from '@objectstack/spec/data'; -import { SystemFieldName } from '@objectstack/spec/system'; - -/** - * Column definitions for the audit-provenance family, keyed by the spec's - * {@link AUDIT_PROVENANCE_FIELDS} tuple — the canonical declaration of WHICH - * columns exist (#3786). This table owns only WHAT each column looks like. - * - * The `satisfies` clause is the sync mechanism: a name added to the spec tuple - * without a definition here — or a definition for a name the spec dropped — is - * a compile error, not a silently diverging copy. Same discipline as the spec's - * `APPROVER_VALUE_BINDINGS`. - * - * Moved here from `@objectstack/objectql`'s registry by #6562; see the module - * header for why, and {@link AUDIT_FIELD_GOVERNANCE} for the subset of these - * keys that is forced over a *declared* audit field rather than merely injected - * in its absence. - */ -export const AUDIT_FIELD_DEFS = { - created_at: { - type: 'datetime', - label: 'Created At', - required: false, - readonly: true, - system: true, - description: 'Timestamp when the record was created (auto-populated by the driver).', - }, - created_by: { - type: 'lookup', - reference: 'sys_user', - label: 'Created By', - required: false, - readonly: true, - system: true, - description: 'User who created the record (populated when an authenticated session is present).', - }, - updated_at: { - type: 'datetime', - label: 'Last Modified At', - required: false, - readonly: true, - system: true, - description: 'Timestamp of the most recent modification (auto-populated by the driver).', - }, - updated_by: { - type: 'lookup', - reference: 'sys_user', - label: 'Last Modified By', - required: false, - readonly: true, - system: true, - description: 'User who last modified the record (populated when an authenticated session is present).', - }, -} satisfies Record>; - -/** - * `organization_id` — THE tenant scope anchor, in its **authorable** shape. - * - * Spread verbatim by `applySystemFields` — nothing is layered on top of it. - * (#6810 removed the `indexed: opts.multiTenant` that used to be; the tenant - * index is declared in the object's `indexes[]` instead. See the module header.) - */ -export const TENANT_SCOPE_FIELD_DEF: Readonly> = { - type: 'lookup', - reference: 'sys_organization', - label: 'Organization', - required: false, - hidden: true, - readonly: true, - system: true, - description: - 'Tenant scope (auto-populated by org-scoping on insert; NULL on single-tenant stacks).', -}; -/** - * `owner_id` — the canonical reassignable owner. `system: true` marks it - * platform-provided (so tooling/migrations recognise it), but — unlike the audit - * `*_by` lookups — it is NOT `readonly`: ownership is transferable, so it stays - * editable in forms and assignable via the API. SecurityPlugin auto-stamps it to - * the acting user on insert when left NULL. - */ -export const OWNER_FIELD_DEF: Readonly> = { - type: 'lookup', - reference: 'sys_user', - label: 'Owner', - required: false, - readonly: false, - system: true, - description: - 'Record owner (auto-stamped to the creating user on insert; reassignable). ' + - 'Drives owner-scoped views, reports and notifications.', -}; - -/** - * [ADR-0117 D1] `owning_business_unit_id` — record-level business-unit - * ownership. Shaped after `organization_id` (a server-stamped scope anchor), NOT - * after `owner_id` (a user-assignable business field). The full reasoning for - * each of `readonly` / `hidden` / `required` — and for why the shape presumes - * nothing about the still-unruled D2 policy — stays at the injection site in - * `@objectstack/objectql`'s `applySystemFields`, which is where an author of the - * stamping middleware will be reading. - */ -export const OWNING_BUSINESS_UNIT_FIELD_DEF: Readonly> = { - type: 'lookup', - reference: 'sys_business_unit', - label: 'Owning Business Unit', - required: false, - hidden: true, - readonly: true, - system: true, - description: - 'Record-level business-unit ownership (ADR-0117 D1). Server-stamped scope anchor; ' + - 'NULL until the stamping middleware lands.', -}; - -/** - * The injected columns THIS object carries, as `name -> definition`. - * - * Gated entirely by {@link resolveInjectedSystemColumns} — every opt-out row - * (`systemFields: false`, `managedBy: 'better-auth'`, `systemFields.audit: - * false`, `tenancy.enabled: false`, the per-tier `ownership` table) is answered - * there and re-derived nowhere. `id` is deliberately absent although the plan - * reports it: the primary key is provisioned by the DRIVER - * (`table.string('id').primary()`), not by the injection pass, so no object - * document declares it and neither exit serves it. - * - * Tolerant of bare / un-parsed metadata records, the same contract the plan - * itself carries. - */ -export function injectedSystemColumnDefs(def: unknown): Record>> { - const plan = resolveInjectedSystemColumns(def); - const defs: Record>> = {}; - if (plan.tenant) defs[SystemFieldName.ORGANIZATION_ID] = TENANT_SCOPE_FIELD_DEF; - if (plan.audit) for (const name of AUDIT_PROVENANCE_FIELDS) defs[name] = AUDIT_FIELD_DEFS[name]; - if (plan.owner) defs[SystemFieldName.OWNER_ID] = OWNER_FIELD_DEF; - if (plan.owningBusinessUnit) defs[SystemFieldName.OWNING_BUSINESS_UNIT_ID] = OWNING_BUSINESS_UNIT_FIELD_DEF; - return defs; -} - -/** - * Is this field definition byte-for-byte the platform's own — i.e. a column the - * INJECTION put there, not something the author wrote? - * - * Shallow by construction: every value in the tables above is a primitive, so a - * key-count check plus strict per-key equality is exact. A nested or extra key - * therefore fails the comparison, and failure means "the author's field" — the - * conservative direction, since {@link stripInjectedSystemColumns} only ever - * removes what matches. A declared `owner_id` carrying the author's own label - * survives; one that happens to be identical to the platform definition is - * removed and re-injected identically, which is a no-op by inspection. - */ -function isInjectedDefinition(value: unknown, def: Readonly>): boolean { - if (!value || typeof value !== 'object' || Array.isArray(value)) return false; - const rec = value as Record; - const keys = Object.keys(rec); - if (keys.length !== Object.keys(def).length) return false; - for (const key of keys) if (rec[key] !== def[key]) return false; - return true; -} +// [#8116] Re-export shim — the definition tables and the #7865 provenance +// derivation moved to `@objectstack/spec/data` so author-time consumers can +// reach them; everything this module exported before the move is re-exported +// here unchanged (the ruling's "nothing downstream breaks" fence — the #7865 +// producer test file pins it by importing from this package's index). +export { + AUDIT_FIELD_DEFS, + TENANT_SCOPE_FIELD_DEF, + OWNER_FIELD_DEF, + OWNING_BUSINESS_UNIT_FIELD_DEF, + injectedSystemColumnDefs, + platformProvisionsStorage, + resolveInjectedColumnProvenance, + unprovisionedInjectedColumns, +} from '@objectstack/spec/data'; +export type { InjectedColumnProvenance } from '@objectstack/spec/data'; /** The `fields` record of a metadata document, or `undefined` when it has none. */ function fieldsOf(doc: unknown): Record | undefined { @@ -298,8 +135,8 @@ export function applyInjectedSystemColumns(doc: T): T { * must not be folded into that one — a read decoration is derived diagnostics * that no schema accepts, whereas these are real, spec-valid field declarations * an author may legitimately write. Hence the exactness of - * {@link isInjectedDefinition}: only a field identical to the platform's own is - * removed. + * `isInjectedColumnDefinition` (`@objectstack/spec/data`): only a field + * identical to the platform's own is removed. * * Returns the **same reference** when nothing needed removing. Pure and total. */ @@ -309,7 +146,7 @@ export function stripInjectedSystemColumns(doc: T): T { let kept: Record | undefined; for (const [name, def] of Object.entries(injectedSystemColumnDefs(doc))) { - if (!isInjectedDefinition(declared[name], def)) continue; + if (!isInjectedColumnDefinition(declared[name], def)) continue; kept ??= { ...declared }; delete kept[name]; } @@ -317,176 +154,3 @@ export function stripInjectedSystemColumns(doc: T): T { return { ...(doc as unknown as Record), fields: kept } as unknown as T; } - -// --------------------------------------------------------------------------- -// [#7865] Injected-column PROVENANCE — the one authoritative answer to -// "is this column actually provisioned by the platform?" -// --------------------------------------------------------------------------- - -/** - * [#7865] Does the platform provision storage for this object's schema? - * - * `false` exactly when the object carries an ADR-0015 `external` binding: the - * remote database owns the schema, `Engine.syncObjectSchema` returns early and - * issues no DDL, and `SqlDriver.registerExternalObject` is DDL-free by design. - * This is the same `external != null` predicate `syncObjectSchema` routes a - * federated object by — ONE spelling of "this schema is the remote's", exported - * so consumers stop re-spelling it (`isFederated` in `Engine.buildDriverOptions` - * / PR #7833 and `isFederatedObject` in plugin-security / PR #7859 are the two - * existing hand-rolled copies; both converge here when next touched, per the - * 2026-08-12 maintainer ruling on #7865). - * - * Tolerant of bare / un-parsed metadata records, like everything in this module. - */ -export function platformProvisionsStorage(def: unknown): boolean { - if (!def || typeof def !== 'object' || Array.isArray(def)) return true; - return (def as { external?: unknown }).external == null; -} - -/** - * [#7865] Provenance verdict for one column on one object document — the - * machine-readable marker the 2026-08-12 maintainer ruling ordered (direction - * B: keep injecting, mark the injected anchors), in its API spelling. - * - * - `'injected-provisioned'` — the platform's own injected anchor, with real - * storage behind it: the object's storage is platform-provisioned, so the - * column exists in the table exactly as registered. - * - `'injected-unprovisioned'` — **the marker**: the platform's own injected - * anchor on an object the platform provisions NO storage for (ADR-0015 - * `external`). The column exists in the registered schema and nowhere else; - * a predicate over it can never resolve — on SQLite it degrades to a string - * literal and the query goes constant-false (HTTP 200, zero rows, no error). - * - `'author'` — the author declared this field; the platform makes no storage - * claim about it. On a local object it is provisioned like any declared - * field; on a federated object it maps a remote column the author vouches - * for. Consumers must treat it as REAL — a federated object may legitimately - * expose a real remote `organization_id`, and its tenant wall must keep - * working (#7859's recorded reasoning). - * - `'absent'` — not a column the injection provides on this object, and not - * declared either. (Note `id` always answers `'absent'`: the primary key is - * the DRIVER's, not this pass's — `resolveInjectedSystemColumns` reports it - * as addressable, but no injected definition exists for it, and on a - * federated object the remote's own primary key backs it via the binding.) - * - * ## Why an exported derivation and NOT a `provisioned: false` key in the data - * - * The ruling's literal illustration ("`provisioned: false` or an equivalent") - * cannot land as a key on the injected field definitions without moving - * surfaces the ruling fenced off, so this API is the equivalent: - * - * 1. `FieldSchema` is `strictObject` — an undeclared key on a served document - * is rejected BY NAME, and `/meta` serves the post-injection document, so - * the key would stamp `_diagnostics: { valid: false }` on every federated - * object (the exact #6810 defect, closed once already). Declaring the key - * instead would make it AUTHORABLE, handing authors a switch that turns - * their own tenant wall off — the shape plugin-security's - * `federated-phantom-anchors.ts` records as deliberately rejected. - * 2. Three consumers read the anchor definitions by EXACT identity — the - * #4326 round-trip strip above ({@link stripInjectedSystemColumns}), the - * #7859 Layer-0 guard (`equalsShippedDef`, key-count strict), and the - * stored-vs-shipped no-op check in {@link isInjectedDefinition}'s doc. A - * new key on the external-object anchors flips every one of them from - * "the platform's anchor" to "the author's field" — for the Layer-0 guard - * that re-emits the phantom tenant predicate, resurrecting the measured - * zero-rows defect this family of fixes closed. - * 3. The #7865 fence: the marker must not change what any consumer accepts. - * This derivation changes no document byte anywhere — registered, served, - * stored — which is what makes the three no-regression proofs exact. - * - * ## Convergence map (opportunistic, per the ruling — NOT rewritten in #7865's PR) - * - * - #7833 (engine): `isFederated` ⇒ `!platformProvisionsStorage(schema)`. - * - #7859 (plugin-security): `hasPhantomTenantAnchor(schema)` ⇒ - * `resolveInjectedColumnProvenance(schema, 'organization_id') === 'injected-unprovisioned'`. - * - #7858 (plugin-sharing): the `owner_id` twin of #7859. - * - * ## Fail direction - * - * Any mismatch — an extra key, a stamped default, an unrecognisable shape — - * answers `'author'`: the consumer keeps enforcing exactly as it does today. - * Toward isolation, never toward exposure; the same direction the #7859 guard - * documents. - * - * Accepts both registered `fields` shapes (record and array); the array shape's - * extra `name` key is excluded from the identity comparison, exactly as the - * #7859 guard excludes it, so both shapes reach the same verdict. - */ -export type InjectedColumnProvenance = - | 'author' - | 'injected-provisioned' - | 'injected-unprovisioned' - | 'absent'; - -/** See {@link InjectedColumnProvenance} — the verdict, and the doc, live together. */ -export function resolveInjectedColumnProvenance( - def: unknown, - column: string, -): InjectedColumnProvenance { - const injectedDef = injectedSystemColumnDefs(def)[column]; - const declared = readDeclaredFieldDef(def, column); - if (injectedDef === undefined) { - return declared.present ? 'author' : 'absent'; - } - // Absent from the document ⇒ the injection provides it at registration - // (pre-injection input); identical to the platform's definition ⇒ the - // injection wrote it (post-injection input), or the author typed a - // byte-identical copy — indistinguishable and semantically equivalent, the - // same reasoning {@link stripInjectedSystemColumns} records. Anything else — - // including a present-but-unrecognisable value — is the author's field (the - // fail direction above). - const isPlatformAnchor = - !declared.present || (declared.def !== undefined && isInjectedDefinition(declared.def, injectedDef)); - if (!isPlatformAnchor) return 'author'; - return platformProvisionsStorage(def) ? 'injected-provisioned' : 'injected-unprovisioned'; -} - -/** - * [#7865] The injected columns this object carries with NO storage behind them - * — the enumerable form of the marker. Empty for every object whose storage - * the platform provisions, and for a federated object exactly the injected - * anchors whose registered definition is the platform's own (an - * author-declared column of the same name is the author's and is excluded, in - * both `fields` shapes). Order follows {@link injectedSystemColumnDefs}. - */ -export function unprovisionedInjectedColumns(def: unknown): string[] { - if (platformProvisionsStorage(def)) return []; - return Object.keys(injectedSystemColumnDefs(def)).filter( - (name) => resolveInjectedColumnProvenance(def, name) === 'injected-unprovisioned', - ); -} - -/** - * Read one declared field definition off either `fields` shape — the record - * shape (`fields: { organization_id: {...} }`) or the array shape - * (`fields: [{ name: 'organization_id', ... }]`). The array element's `name` - * key duplicates what the record shape expresses as the map key, so it is - * removed before the identity comparison — the same exclusion the #7859 - * guard's `equalsShippedDef` applies, for the same reason: both shapes must - * reach the same verdict about the same column. - * - * `present` distinguishes "the document does not mention this column" (the - * injection provides it) from "the document mentions it in a shape this module - * cannot read" (the author's — {@link resolveInjectedColumnProvenance}'s fail - * direction requires the two to answer differently). - */ -function readDeclaredFieldDef( - doc: unknown, - name: string, -): { present: boolean; def?: Record } { - if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { present: false }; - const fields = (doc as { fields?: unknown }).fields; - if (Array.isArray(fields)) { - const found = fields.find( - (f) => !!f && typeof f === 'object' && (f as { name?: unknown }).name === name, - ); - if (found === undefined) return { present: false }; - const copy = { ...(found as Record) }; - delete copy.name; - return { present: true, def: copy }; - } - if (!fields || typeof fields !== 'object') return { present: false }; - const value = (fields as Record)[name]; - if (value === undefined) return { present: false }; - if (!value || typeof value !== 'object' || Array.isArray(value)) return { present: true }; - return { present: true, def: value as Record }; -} diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 97527ea83f..c6acc2fcba 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -186,10 +186,17 @@ export * from './record-surface'; // injected-system-column derivation (#5378) — the single source for WHICH system // columns an object carries without declaring them. Consumed by the registry's -// `applySystemFields` (which owns the column definitions) and by author-time -// tooling that must resolve a reference to one but cannot load a runtime. +// `applySystemFields` and by author-time tooling that must resolve a reference +// to one but cannot load a runtime. export * from './injected-system-columns'; +// injected-system-column DEFINITIONS + the #7865 provenance marker (#8116) — +// WHAT each injected column looks like, and whether storage actually backs it +// on a given object (`external` objects register anchors the platform never +// provisions). Moved here from `@objectstack/metadata-core` (which re-exports +// it) so `@objectstack/lint` — spec-only by contract — can warn at author time. +export * from './injected-system-column-provenance'; + // Feed & Activity Protocol — retains only the UI activity-timeline config enums // (FeedItemType / FeedFilterMode); the feed backend contracts were retired (ADR-0052 §5). export * from './feed.zod'; diff --git a/packages/spec/src/data/injected-system-column-provenance.test.ts b/packages/spec/src/data/injected-system-column-provenance.test.ts new file mode 100644 index 0000000000..dd546ce15f --- /dev/null +++ b/packages/spec/src/data/injected-system-column-provenance.test.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8116] The injected-column definition tables + #7865 provenance derivation, + * at their post-move home. The full decision matrix (every opt-out row, both + * `fields` shapes, the fail directions) is pinned by the producer test that + * moved WITH the metadata-core re-export — + * `packages/metadata-core/test/injected-column-provenance.test.ts` — which now + * doubles as the "nothing downstream breaks" proof: it imports every one of + * these names from `@objectstack/metadata-core`'s index and must keep passing + * against the shim unchanged. This file pins what is NEW here: the spec is the + * declaration site (author-time consumers import from `@objectstack/spec/data` + * with no runtime package on the path), and the newly-exported identity + * predicate keeps the strip/provenance verdicts in one spelling. + */ + +import { describe, it, expect } from 'vitest'; + +import { + AUDIT_FIELD_DEFS, + TENANT_SCOPE_FIELD_DEF, + OWNER_FIELD_DEF, + OWNING_BUSINESS_UNIT_FIELD_DEF, + injectedSystemColumnDefs, + isInjectedColumnDefinition, + platformProvisionsStorage, + resolveInjectedColumnProvenance, + unprovisionedInjectedColumns, +} from './injected-system-column-provenance'; + +/** The seven anchors #7865's showcase measurement counted on a federated object. */ +const SEVEN_ANCHORS = [ + 'organization_id', + 'created_at', + 'created_by', + 'updated_at', + 'updated_by', + 'owner_id', + 'owning_business_unit_id', +] as const; + +const external = () => ({ + name: 'showcase_ext_customer', + external: { remoteName: 'customers' }, + fields: { email: { type: 'text', label: 'Email' } }, +}); + +const local = () => ({ + name: 'showcase_customer', + fields: { email: { type: 'text', label: 'Email' } }, +}); + +describe('[#8116] provenance derivation at its spec home', () => { + it('marks all seven anchors unprovisioned on an external object, none on the local twin', () => { + expect(unprovisionedInjectedColumns(external()).sort()).toEqual([...SEVEN_ANCHORS].sort()); + expect(unprovisionedInjectedColumns(local())).toEqual([]); + for (const anchor of SEVEN_ANCHORS) { + expect(resolveInjectedColumnProvenance(external(), anchor), anchor).toBe( + 'injected-unprovisioned', + ); + expect(resolveInjectedColumnProvenance(local(), anchor), anchor).toBe( + 'injected-provisioned', + ); + } + }); + + it("SECURITY DIRECTION: an author-declared organization_id on a federated object stays 'author'", () => { + // #7859's recorded reasoning — a federated object may expose a REAL remote + // organization_id, and its tenant wall must keep working. The lint warning + // built on this derivation (#8116) must therefore stay silent here. + const declaredReal = { + ...external(), + fields: { + email: { type: 'text', label: 'Email' }, + organization_id: { type: 'text', label: 'Remote Org Key' }, + }, + }; + expect(resolveInjectedColumnProvenance(declaredReal, 'organization_id')).toBe('author'); + expect(unprovisionedInjectedColumns(declaredReal)).not.toContain('organization_id'); + }); + + it('respects the injection opt-outs — a withheld anchor is absent even on external objects', () => { + expect(resolveInjectedColumnProvenance({ ...external(), ownership: 'none' }, 'owner_id')).toBe( + 'absent', + ); + expect(unprovisionedInjectedColumns({ ...external(), systemFields: false })).toEqual([]); + }); + + it('platformProvisionsStorage is the ADR-0015 external != null predicate, total over bare input', () => { + expect(platformProvisionsStorage(local())).toBe(true); + expect(platformProvisionsStorage(external())).toBe(false); + expect(platformProvisionsStorage({ name: 'x', external: null })).toBe(true); + expect(platformProvisionsStorage(undefined)).toBe(true); + }); + + it('isInjectedColumnDefinition (newly public, #8116) reproduces the strip/provenance identity verdict', () => { + // Byte-identical copy of a table ⇒ the platform's anchor. + expect(isInjectedColumnDefinition({ ...OWNER_FIELD_DEF }, OWNER_FIELD_DEF)).toBe(true); + // Any mismatch — extra key, changed value, unrecognisable shape ⇒ the + // author's field (the conservative direction the strip and #7859 rely on). + expect( + isInjectedColumnDefinition({ ...TENANT_SCOPE_FIELD_DEF, extra: true }, TENANT_SCOPE_FIELD_DEF), + ).toBe(false); + expect( + isInjectedColumnDefinition( + { ...TENANT_SCOPE_FIELD_DEF, readonly: false }, + TENANT_SCOPE_FIELD_DEF, + ), + ).toBe(false); + expect(isInjectedColumnDefinition(true, TENANT_SCOPE_FIELD_DEF)).toBe(false); + }); + + it('injectedSystemColumnDefs serves the tables verbatim (the marker adds NOTHING to the data)', () => { + const defs = injectedSystemColumnDefs(external()); + expect(defs.organization_id).toEqual(TENANT_SCOPE_FIELD_DEF); + expect(defs.owner_id).toEqual(OWNER_FIELD_DEF); + expect(defs.owning_business_unit_id).toEqual(OWNING_BUSINESS_UNIT_FIELD_DEF); + expect(defs.created_at).toEqual(AUDIT_FIELD_DEFS.created_at); + // `id` is the driver's column — never in the defs, never in the marker. + expect(defs.id).toBeUndefined(); + expect(resolveInjectedColumnProvenance(external(), 'id')).toBe('absent'); + }); +}); diff --git a/packages/spec/src/data/injected-system-column-provenance.ts b/packages/spec/src/data/injected-system-column-provenance.ts new file mode 100644 index 0000000000..639e775cb5 --- /dev/null +++ b/packages/spec/src/data/injected-system-column-provenance.ts @@ -0,0 +1,389 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Injected-system-column DEFINITIONS, and the [#7865] PROVENANCE derivation + * over them — the one authoritative answer to *"is this column actually + * provisioned by the platform?"*, readable by author-time tools. + * + * ## Why this lives in the spec (#8116) + * + * The provenance derivation landed in `@objectstack/metadata-core` (#7865 / + * PR #8115) — structurally unreachable from `@objectstack/lint`, whose package + * contract is *"depends on `@objectstack/spec`; never on a runtime"*. So an + * author writing `record.owner_id` in an expression, view filter or highlight + * on an ADR-0015 `external` object got a clean lint pass, and the failure + * surfaced at query time, silently, on the default dev dialect (constant-false: + * HTTP 200, zero rows, no error). + * + * The maintainer ruling on #8116 (2026-08-12) is option 1: sink the derivation + * into the contract package rather than grant lint an exception to its + * no-runtime rule — the exception would outlive its reason and become precedent + * for the next runtime import. Every input here is a document-declared key + * (`external`, `fields`, the `resolveInjectedSystemColumns` plan inputs), so + * the derivation is spec-representable with no runtime dependency, exactly like + * the WHICH-half derivation it sits beside. + * + * This is the WHAT-half move #3786 anticipated, scoped to the provenance + * predicate and the definition tables it reads. The division of ownership is + * now: **this module declares WHICH columns exist per object + * (`injected-system-columns.ts`) and WHAT each one looks like (the tables + * below); the runtime keeps the INJECTION** — `applySystemFields` + * (`@objectstack/objectql`) spreads these tables at registration, and the + * served-document injection/strip pair (#6562) stays in + * `@objectstack/metadata-core`, which re-exports everything moved here so no + * downstream import changes (#8116's "nothing downstream breaks" fence). + * + * Everything in this module is tolerant of bare / un-parsed metadata records + * (same contract as `resolveInjectedSystemColumns`), so every consumer can call + * it, including on input that has not been through Zod. + */ + +import { AUDIT_PROVENANCE_FIELDS, type AuditProvenanceField } from './field-group-layout'; +import { resolveInjectedSystemColumns } from './injected-system-columns'; +import { SystemFieldName } from '../system/constants/system-names'; + +/** + * Column definitions for the audit-provenance family, keyed by + * {@link AUDIT_PROVENANCE_FIELDS} — the canonical declaration of WHICH columns + * exist (#3786). This table owns WHAT each column looks like. + * + * The `satisfies` clause is the sync mechanism: a name added to the tuple + * without a definition here — or a definition for a name the tuple dropped — + * is a compile error, not a silently diverging copy. + * + * Moved from `@objectstack/objectql`'s registry to `@objectstack/metadata-core` + * by #6562 (so the `/meta` read path could reach it) and from there to the spec + * by #8116 (so author-time tools can reach the provenance derivation below); + * see the module header for the ruling. The subset of these keys that is forced + * over a *declared* audit field lives with `applyAuditFieldGovernance` + * (`@objectstack/metadata-core`), unchanged. + */ +export const AUDIT_FIELD_DEFS = { + created_at: { + type: 'datetime', + label: 'Created At', + required: false, + readonly: true, + system: true, + description: 'Timestamp when the record was created (auto-populated by the driver).', + }, + created_by: { + type: 'lookup', + reference: 'sys_user', + label: 'Created By', + required: false, + readonly: true, + system: true, + description: 'User who created the record (populated when an authenticated session is present).', + }, + updated_at: { + type: 'datetime', + label: 'Last Modified At', + required: false, + readonly: true, + system: true, + description: 'Timestamp of the most recent modification (auto-populated by the driver).', + }, + updated_by: { + type: 'lookup', + reference: 'sys_user', + label: 'Last Modified By', + required: false, + readonly: true, + system: true, + description: 'User who last modified the record (populated when an authenticated session is present).', + }, +} satisfies Record>; + +/** + * `organization_id` — THE tenant scope anchor, in its **authorable** shape. + * + * Spread verbatim by `applySystemFields` — nothing is layered on top of it. + * (#6810 removed the `indexed: opts.multiTenant` that used to be; the tenant + * index is declared in the object's `indexes[]` instead.) + */ +export const TENANT_SCOPE_FIELD_DEF: Readonly> = { + type: 'lookup', + reference: 'sys_organization', + label: 'Organization', + required: false, + hidden: true, + readonly: true, + system: true, + description: + 'Tenant scope (auto-populated by org-scoping on insert; NULL on single-tenant stacks).', +}; + +/** + * `owner_id` — the canonical reassignable owner. `system: true` marks it + * platform-provided (so tooling/migrations recognise it), but — unlike the audit + * `*_by` lookups — it is NOT `readonly`: ownership is transferable, so it stays + * editable in forms and assignable via the API. SecurityPlugin auto-stamps it to + * the acting user on insert when left NULL. + */ +export const OWNER_FIELD_DEF: Readonly> = { + type: 'lookup', + reference: 'sys_user', + label: 'Owner', + required: false, + readonly: false, + system: true, + description: + 'Record owner (auto-stamped to the creating user on insert; reassignable). ' + + 'Drives owner-scoped views, reports and notifications.', +}; + +/** + * [ADR-0117 D1] `owning_business_unit_id` — record-level business-unit + * ownership. Shaped after `organization_id` (a server-stamped scope anchor), NOT + * after `owner_id` (a user-assignable business field). The full reasoning for + * each of `readonly` / `hidden` / `required` — and for why the shape presumes + * nothing about the still-unruled D2 policy — stays at the injection site in + * `@objectstack/objectql`'s `applySystemFields`, which is where an author of the + * stamping middleware will be reading. + */ +export const OWNING_BUSINESS_UNIT_FIELD_DEF: Readonly> = { + type: 'lookup', + reference: 'sys_business_unit', + label: 'Owning Business Unit', + required: false, + hidden: true, + readonly: true, + system: true, + description: + 'Record-level business-unit ownership (ADR-0117 D1). Server-stamped scope anchor; ' + + 'NULL until the stamping middleware lands.', +}; + +/** + * The injected columns THIS object carries, as `name -> definition`. + * + * Gated entirely by {@link resolveInjectedSystemColumns} — every opt-out row + * (`systemFields: false`, `managedBy: 'better-auth'`, `systemFields.audit: + * false`, `tenancy.enabled: false`, the per-tier `ownership` table) is answered + * there and re-derived nowhere. `id` is deliberately absent although the plan + * reports it: the primary key is provisioned by the DRIVER + * (`table.string('id').primary()`), not by the injection pass, so no object + * document declares it and neither `/meta` exit serves it. + * + * Tolerant of bare / un-parsed metadata records, the same contract the plan + * itself carries. + */ +export function injectedSystemColumnDefs(def: unknown): Record>> { + const plan = resolveInjectedSystemColumns(def); + const defs: Record>> = {}; + if (plan.tenant) defs[SystemFieldName.ORGANIZATION_ID] = TENANT_SCOPE_FIELD_DEF; + if (plan.audit) for (const name of AUDIT_PROVENANCE_FIELDS) defs[name] = AUDIT_FIELD_DEFS[name]; + if (plan.owner) defs[SystemFieldName.OWNER_ID] = OWNER_FIELD_DEF; + if (plan.owningBusinessUnit) defs[SystemFieldName.OWNING_BUSINESS_UNIT_ID] = OWNING_BUSINESS_UNIT_FIELD_DEF; + return defs; +} + +/** + * Is this field definition byte-for-byte the platform's own — i.e. a column the + * INJECTION put there, not something the author wrote? + * + * Shallow by construction: every value in the tables above is a primitive, so a + * key-count check plus strict per-key equality is exact. A nested or extra key + * therefore fails the comparison, and failure means "the author's field" — the + * conservative direction, since `stripInjectedSystemColumns` + * (`@objectstack/metadata-core`) only ever removes what matches. A declared + * `owner_id` carrying the author's own label survives; one that happens to be + * identical to the platform definition is removed and re-injected identically, + * which is a no-op by inspection. + * + * Exported (it was private to `metadata-core` before #8116) because two + * consumers on two sides of the package boundary need the SAME identity + * verdict: the #4326 round-trip strip in `@objectstack/metadata-core`, and + * {@link resolveInjectedColumnProvenance} below. It is also the convergence + * target for the hand-rolled `equalsShippedDef` copies the #7865 ruling lists + * (plugin-security / plugin-sharing), when next touched. + */ +export function isInjectedColumnDefinition( + value: unknown, + def: Readonly>, +): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const rec = value as Record; + const keys = Object.keys(rec); + if (keys.length !== Object.keys(def).length) return false; + for (const key of keys) if (rec[key] !== def[key]) return false; + return true; +} + +// --------------------------------------------------------------------------- +// [#7865] Injected-column PROVENANCE — the one authoritative answer to +// "is this column actually provisioned by the platform?" +// --------------------------------------------------------------------------- + +/** + * [#7865] Does the platform provision storage for this object's schema? + * + * `false` exactly when the object carries an ADR-0015 `external` binding: the + * remote database owns the schema, `Engine.syncObjectSchema` returns early and + * issues no DDL, and `SqlDriver.registerExternalObject` is DDL-free by design. + * This is the same `external != null` predicate `syncObjectSchema` routes a + * federated object by — ONE spelling of "this schema is the remote's", exported + * so consumers stop re-spelling it (`isFederated` in `Engine.buildDriverOptions` + * / PR #7833 and `isFederatedObject` in plugin-security / PR #7859 are the two + * existing hand-rolled copies; both converge here when next touched, per the + * 2026-08-12 maintainer ruling on #7865). + * + * Tolerant of bare / un-parsed metadata records, like everything in this module. + */ +export function platformProvisionsStorage(def: unknown): boolean { + if (!def || typeof def !== 'object' || Array.isArray(def)) return true; + return (def as { external?: unknown }).external == null; +} + +/** + * [#7865] Provenance verdict for one column on one object document — the + * machine-readable marker the 2026-08-12 maintainer ruling ordered (direction + * B: keep injecting, mark the injected anchors), in its API spelling. + * + * - `'injected-provisioned'` — the platform's own injected anchor, with real + * storage behind it: the object's storage is platform-provisioned, so the + * column exists in the table exactly as registered. + * - `'injected-unprovisioned'` — **the marker**: the platform's own injected + * anchor on an object the platform provisions NO storage for (ADR-0015 + * `external`). The column exists in the registered schema and nowhere else; + * a predicate over it can never resolve — on SQLite it degrades to a string + * literal and the query goes constant-false (HTTP 200, zero rows, no error). + * - `'author'` — the author declared this field; the platform makes no storage + * claim about it. On a local object it is provisioned like any declared + * field; on a federated object it maps a remote column the author vouches + * for. Consumers must treat it as REAL — a federated object may legitimately + * expose a real remote `organization_id`, and its tenant wall must keep + * working (#7859's recorded reasoning). + * - `'absent'` — not a column the injection provides on this object, and not + * declared either. (Note `id` always answers `'absent'`: the primary key is + * the DRIVER's, not this pass's — `resolveInjectedSystemColumns` reports it + * as addressable, but no injected definition exists for it, and on a + * federated object the remote's own primary key backs it via the binding.) + * + * ## Why an exported derivation and NOT a `provisioned: false` key in the data + * + * The ruling's literal illustration ("`provisioned: false` or an equivalent") + * cannot land as a key on the injected field definitions without moving + * surfaces the ruling fenced off, so this API is the equivalent: + * + * 1. `FieldSchema` is `strictObject` — an undeclared key on a served document + * is rejected BY NAME, and `/meta` serves the post-injection document, so + * the key would stamp `_diagnostics: { valid: false }` on every federated + * object (the exact #6810 defect, closed once already). Declaring the key + * instead would make it AUTHORABLE, handing authors a switch that turns + * their own tenant wall off — the shape plugin-security's + * `federated-phantom-anchors.ts` records as deliberately rejected. + * 2. Three consumers read the anchor definitions by EXACT identity — the + * #4326 round-trip strip (`stripInjectedSystemColumns`, + * `@objectstack/metadata-core`), the #7859 Layer-0 guard + * (`equalsShippedDef`, key-count strict), and the stored-vs-shipped no-op + * check in {@link isInjectedColumnDefinition}'s doc. A new key on the + * external-object anchors flips every one of them from "the platform's + * anchor" to "the author's field" — for the Layer-0 guard that re-emits the + * phantom tenant predicate, resurrecting the measured zero-rows defect this + * family of fixes closed. + * 3. The #7865 fence: the marker must not change what any consumer accepts. + * This derivation changes no document byte anywhere — registered, served, + * stored — which is what makes the three no-regression proofs exact. + * + * ## Convergence map (opportunistic, per the ruling — NOT rewritten here) + * + * - #7833 (engine): `isFederated` ⇒ `!platformProvisionsStorage(schema)`. + * - #7859 (plugin-security): `hasPhantomTenantAnchor(schema)` ⇒ + * `resolveInjectedColumnProvenance(schema, 'organization_id') === 'injected-unprovisioned'`. + * - #7858 (plugin-sharing): the `owner_id` twin of #7859. + * - #8116 (lint): the author-time consumer this module moved to the spec for — + * expression / semantic-role validation reads the marker and warns. + * + * ## Fail direction + * + * Any mismatch — an extra key, a stamped default, an unrecognisable shape — + * answers `'author'`: the consumer keeps enforcing exactly as it does today. + * Toward isolation, never toward exposure; the same direction the #7859 guard + * documents. + * + * Accepts both registered `fields` shapes (record and array); the array shape's + * extra `name` key is excluded from the identity comparison, exactly as the + * #7859 guard excludes it, so both shapes reach the same verdict. + */ +export type InjectedColumnProvenance = + | 'author' + | 'injected-provisioned' + | 'injected-unprovisioned' + | 'absent'; + +/** See {@link InjectedColumnProvenance} — the verdict, and the doc, live together. */ +export function resolveInjectedColumnProvenance( + def: unknown, + column: string, +): InjectedColumnProvenance { + const injectedDef = injectedSystemColumnDefs(def)[column]; + const declared = readDeclaredFieldDef(def, column); + if (injectedDef === undefined) { + return declared.present ? 'author' : 'absent'; + } + // Absent from the document ⇒ the injection provides it at registration + // (pre-injection input); identical to the platform's definition ⇒ the + // injection wrote it (post-injection input), or the author typed a + // byte-identical copy — indistinguishable and semantically equivalent, the + // same reasoning `stripInjectedSystemColumns` records. Anything else — + // including a present-but-unrecognisable value — is the author's field (the + // fail direction above). + const isPlatformAnchor = + !declared.present || + (declared.def !== undefined && isInjectedColumnDefinition(declared.def, injectedDef)); + if (!isPlatformAnchor) return 'author'; + return platformProvisionsStorage(def) ? 'injected-provisioned' : 'injected-unprovisioned'; +} + +/** + * [#7865] The injected columns this object carries with NO storage behind them + * — the enumerable form of the marker. Empty for every object whose storage + * the platform provisions, and for a federated object exactly the injected + * anchors whose registered definition is the platform's own (an + * author-declared column of the same name is the author's and is excluded, in + * both `fields` shapes). Order follows {@link injectedSystemColumnDefs}. + */ +export function unprovisionedInjectedColumns(def: unknown): string[] { + if (platformProvisionsStorage(def)) return []; + return Object.keys(injectedSystemColumnDefs(def)).filter( + (name) => resolveInjectedColumnProvenance(def, name) === 'injected-unprovisioned', + ); +} + +/** + * Read one declared field definition off either `fields` shape — the record + * shape (`fields: { organization_id: {...} }`) or the array shape + * (`fields: [{ name: 'organization_id', ... }]`). The array element's `name` + * key duplicates what the record shape expresses as the map key, so it is + * removed before the identity comparison — the same exclusion the #7859 + * guard's `equalsShippedDef` applies, for the same reason: both shapes must + * reach the same verdict about the same column. + * + * `present` distinguishes "the document does not mention this column" (the + * injection provides it) from "the document mentions it in a shape this module + * cannot read" (the author's — {@link resolveInjectedColumnProvenance}'s fail + * direction requires the two to answer differently). + */ +function readDeclaredFieldDef( + doc: unknown, + name: string, +): { present: boolean; def?: Record } { + if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { present: false }; + const fields = (doc as { fields?: unknown }).fields; + if (Array.isArray(fields)) { + const found = fields.find( + (f) => !!f && typeof f === 'object' && (f as { name?: unknown }).name === name, + ); + if (found === undefined) return { present: false }; + const copy = { ...(found as Record) }; + delete copy.name; + return { present: true, def: copy }; + } + if (!fields || typeof fields !== 'object') return { present: false }; + const value = (fields as Record)[name]; + if (value === undefined) return { present: false }; + if (!value || typeof value !== 'object' || Array.isArray(value)) return { present: true }; + return { present: true, def: value as Record }; +} diff --git a/packages/spec/src/data/injected-system-columns.ts b/packages/spec/src/data/injected-system-columns.ts index 1ce669ba13..3679d0c4e4 100644 --- a/packages/spec/src/data/injected-system-columns.ts +++ b/packages/spec/src/data/injected-system-columns.ts @@ -30,9 +30,12 @@ * This module is that answer, in the one place both a runtime and an * author-time tool can read. It is the same split #3786 established for the * audit family and the `AUDIT_FIELD_DEFS` table records: **the spec declares - * WHICH columns exist; the registry owns WHAT each one looks like.** Here the - * spec declares which ones exist *on a given object*; `applySystemFields` - * consumes this plan and keeps sole ownership of the column shapes. + * WHICH columns exist; the registry owns the injection.** Here the spec + * declares which ones exist *on a given object*; `applySystemFields` consumes + * this plan when it injects. (The column DEFINITIONS — WHAT each one looks + * like — and the #7865 provenance marker over them moved into the sibling + * `injected-system-column-provenance.ts` by #8116, the WHAT-half move #3786 + * anticipated, so author-time tools can ask the storage question too.) * * ## Why it is a pure derivation, and total * From d212db942d7d60fc2284422a40107d5887a6f267 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 05:56:34 +0000 Subject: [PATCH 2/5] 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 Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2 --- packages/lint/src/validate-expressions.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index c9a9f202ce..78bd4180b4 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -2197,6 +2197,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([]); }); From f369cc00387768746cb490d32119b1c2c5494100 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 06:14:35 +0000 Subject: [PATCH 3/5] chore(spec): regenerate api-surface + export-origins for the #8116 data exports (10 added, 0 removed) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2 --- packages/spec/api-surface/data.json | 10 ++++++++++ packages/spec/export-origins/data.json | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 967f80d553..a881003433 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -11,6 +11,7 @@ "API_METHOD_ORDER (const)", "API_OPERATION_ORDER (const)", "API_PRIMITIVES (const)", + "AUDIT_FIELD_DEFS (const)", "AUDIT_PROVENANCE_FIELDS (const)", "Address (type)", "AddressSchema (const)", @@ -318,6 +319,7 @@ "ImportFieldMappingParsed (type)", "ImportFieldMappingSchema (const)", "IndexSchema (const)", + "InjectedColumnProvenance (type)", "InjectedSystemColumnPlan (interface)", "InstantValue (type)", "InstantValueSchema (const)", @@ -389,6 +391,8 @@ "NormalizedFilter (type)", "NormalizedFilterSchema (const)", "OBJECT_KEY_GUIDANCE (const)", + "OWNER_FIELD_DEF (const)", + "OWNING_BUSINESS_UNIT_FIELD_DEF (const)", "ObjectAccessConfig (type)", "ObjectAccessConfigParsed (type)", "ObjectAccessConfigSchema (const)", @@ -570,6 +574,7 @@ "TEMPORAL_ROWS (const)", "TEMPORAL_TIME_CASES (const)", "TEMPORAL_TIME_ROWS (const)", + "TENANT_SCOPE_FIELD_DEF (const)", "TITLE_ELIGIBLE (const)", "TITLE_ELIGIBLE_TYPES (const)", "TITLE_INELIGIBLE_TYPES (const)", @@ -641,6 +646,7 @@ "hasDanglingLikeEscape (function)", "hasDynamicTokens (function)", "hookForm (const)", + "injectedSystemColumnDefs (function)", "isAcceptedFilterComparand (function)", "isApiOperationAllowed (function)", "isApiPrimitive (function)", @@ -654,6 +660,7 @@ "isFilterAST (function)", "isGlobalUnique (function)", "isIncoherentAggregate (function)", + "isInjectedColumnDefinition (function)", "isKnownFilterToken (function)", "isLegacyApiMethod (function)", "isMultiValueField (function)", @@ -678,6 +685,7 @@ "parseDateMacroParam (function)", "parseFilterAST (function)", "percentScaleOf (function)", + "platformProvisionsStorage (function)", "provisionPrimary (function)", "readAutonumberCounter (function)", "reduceFilterKeyVerdict (function)", @@ -693,6 +701,7 @@ "resolveDisplayField (function)", "resolveDriverId (function)", "resolveEffectiveApiMethods (function)", + "resolveInjectedColumnProvenance (function)", "resolveInjectedSystemColumns (function)", "resolveRecordDisplayName (function)", "resolveSearchFieldResolution (function)", @@ -701,6 +710,7 @@ "stripLegacyApiMethods (function)", "suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType (function)", + "unprovisionedInjectedColumns (function)", "utcInstantMs (function)", "validateDriverConfig (function)", "valueSchemaFor (function)" diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index e2611b87fa..f48849ea24 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -11,6 +11,7 @@ "API_METHOD_ORDER": "src/data/api-derivation.ts#API_METHOD_ORDER (const)", "API_OPERATION_ORDER": "src/data/object.zod.ts#API_OPERATION_ORDER (const)", "API_PRIMITIVES": "src/data/api-derivation.ts#API_PRIMITIVES (const)", + "AUDIT_FIELD_DEFS": "src/data/injected-system-column-provenance.ts#AUDIT_FIELD_DEFS (const)", "AUDIT_PROVENANCE_FIELDS": "src/data/field-group-layout.ts#AUDIT_PROVENANCE_FIELDS (const)", "Address": "src/data/field.zod.ts#Address (type)", "AddressSchema": "src/data/field-value.zod.ts#AddressSchema (const)", @@ -318,6 +319,7 @@ "ImportFieldMappingParsed": "src/data/mapping.zod.ts#ImportFieldMappingParsed (type)", "ImportFieldMappingSchema": "src/data/mapping.zod.ts#ImportFieldMappingSchema (const)", "IndexSchema": "src/data/object.zod.ts#IndexSchema (const)", + "InjectedColumnProvenance": "src/data/injected-system-column-provenance.ts#InjectedColumnProvenance (type)", "InjectedSystemColumnPlan": "src/data/injected-system-columns.ts#InjectedSystemColumnPlan (interface)", "InstantValue": "src/data/field-value.zod.ts#InstantValue (type)", "InstantValueSchema": "src/data/field-value.zod.ts#InstantValueSchema (const)", @@ -389,6 +391,8 @@ "NormalizedFilter": "src/data/filter.zod.ts#NormalizedFilter (type)", "NormalizedFilterSchema": "src/data/filter.zod.ts#NormalizedFilterSchema (const)", "OBJECT_KEY_GUIDANCE": "src/data/authoring-key-lint.ts#OBJECT_KEY_GUIDANCE (const)", + "OWNER_FIELD_DEF": "src/data/injected-system-column-provenance.ts#OWNER_FIELD_DEF (const)", + "OWNING_BUSINESS_UNIT_FIELD_DEF": "src/data/injected-system-column-provenance.ts#OWNING_BUSINESS_UNIT_FIELD_DEF (const)", "ObjectAccessConfig": "src/data/object.zod.ts#ObjectAccessConfig (type)", "ObjectAccessConfigParsed": "src/data/object.zod.ts#ObjectAccessConfigParsed (type)", "ObjectAccessConfigSchema": "src/data/object.zod.ts#ObjectAccessConfigSchema (const)", @@ -570,6 +574,7 @@ "TEMPORAL_ROWS": "src/data/temporal-conformance.ts#TEMPORAL_ROWS (const)", "TEMPORAL_TIME_CASES": "src/data/temporal-conformance.ts#TEMPORAL_TIME_CASES (const)", "TEMPORAL_TIME_ROWS": "src/data/temporal-conformance.ts#TEMPORAL_TIME_ROWS (const)", + "TENANT_SCOPE_FIELD_DEF": "src/data/injected-system-column-provenance.ts#TENANT_SCOPE_FIELD_DEF (const)", "TITLE_ELIGIBLE": "src/data/display-name.ts#TITLE_ELIGIBLE (const)", "TITLE_ELIGIBLE_TYPES": "src/data/display-name.ts#TITLE_ELIGIBLE_TYPES (const)", "TITLE_INELIGIBLE_TYPES": "src/data/display-name.ts#TITLE_INELIGIBLE_TYPES (const)", @@ -641,6 +646,7 @@ "hasDanglingLikeEscape": "src/data/filter.zod.ts#hasDanglingLikeEscape (function)", "hasDynamicTokens": "src/data/autonumber-format.ts#hasDynamicTokens (function)", "hookForm": "src/data/hook.form.ts#hookForm (const)", + "injectedSystemColumnDefs": "src/data/injected-system-column-provenance.ts#injectedSystemColumnDefs (function)", "isAcceptedFilterComparand": "src/data/filter-comparand-type.ts#isAcceptedFilterComparand (function)", "isApiOperationAllowed": "src/data/api-derivation.ts#isApiOperationAllowed (function)", "isApiPrimitive": "src/data/api-derivation.ts#isApiPrimitive (function)", @@ -654,6 +660,7 @@ "isFilterAST": "src/data/filter.zod.ts#isFilterAST (function)", "isGlobalUnique": "src/data/field.zod.ts#isGlobalUnique (function)", "isIncoherentAggregate": "src/data/aggregation-policy.ts#isIncoherentAggregate (function)", + "isInjectedColumnDefinition": "src/data/injected-system-column-provenance.ts#isInjectedColumnDefinition (function)", "isKnownFilterToken": "src/data/context-tokens.zod.ts#isKnownFilterToken (function)", "isLegacyApiMethod": "src/data/api-derivation.ts#isLegacyApiMethod (function)", "isMultiValueField": "src/data/field-value.zod.ts#isMultiValueField (function)", @@ -678,6 +685,7 @@ "parseDateMacroParam": "src/data/date-macros.zod.ts#parseDateMacroParam (function)", "parseFilterAST": "src/data/filter.zod.ts#parseFilterAST (function)", "percentScaleOf": "src/data/percent-scale.ts#percentScaleOf (function)", + "platformProvisionsStorage": "src/data/injected-system-column-provenance.ts#platformProvisionsStorage (function)", "provisionPrimary": "src/data/display-name.ts#provisionPrimary (function)", "readAutonumberCounter": "src/data/autonumber-format.ts#readAutonumberCounter (function)", "reduceFilterKeyVerdict": "src/data/filter-verdict.ts#reduceFilterKeyVerdict (function)", @@ -693,6 +701,7 @@ "resolveDisplayField": "src/data/display-name.ts#resolveDisplayField (function)", "resolveDriverId": "src/data/driver/config-registry.zod.ts#resolveDriverId (function)", "resolveEffectiveApiMethods": "src/data/api-derivation.ts#resolveEffectiveApiMethods (function)", + "resolveInjectedColumnProvenance": "src/data/injected-system-column-provenance.ts#resolveInjectedColumnProvenance (function)", "resolveInjectedSystemColumns": "src/data/injected-system-columns.ts#resolveInjectedSystemColumns (function)", "resolveRecordDisplayName": "src/data/display-name.ts#resolveRecordDisplayName (function)", "resolveSearchFieldResolution": "src/data/search-fields.ts#resolveSearchFieldResolution (function)", @@ -701,6 +710,7 @@ "stripLegacyApiMethods": "src/data/object.zod.ts#stripLegacyApiMethods (function)", "suggestDefaultValueToken": "src/data/default-value-shape.ts#suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType": "src/data/type-compat.ts#suggestFieldTypeForSqlType (function)", + "unprovisionedInjectedColumns": "src/data/injected-system-column-provenance.ts#unprovisionedInjectedColumns (function)", "utcInstantMs": "src/data/calendar-day.ts#utcInstantMs (function)", "validateDriverConfig": "src/data/driver/config-registry.zod.ts#validateDriverConfig (function)", "valueSchemaFor": "src/data/field-value.zod.ts#valueSchemaFor (function)" From beb84372a01612ab653c6db5a5d05804ece81355 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 08:59:11 +0000 Subject: [PATCH 4/5] =?UTF-8?q?test(lint):=20type=20warningsOf=20over=20Ex?= =?UTF-8?q?prIssue=20=E2=80=94=20clears=20the=209=20test-layer=20tsc=20err?= =?UTF-8?q?ors=20the=20TEST=5FDEBT=20ratchet=20caught=20(#8116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2 --- packages/lint/src/validate-expressions.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/lint/src/validate-expressions.test.ts b/packages/lint/src/validate-expressions.test.ts index 78bd4180b4..01a0e4ee30 100644 --- a/packages/lint/src/validate-expressions.test.ts +++ b/packages/lint/src/validate-expressions.test.ts @@ -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 = [ @@ -2817,7 +2818,7 @@ describe('validateStackExpressions — unprovisioned injected anchors (#8116)', objects: [{ ...object, validations: [{ name: 'r1', type: 'script', condition }] }], }); - const warningsOf = (issues: Array<{ severity?: string }>) => + const warningsOf = (issues: readonly ExprIssue[]): ExprIssue[] => issues.filter((i) => i.severity === 'warning'); it('warns on record. in a validation rule on an external object', () => { From c22b450e3f080c46f8b4ce1455f6f6f38210aac9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 09:30:22 +0000 Subject: [PATCH 5/5] chore(spec): regenerate api-surface + export-origins from the merged tree (#8116 x #8341/#8335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2 --- packages/spec/api-surface/data.json | 3 +++ packages/spec/export-origins/data.json | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index a881003433..bbd1770914 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -595,6 +595,7 @@ "TursoDriverSpec (const)", "TursoTransportMode (type)", "TursoTransportModeSchema (const)", + "URL_EMBEDDED_CREDENTIAL_REFUSED (const)", "UniqueScope (type)", "UniqueScopeSchema (const)", "UnknownAuthoringKeyFinding (interface)", @@ -613,6 +614,7 @@ "checkManagedApiMethodAffordances (function)", "classifyFilterToken (function)", "countAuthorableFields (function)", + "credentialFreeUrl (function)", "defaultAggregateFor (function)", "defaultValueTokenIssue (function)", "defineCube (function)", @@ -711,6 +713,7 @@ "suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType (function)", "unprovisionedInjectedColumns (function)", + "urlUserinfoPassword (function)", "utcInstantMs (function)", "validateDriverConfig (function)", "valueSchemaFor (function)" diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index f48849ea24..fbb638d6bc 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -595,6 +595,7 @@ "TursoDriverSpec": "src/data/driver/turso.zod.ts#TursoDriverSpec (const)", "TursoTransportMode": "src/data/driver/turso.zod.ts#TursoTransportMode (type)", "TursoTransportModeSchema": "src/data/driver/turso.zod.ts#TursoTransportModeSchema (const)", + "URL_EMBEDDED_CREDENTIAL_REFUSED": "src/data/driver/common.zod.ts#URL_EMBEDDED_CREDENTIAL_REFUSED (const)", "UniqueScope": "src/data/field.zod.ts#UniqueScope (type)", "UniqueScopeSchema": "src/data/field.zod.ts#UniqueScopeSchema (const)", "UnknownAuthoringKeyFinding": "src/data/authoring-key-lint.ts#UnknownAuthoringKeyFinding (interface)", @@ -613,6 +614,7 @@ "checkManagedApiMethodAffordances": "src/data/managed-api-affordance.ts#checkManagedApiMethodAffordances (function)", "classifyFilterToken": "src/data/context-tokens.zod.ts#classifyFilterToken (function)", "countAuthorableFields": "src/data/record-surface.ts#countAuthorableFields (function)", + "credentialFreeUrl": "src/data/driver/common.zod.ts#credentialFreeUrl (function)", "defaultAggregateFor": "src/data/aggregation-policy.ts#defaultAggregateFor (function)", "defaultValueTokenIssue": "src/data/default-value-shape.ts#defaultValueTokenIssue (function)", "defineCube": "src/data/analytics.zod.ts#defineCube (function)", @@ -711,6 +713,7 @@ "suggestDefaultValueToken": "src/data/default-value-shape.ts#suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType": "src/data/type-compat.ts#suggestFieldTypeForSqlType (function)", "unprovisionedInjectedColumns": "src/data/injected-system-column-provenance.ts#unprovisionedInjectedColumns (function)", + "urlUserinfoPassword": "src/data/driver/common.zod.ts#urlUserinfoPassword (function)", "utcInstantMs": "src/data/calendar-day.ts#utcInstantMs (function)", "validateDriverConfig": "src/data/driver/config-registry.zod.ts#validateDriverConfig (function)", "valueSchemaFor": "src/data/field-value.zod.ts#valueSchemaFor (function)"