Skip to content

Commit 192213f

Browse files
fix(lint): three write rules ask anchor provenance before exempting a system column (#8996)
* fix(lint): three write rules ask anchor provenance before exempting a system column (#8663) `IMPLICIT_FIELDS` is object-independent, so on an ADR-0015 `external` object it exempted injected anchors the platform never provisioned storage for. Measured end to end: the engine's write-path validator PASSES the anchor (it is in the registered schema) while refusing an undeclared name outright with INVALID_FIELD, so the anchor is the only payload key that reaches the remote database raw — where SQLite answers an untyped `no such column` that aborts the whole statement. Each of the three consumers now emits a separate advisory finding on that path, at `warning`, reusing the read-axis anchor wording. Author-declared columns of the same name stay untouched. * docs(automation): record the unprovisioned-anchor write findings on the hook-bodies page (#8663) * docs(automation): point the federation reference at the page that owns it (#8663) The added line linked ADR-0015 to /docs/protocol/federation, which does not exist — Check Documentation Links caught it. The ADR id now reads as bare text (matching all eight existing ADR-0015 citations in content/docs) and the link moves onto the concept, targeting the page that actually owns federation. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cfc7af4 commit 192213f

9 files changed

Lines changed: 447 additions & 13 deletions

.changeset/silly-pandas-repeat.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
Three write-surface lint rules now ask provenance, not just membership, before exempting a system column (#8663).
6+
7+
`validate-hook-body-writes`, `validate-action-body-writes` and `validate-flow-node-writes` share one `IMPLICIT_FIELDS` set, which is object-INDEPENDENT: it answers "could this name be implicitly writable somewhere", never "did the platform provision a column for it on THIS object". On an ADR-0015 `external` object those diverge — the registry injects `owner_id` / `organization_id` / the audit family onto a federated object exactly as onto a local one, but the remote database owns the schema and no column exists behind them.
8+
9+
Each rule now emits a new advisory finding on that path instead of staying silent — `hook-body-write-unprovisioned-anchor`, `action-body-write-unprovisioned-anchor`, `flow-node-write-unprovisioned-anchor` — sharing the `unprovisionedAnchorCause` / `unprovisionedAnchorHint` wording the read-axis rules already use. All three are `warning`: the flow-node rule's existence finding still gates at `error`, and its provenance finding deliberately does not, because the claim is about a remote schema this repo cannot see.
10+
11+
An author-DECLARED column of the same name is untouched — on a federated object it maps a remote column the author vouches for. `FlowNodeWriteSeverity` widens from `'error'` to `'error' | 'warning'` accordingly.

content/docs/automation/hook-bodies.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ Static validation around a hook is asymmetric, and it is worth knowing exactly w
141141
- **Checked — read side.** `hook.condition` is validated at build time against the target object's fields by the expression validator (`@objectstack/lint`), including array-valued `hook.object` targets. A condition referencing a nonexistent field fails the lint.
142142
- **Checked — capability side.** `body.capabilities` gates which `ctx` APIs the body may call at all; the sandbox throws on an undeclared call.
143143
- **Checked — write side, advisory and literal-only.** Since [#4271](https://github.com/objectstack-ai/objectstack/issues/4271), `body.source` is **parsed** (never executed, never type-checked) and the field names it writes are resolved against the target object's declarations. An unknown field raises `hook-body-write-unknown-field` — a **warning** carrying a did-you-mean suggestion, which never blocks a build. Action bodies get the same check on their `ctx.api` writes (`action-body-write-unknown-field`). Both run under `os validate`, `os lint` and `os compile`.
144+
- **Checked — writes to a system column the object has no storage for.** Since [#8663](https://github.com/objectstack-ai/objectstack/issues/8663), a write to an injected system column is no longer exempted on the strength of its NAME alone. The registry injects `owner_id` / `organization_id` / the audit family onto an ADR-0015 [`external` object](/docs/data-modeling/external-datasources) exactly as onto a local one, but the remote database owns that schema and no column exists behind them. Writing one raises `hook-body-write-unprovisioned-anchor` (or `action-body-write-unprovisioned-anchor` / `flow-node-write-unprovisioned-anchor` on the other two surfaces) — a **warning** on all three, including the flow-node rule that otherwise gates, because the claim is about a remote schema the build cannot see. A column you **declare** yourself is untouched: on a federated object a declared `owner_id` maps a remote column you vouch for. Why it matters more than an ordinary typo: an undeclared name is refused upstream by the engine's own write-path validator (`INVALID_FIELD`), whereas the injected anchor is in the registered schema and passes it — so it is the one payload key that reaches the remote database raw, where a SQL remote aborts the **whole statement** with an untyped `no such column` and takes the correctly named fields of the same payload with it.
144145
- **Checked — writes that reach nothing at all.** Since [#4345](https://github.com/objectstack-ai/objectstack/issues/4345), an action body assigning to `ctx.record` raises `action-record-write-discarded`, also a warning. This one is **not** a field-resolution question: an action's `ctx.record` is a snapshot the runtime never writes back, so the assignment is discarded whether or not the field is declared — see [Signature conventions](#signature-conventions) below.
145146

146147
Four literal write shapes are recognized, and only these:

packages/lint/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,7 @@ export {
484484
HOOK_BODY_WRITE_PATTERN_IDS,
485485
HOOK_BODY_WRITE_EXCLUSIONS,
486486
HOOK_BODY_WRITE_UNKNOWN_FIELD,
487+
HOOK_BODY_WRITE_UNPROVISIONED_ANCHOR,
487488
} from './validate-hook-body-writes.js';
488489
export type {
489490
HookBodyWriteFinding,
@@ -506,6 +507,7 @@ export {
506507
ACTION_RECORD_WRITE_PATTERN_IDS,
507508
ACTION_BODY_WRITE_EXCLUSIONS,
508509
ACTION_BODY_WRITE_UNKNOWN_FIELD,
510+
ACTION_BODY_WRITE_UNPROVISIONED_ANCHOR,
509511
ACTION_RECORD_WRITE_DISCARDED,
510512
} from './validate-action-body-writes.js';
511513
export type {
@@ -522,6 +524,7 @@ export type {
522524
export {
523525
validateFlowNodeWrites,
524526
FLOW_NODE_WRITE_UNKNOWN_FIELD,
527+
FLOW_NODE_WRITE_UNPROVISIONED_ANCHOR,
525528
FLOW_WRITE_NODE_TYPES,
526529
FLOW_WRITE_NODE_TYPES_DEFERRED,
527530
} from './validate-flow-node-writes.js';

packages/lint/src/validate-action-body-writes.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ACTION_RECORD_WRITE_PATTERN_IDS,
1010
ACTION_BODY_WRITE_EXCLUSIONS,
1111
ACTION_BODY_WRITE_UNKNOWN_FIELD,
12+
ACTION_BODY_WRITE_UNPROVISIONED_ANCHOR,
1213
ACTION_RECORD_WRITE_DISCARDED,
1314
} from './validate-action-body-writes.js';
1415
import {
@@ -446,3 +447,57 @@ describe('validateActionBodyWrites — scope and shape tolerance', () => {
446447
).not.toThrow();
447448
});
448449
});
450+
451+
// ─── [#8663] Unprovisioned injected anchors on the WRITE axis ────────────────
452+
//
453+
// This rule shares IMPLICIT_FIELDS with the hook rule, so it shared the set's
454+
// object-independence too: on an ADR-0015 `external` object the injected anchor
455+
// is registered but has no column behind it. See the hook rule's test block for
456+
// the measured runtime chain the diagnostic's wording reports.
457+
const federatedObject = {
458+
name: 'wh_order',
459+
datasource: 'warehouse',
460+
external: { remoteName: 'fact_orders' },
461+
fields: { order_id: { type: 'text' }, amount: { type: 'number' } },
462+
};
463+
const localTwin = { name: 'wh_order', fields: { order_id: { type: 'text' }, amount: { type: 'number' } } };
464+
465+
function actionStackOver(object: Record<string, unknown>, source: string) {
466+
return {
467+
objects: [object],
468+
actions: [{ name: 'stamp_owner', label: 'Stamp', objectName: 'wh_order', body: { language: 'js', source } }],
469+
};
470+
}
471+
472+
describe('[#8663] validateActionBodyWrites — unprovisioned anchor writes', () => {
473+
it('warns when an action body writes an anchor the federated object has no storage for', () => {
474+
const findings = validateActionBodyWrites(
475+
actionStackOver(federatedObject, "await ctx.api.object('wh_order').updateById(ctx.recordId, { owner_id: ctx.user.id });"),
476+
);
477+
expect(findings).toHaveLength(1);
478+
expect(findings[0].rule).toBe(ACTION_BODY_WRITE_UNPROVISIONED_ANCHOR);
479+
expect(findings[0].severity).toBe('warning');
480+
expect(findings[0].where).toBe('action "stamp_owner" › body');
481+
expect(findings[0].path).toBe('actions[0].body.source');
482+
expect(findings[0].message).toContain("'owner_id'");
483+
expect(findings[0].message).toContain('external object (ADR-0015)');
484+
expect(findings[0].message).toContain('can never land');
485+
});
486+
487+
it('the same write without the external binding is silent', () => {
488+
expect(
489+
validateActionBodyWrites(
490+
actionStackOver(localTwin, "await ctx.api.object('wh_order').updateById(ctx.recordId, { owner_id: ctx.user.id });"),
491+
),
492+
).toEqual([]);
493+
});
494+
495+
it('an author-declared column of the same name is never flagged', () => {
496+
const declared = { ...federatedObject, fields: { ...federatedObject.fields, owner_id: { type: 'text' } } };
497+
expect(
498+
validateActionBodyWrites(
499+
actionStackOver(declared, "await ctx.api.object('wh_order').updateById(ctx.recordId, { owner_id: ctx.user.id });"),
500+
),
501+
).toEqual([]);
502+
});
503+
});

packages/lint/src/validate-action-body-writes.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,17 @@
7777
// match either shape and never pays the TypeScript load.
7878

7979
import { findClosestMatches, formatSuggestion } from '@objectstack/spec/shared';
80+
import {
81+
indexUnprovisionedAnchors,
82+
unprovisionedAnchorCause,
83+
unprovisionedAnchorHint,
84+
} from './system-fields.js';
8085
import {
8186
extractHookBodyWriteSet,
8287
indexObjectFields,
8388
judgeableFieldsOf,
8489
IMPLICIT_FIELDS,
90+
unprovisionedAnchorWriteConsequence,
8591
HOOK_BODY_WRITE_PATTERNS,
8692
type BodyWritePatternExclusion,
8793
type HookBodyWritePattern,
@@ -105,6 +111,13 @@ export interface ActionBodyWriteFinding {
105111
export const ACTION_BODY_WRITE_UNKNOWN_FIELD = 'action-body-write-unknown-field';
106112
export const ACTION_RECORD_WRITE_DISCARDED = 'action-record-write-discarded';
107113

114+
/**
115+
* [#8663] The action-surface twin of `hook-body-write-unprovisioned-anchor`.
116+
* Same question, same wording, same `warning` severity — this rule and the hook
117+
* rule share {@link IMPLICIT_FIELDS}, so they shared its blind spot too.
118+
*/
119+
export const ACTION_BODY_WRITE_UNPROVISIONED_ANCHOR = 'action-body-write-unprovisioned-anchor';
120+
108121
// ─── The applicable-pattern ledger ──────────────────────────────────────────
109122
//
110123
// Not a second pattern list: a declared PARTITION of the shared
@@ -279,6 +292,8 @@ export function validateActionBodyWrites(stack: AnyRec): ActionBodyWriteFinding[
279292
// Built lazily: only the unknown-field check needs it, so a stack whose
280293
// action bodies never reach `ctx.api` never pays it.
281294
let objectFields: Map<string, Set<string>> | null = null;
295+
// [#8663] Non-empty only for a stack carrying an ADR-0015 `external` object.
296+
let anchors: ReadonlyMap<string, ReadonlySet<string>> | null = null;
282297

283298
for (const site of sites) {
284299
// Cheap prefilter, narrower than the extractor's own: every consumed
@@ -326,6 +341,7 @@ export function validateActionBodyWrites(stack: AnyRec): ActionBodyWriteFinding[
326341

327342
if (writes.length === 0) continue;
328343
objectFields ??= indexObjectFields(stack);
344+
anchors ??= indexUnprovisionedAnchors(stack);
329345
const reported = new Set<string>();
330346

331347
for (const w of writes) {
@@ -340,7 +356,25 @@ export function validateActionBodyWrites(stack: AnyRec): ActionBodyWriteFinding[
340356

341357
const known = judgeableFieldsOf(objectFields, w.object);
342358
if (!known) continue; // cross-package, or no declared fields — cannot judge
343-
if (IMPLICIT_FIELDS.has(w.field) || known.has(w.field)) continue;
359+
// An author-DECLARED column is the author's, on a federated object too
360+
// (it maps a remote column they vouch for) — never either finding.
361+
if (known.has(w.field)) continue;
362+
if (IMPLICIT_FIELDS.has(w.field)) {
363+
// [#8663] Implicitly writable SOMEWHERE is not provisioned HERE.
364+
if (!anchors.get(w.object)?.has(w.field)) continue;
365+
reported.add(dedupeKey);
366+
findings.push({
367+
severity: 'warning',
368+
rule: ACTION_BODY_WRITE_UNPROVISIONED_ANCHOR,
369+
where,
370+
path: site.path,
371+
message:
372+
`body calls ctx.api.object('${w.object}').${w.method ?? 'update'}(…) writing '${w.field}', and ` +
373+
`${unprovisionedAnchorCause(w.object, w.field)}${unprovisionedAnchorWriteConsequence()}`,
374+
hint: unprovisionedAnchorHint(w.object, w.field),
375+
});
376+
continue;
377+
}
344378

345379
reported.add(dedupeKey);
346380
findings.push({

packages/lint/src/validate-flow-node-writes.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import {
1212
validateFlowNodeWrites,
1313
FLOW_NODE_WRITE_UNKNOWN_FIELD,
14+
FLOW_NODE_WRITE_UNPROVISIONED_ANCHOR,
1415
FLOW_WRITE_NODE_TYPES,
1516
FLOW_WRITE_NODE_TYPES_DEFERRED,
1617
} from './validate-flow-node-writes.js';
@@ -482,3 +483,62 @@ describe('validateFlowNodeWrites', () => {
482483
expect(findings).toEqual([]);
483484
});
484485
});
486+
487+
// ─── [#8663] Unprovisioned injected anchors on the WRITE axis ────────────────
488+
//
489+
// The third consumer of the hook rule's IMPLICIT_FIELDS, and the only one whose
490+
// existence finding GATES. The provenance finding deliberately does not: it is
491+
// a claim about a remote schema this repo cannot see, so reclassifying it up to
492+
// `error` would turn a silent case straight into a build break.
493+
const federatedDeal = {
494+
name: 'wh_order',
495+
datasource: 'warehouse',
496+
external: { remoteName: 'fact_orders' },
497+
fields: { order_id: { type: 'text' }, amount: { type: 'currency' } },
498+
};
499+
const localWhOrder = { name: 'wh_order', fields: { order_id: { type: 'text' }, amount: { type: 'currency' } } };
500+
501+
const anchorFlow = (fields: unknown) => ({
502+
name: 'stamp_owner',
503+
type: 'record_change',
504+
nodes: [
505+
{ id: 'start', type: 'start', config: {} },
506+
{
507+
id: 'mark',
508+
type: 'update_record',
509+
label: 'Stamp',
510+
config: { objectName: 'wh_order', filter: { id: '{recordId}' }, fields },
511+
},
512+
],
513+
edges: [],
514+
});
515+
516+
describe('[#8663] validateFlowNodeWrites — unprovisioned anchor writes', () => {
517+
it('warns (does NOT gate) when a node writes an anchor the federated object has no storage for', () => {
518+
const findings = validateFlowNodeWrites({ objects: [federatedDeal], flows: [anchorFlow({ owner_id: '{user.id}' })] });
519+
expect(findings).toHaveLength(1);
520+
expect(findings[0].rule).toBe(FLOW_NODE_WRITE_UNPROVISIONED_ANCHOR);
521+
// The whole point of the separate id: this rule's other finding is `error`.
522+
expect(findings[0].severity).toBe('warning');
523+
expect(findings[0].path).toBe('flows[0].nodes[1].config.fields.owner_id');
524+
expect(findings[0].where).toBe('flow "stamp_owner" › node "Stamp"');
525+
expect(findings[0].message).toContain('external object (ADR-0015)');
526+
expect(findings[0].message).toContain('can never land');
527+
});
528+
529+
it('the same node against the same object without the external binding is silent', () => {
530+
expect(validateFlowNodeWrites({ objects: [localWhOrder], flows: [anchorFlow({ owner_id: '{user.id}' })] })).toEqual([]);
531+
});
532+
533+
it('an author-declared column of the same name is never flagged', () => {
534+
const declared = { ...federatedDeal, fields: { ...federatedDeal.fields, owner_id: { type: 'text' } } };
535+
expect(validateFlowNodeWrites({ objects: [declared], flows: [anchorFlow({ owner_id: '{user.id}' })] })).toEqual([]);
536+
});
537+
538+
it('the gating unknown-field finding on the same federated object is unchanged', () => {
539+
const findings = validateFlowNodeWrites({ objects: [federatedDeal], flows: [anchorFlow({ ordr_id: 'x' })] });
540+
expect(findings).toHaveLength(1);
541+
expect(findings[0].rule).toBe(FLOW_NODE_WRITE_UNKNOWN_FIELD);
542+
expect(findings[0].severity).toBe('error');
543+
});
544+
});

packages/lint/src/validate-flow-node-writes.ts

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,31 @@
8484

8585
import { findClosestMatches, formatSuggestion } from '@objectstack/spec/shared';
8686

87-
import { indexObjectFields, judgeableFieldsOf, IMPLICIT_FIELDS } from './validate-hook-body-writes.js';
87+
import {
88+
indexObjectFields,
89+
judgeableFieldsOf,
90+
IMPLICIT_FIELDS,
91+
unprovisionedAnchorWriteConsequence,
92+
} from './validate-hook-body-writes.js';
93+
import {
94+
indexUnprovisionedAnchors,
95+
unprovisionedAnchorCause,
96+
unprovisionedAnchorHint,
97+
} from './system-fields.js';
8898
import { walkFlowNodes, flowNodeLabel } from './flow-walk.js';
8999

90-
export type FlowNodeWriteSeverity = 'error';
100+
/**
101+
* `error` for the existence verdict — a literal key against a literal object is
102+
* a certainty (see the module note). [#8663] `warning` for the provenance one:
103+
* the same widening `validateFlowTemplatePaths` carries, for the same reason.
104+
* The two questions have different certainties, so they cannot share a
105+
* severity; the suite that runs this rule is severity-agnostic by contract and
106+
* carries each finding's own value through.
107+
*/
108+
export type FlowNodeWriteSeverity = 'error' | 'warning';
91109

92110
export interface FlowNodeWriteFinding {
93-
/** Always `error` — a literal key against a literal object is a certainty (see module note). */
111+
/** Per-finding — see {@link FlowNodeWriteSeverity}, which says why it is not a constant. */
94112
severity: FlowNodeWriteSeverity;
95113
rule: string;
96114
/** Human-readable location, e.g. `flow "close_deal" › node "Mark won"`. */
@@ -104,6 +122,21 @@ export interface FlowNodeWriteFinding {
104122
// Rule id (registry entry).
105123
export const FLOW_NODE_WRITE_UNKNOWN_FIELD = 'flow-node-write-unknown-field';
106124

125+
/**
126+
* [#8663] The flow-node twin of `hook-body-write-unprovisioned-anchor`. This
127+
* rule reached the same blind spot from the same direction: it imports
128+
* {@link IMPLICIT_FIELDS} from the hook rule, so it inherited the set's
129+
* object-independence along with its contents.
130+
*
131+
* ⚠️ `warning`, NOT this rule's usual `error`. The existence verdict gates
132+
* because a literal key against a literal object is a certainty; the provenance
133+
* verdict is a claim about a REMOTE schema this repo cannot see, so it advises.
134+
* Reclassifying it upward would convert a silent case straight into a build
135+
* break — the shape ADR-0072 D1 forbids, at the one severity where it cannot be
136+
* ignored.
137+
*/
138+
export const FLOW_NODE_WRITE_UNPROVISIONED_ANCHOR = 'flow-node-write-unprovisioned-anchor';
139+
107140
// ─── The covered-node ledger ────────────────────────────────────────────────
108141
//
109142
// Which flow node types have their `config.fields` write map resolved against
@@ -188,6 +221,8 @@ export function validateFlowNodeWrites(stack: AnyRec): FlowNodeWriteFinding[] {
188221

189222
// Built lazily: a stack whose flows carry no write node never pays it.
190223
let objectFields: Map<string, Set<string>> | null = null;
224+
// [#8663] Non-empty only for a stack carrying an ADR-0015 `external` object.
225+
let anchors: ReadonlyMap<string, ReadonlySet<string>> | null = null;
191226

192227
flows.forEach((flow, flowIndex) => {
193228
const flowName = typeof flow.name === 'string' && flow.name ? flow.name : `#${flowIndex}`;
@@ -213,6 +248,7 @@ export function validateFlowNodeWrites(stack: AnyRec): FlowNodeWriteFinding[] {
213248
if (!objectName) return; // templated / dynamic object — resolved at run time
214249

215250
objectFields ??= indexObjectFields(stack);
251+
anchors ??= indexUnprovisionedAnchors(stack);
216252
// Cross-package objects and objects declaring no fields at all (external /
217253
// datasource-introspected schemas) are both unjudgeable, and this rule
218254
// gates — see {@link judgeableFieldsOf}, which is where that guard now
@@ -226,7 +262,24 @@ export function validateFlowNodeWrites(stack: AnyRec): FlowNodeWriteFinding[] {
226262
const nodeWhere = regionTrail ? `${regionTrail} › node "${nodeName}"` : `node "${nodeName}"`;
227263

228264
for (const fieldName of written) {
229-
if (known.has(fieldName) || IMPLICIT_FIELDS.has(fieldName)) continue;
265+
// An author-DECLARED column wins outright — on a federated object it
266+
// maps a remote column the author vouches for (#7859's direction).
267+
if (known.has(fieldName)) continue;
268+
if (IMPLICIT_FIELDS.has(fieldName)) {
269+
// [#8663] Implicitly writable SOMEWHERE is not provisioned HERE.
270+
if (!anchors.get(objectName)?.has(fieldName)) continue;
271+
findings.push({
272+
severity: 'warning',
273+
rule: FLOW_NODE_WRITE_UNPROVISIONED_ANCHOR,
274+
where: `flow "${flowName}" › ${nodeWhere}`,
275+
path: `${nodePath}.config.fields.${fieldName}`,
276+
message:
277+
`${node.type} writes '${fieldName}', and ${unprovisionedAnchorCause(objectName, fieldName)} — ` +
278+
unprovisionedAnchorWriteConsequence(),
279+
hint: unprovisionedAnchorHint(objectName, fieldName),
280+
});
281+
continue;
282+
}
230283
// A dotted key addresses a nested path, not a top-level column — the
231284
// document drivers forward it verbatim. Not statically a missing field.
232285
if (fieldName.includes('.')) continue;

0 commit comments

Comments
 (0)