Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/readonly-flow-write-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@objectstack/lint": minor
"@objectstack/cli": minor
---

feat(lint,cli): flag flow `update_record` writes to readonly fields at design time (#3425)

A flow `update_record` node that writes a field the target object declares
`readonly: true`, under the default `runAs: 'user'` identity, is a **silent
no-op**: the objectql engine strips static-`readonly` fields from a non-system
UPDATE payload (#2948), so the intended write never lands — yet the step still
reports `success`. #3407/#3413 surfaced the strip as a run-time step warning;
this moves the discovery **left** to `os validate` / `os build` so an author
finds the mismatch at design time instead of by reading server WARN logs days
later.

- New `@objectstack/lint` rule `validateReadonlyFlowWrites(stack)` — a pure
`(stack) => Finding[]` check (ADR-0019). A static `readonly:true` field
written by a literal `update_record` under `runAs !== 'system'` is a
100%-certain no-op → **error** (gates the build). A `readonlyWhen` field is
per-record-state → **warning** (advisory). Deliberately narrow to stay
false-positive-free: `create_record` (INSERT is engine-exempt from the strip),
`runAs: 'system'` flows (the intended "automation maintains it" channel),
templated object names, and non-literal `fields` maps are all skipped.
- Wired into `os validate` and `os compile`/`os build`, mirroring the existing
security-posture gate (errors fail; advisories print dimmed).

The formal contract, unchanged in behavior: `readonly` governs the end-user /
API surface (REST/UI and `runAs:'user'` flows strip it); trusted system writers
(`runAs:'system'`, system hooks, seeds) maintain it. To let a flow maintain a
readonly field, declare `runAs: 'system'`.
34 changes: 34 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { validateWidgetBindings } from '@objectstack/lint';
import { validateDashboardActionRefs } from '@objectstack/lint';
import { validateResponsiveStyles } from '@objectstack/lint';
import { validateSecurityPosture, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
import { validateReadonlyFlowWrites } from '@objectstack/lint';
import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
import { lintAutonumberFormats } from '../utils/lint-autonumber-formats.js';
import { lintLivenessProperties } from '../utils/lint-liveness-properties.js';
Expand Down Expand Up @@ -451,6 +452,39 @@ export default class Compile extends Command {
}
}

// 3e2. [#3425] Readonly flow-write guardrail. A `runAs:user` update_record
// writing a static-`readonly` field is a silent no-op — the engine
// strips it from the UPDATE payload (#2948) while the step reports
// success. This GATES the build (shift-left of the #3407/#3413
// run-time strip warning); `readonlyWhen` writes are per-record-state,
// so they are advisory, printed dimmed and never fatal.
if (!flags.json) printStep('Checking readonly flow writes (#3425)...');
const readonlyWriteFindings = validateReadonlyFlowWrites(result.data as Record<string, unknown>);
const readonlyWriteErrors = readonlyWriteFindings.filter((f) => f.severity === 'error');
const readonlyWriteAdvisories = readonlyWriteFindings.filter((f) => f.severity !== 'error');
if (readonlyWriteErrors.length > 0) {
if (flags.json) {
console.log(JSON.stringify({ success: false, error: 'readonly flow-write validation failed', issues: readonlyWriteErrors }));
this.exit(1);
}
console.log('');
printError(`Readonly flow-write check failed (${readonlyWriteErrors.length} issue${readonlyWriteErrors.length > 1 ? 's' : ''})`);
for (const f of readonlyWriteErrors.slice(0, 50)) {
console.log(` • ${f.where}: ${f.message}`);
console.log(chalk.dim(` ${f.hint}`));
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
}
this.exit(1);
}
if (readonlyWriteAdvisories.length > 0 && !flags.json) {
console.log('');
for (const f of readonlyWriteAdvisories) {
printWarning(`${f.where}: ${f.message}`);
console.log(chalk.dim(` ${f.hint}`));
console.log(chalk.dim(` rule: ${f.rule}`));
}
}

// 3f. [ADR-0090 D6] Access-matrix snapshot gate. Opt-in per app: when
// `access-matrix.json` sits next to the config, the (permission set
// × object) capability matrix derived from THIS build must match it
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { validateCapabilityReferences } from '@objectstack/lint';
import { validateVisibilityPredicates } from '@objectstack/lint';
import { validateSecurityPosture } from '@objectstack/lint';
import { validateFlowTriggerReadiness } from '@objectstack/lint';
import { validateReadonlyFlowWrites } from '@objectstack/lint';
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
import {
printHeader,
Expand Down Expand Up @@ -427,6 +428,41 @@ export default class Validate extends Command {
}
}

// 3e2. [#3425] Readonly flow-write guardrail. A `runAs:user` update_record
// that writes a static-`readonly` field is a SILENT no-op — the engine
// strips it from the UPDATE payload (#2948) yet the step reports
// success. This is the shift-left of the run-time strip warning
// (#3407/#3413): a static readonly + literal field is a certain no-op
// → error (gates); a `readonlyWhen` field is per-record-state → advisory.
if (!flags.json) printStep('Checking readonly flow writes (#3425)...');
const readonlyWriteFindings = validateReadonlyFlowWrites(normalized as Record<string, unknown>);
const readonlyWriteErrors = readonlyWriteFindings.filter((f) => f.severity === 'error');
const readonlyWriteWarnings = readonlyWriteFindings.filter((f) => f.severity === 'warning');
if (readonlyWriteErrors.length > 0) {
if (flags.json) {
console.log(JSON.stringify({
valid: false,
errors: readonlyWriteErrors,
duration: timer.elapsed(),
}, null, 2));
this.exit(1);
}
console.log('');
printError(`Readonly flow-write check failed (${readonlyWriteErrors.length} issue${readonlyWriteErrors.length > 1 ? 's' : ''})`);
for (const f of readonlyWriteErrors.slice(0, 50)) {
console.log(` • ${f.where}: ${f.message}`);
console.log(chalk.dim(` ${f.hint}`));
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
}
this.exit(1);
}
if (!flags.json) {
for (const f of readonlyWriteWarnings.slice(0, 50)) {
console.log(chalk.yellow(` ⚠ ${f.where}: ${f.message}`));
console.log(chalk.dim(` ${f.hint}`));
}
}

// 3f. [ADR-0090 D7] Security posture — the same gate `os compile`/`os build`
// run. Without it here, `os validate` passed a stack (e.g. a custom
// object with no explicit sharingModel) that the build then rejected,
Expand Down
10 changes: 10 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ export type {
FlowTriggerReadinessSeverity,
} from './validate-flow-trigger-readiness.js';

export {
validateReadonlyFlowWrites,
FLOW_UPDATE_READONLY_FIELD,
FLOW_UPDATE_READONLY_WHEN_FIELD,
} from './validate-readonly-flow-writes.js';
export type {
ReadonlyFlowWriteFinding,
ReadonlyFlowWriteSeverity,
} from './validate-readonly-flow-writes.js';

export { validateViewContainers, VIEW_CONTAINER_SHAPE } from './validate-view-containers.js';
export type { ViewContainerFinding, ViewContainerSeverity } from './validate-view-containers.js';

Expand Down
243 changes: 243 additions & 0 deletions packages/lint/src/validate-readonly-flow-writes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import {
validateReadonlyFlowWrites,
FLOW_UPDATE_READONLY_FIELD,
FLOW_UPDATE_READONLY_WHEN_FIELD,
} from './validate-readonly-flow-writes.js';

// Target object: a static-readonly field, a conditional readonlyWhen field, and
// a plain writable field. Map-shaped `fields` (the common authoring form).
const opportunityObject = {
name: 'crm_opportunity',
label: 'Opportunity',
fields: {
approval_status: { type: 'text', readonly: true },
amount: { type: 'currency', readonlyWhen: "record.stage == 'closed_won'" },
notes: { type: 'text' },
},
};

/** A flow with a single `update_record` node (nodes[1]) writing `fields`. */
function flowWith(
fields: unknown,
flowOverrides: Record<string, unknown> = {},
nodeConfigOverrides: Record<string, unknown> = {},
) {
return {
name: 'stamp_approval',
type: 'record_change',
nodes: [
{ id: 'start', type: 'start', config: {} },
{
id: 'stamp',
type: 'update_record',
label: 'Stamp approval',
config: { objectName: 'crm_opportunity', filter: { id: '{recordId}' }, fields, ...nodeConfigOverrides },
},
{ id: 'end', type: 'end' },
],
edges: [],
...flowOverrides,
};
}

describe('validateReadonlyFlowWrites', () => {
// ── static readonly → ERROR ──────────────────────────────────────────
it('errors when a runAs:user update_record writes a static-readonly field', () => {
const findings = validateReadonlyFlowWrites({
objects: [opportunityObject],
flows: [flowWith({ approval_status: 'approved' }, { runAs: 'user' })],
});
expect(findings).toHaveLength(1);
expect(findings[0].severity).toBe('error');
expect(findings[0].rule).toBe(FLOW_UPDATE_READONLY_FIELD);
expect(findings[0].path).toBe('flows[0].nodes[1].config.fields.approval_status');
expect(findings[0].message).toContain('approval_status');
expect(findings[0].message).toContain('crm_opportunity');
expect(findings[0].message).toContain('#2948');
expect(findings[0].where).toBe('flow "stamp_approval" › node "Stamp approval"');
});

it('errors when runAs is unauthored (defaults to user)', () => {
const findings = validateReadonlyFlowWrites({
objects: [opportunityObject],
flows: [flowWith({ approval_status: 'approved' })], // no runAs
});
expect(findings).toHaveLength(1);
expect(findings[0].severity).toBe('error');
});

it('resolves the target object via the `object` alias', () => {
const findings = validateReadonlyFlowWrites({
objects: [opportunityObject],
flows: [flowWith({ approval_status: 'approved' }, { runAs: 'user' }, { objectName: undefined, object: 'crm_opportunity' })],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(FLOW_UPDATE_READONLY_FIELD);
});

it('flags each readonly field written in one node', () => {
const twoReadonly = {
name: 'crm_case',
fields: {
is_sla_violated: { type: 'boolean', readonly: true },
closed_at: { type: 'datetime', readonly: true },
subject: { type: 'text' },
},
};
const flow = {
name: 'close_case',
type: 'record_change',
runAs: 'user',
nodes: [
{ id: 'start', type: 'start', config: {} },
{
id: 'u',
type: 'update_record',
label: 'Close',
config: { objectName: 'crm_case', fields: { is_sla_violated: true, closed_at: '{now}', subject: 'x' } },
},
],
edges: [],
};
const findings = validateReadonlyFlowWrites({ objects: [twoReadonly], flows: [flow] });
expect(findings).toHaveLength(2);
expect(findings.every((f) => f.severity === 'error')).toBe(true);
expect(findings.map((f) => f.path)).toEqual([
'flows[0].nodes[1].config.fields.is_sla_violated',
'flows[0].nodes[1].config.fields.closed_at',
]);
});

it('handles array-shaped object.fields', () => {
const arrObject = {
name: 'crm_lead',
fields: [
{ name: 'converted_account', type: 'lookup', readonly: true },
{ name: 'company', type: 'text' },
],
};
const flow = {
name: 'convert',
type: 'record_change',
runAs: 'user',
nodes: [
{ id: 'start', type: 'start', config: {} },
{ id: 'u', type: 'update_record', label: 'Convert', config: { objectName: 'crm_lead', fields: { converted_account: '{acct}' } } },
],
edges: [],
};
const findings = validateReadonlyFlowWrites({ objects: [arrObject], flows: [flow] });
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(FLOW_UPDATE_READONLY_FIELD);
});

// ── readonlyWhen → WARNING ───────────────────────────────────────────
it('warns (not errors) when writing a readonlyWhen field', () => {
const findings = validateReadonlyFlowWrites({
objects: [opportunityObject],
flows: [flowWith({ amount: 5000 }, { runAs: 'user' })],
});
expect(findings).toHaveLength(1);
expect(findings[0].severity).toBe('warning');
expect(findings[0].rule).toBe(FLOW_UPDATE_READONLY_WHEN_FIELD);
expect(findings[0].message).toContain('#3042');
});

it('separates readonly (error) + readonlyWhen (warning) + plain (clean) in one node', () => {
const findings = validateReadonlyFlowWrites({
objects: [opportunityObject],
flows: [flowWith({ approval_status: 'approved', amount: 1, notes: 'hi' }, { runAs: 'user' })],
});
expect(findings).toHaveLength(2);
expect(findings.find((f) => f.severity === 'error')?.path).toBe('flows[0].nodes[1].config.fields.approval_status');
expect(findings.find((f) => f.severity === 'warning')?.path).toBe('flows[0].nodes[1].config.fields.amount');
});

// ── clean: runAs:system is the intended maintenance channel ───────────
it('does NOT flag a runAs:system flow (elevated writer bypasses the strip)', () => {
const findings = validateReadonlyFlowWrites({
objects: [opportunityObject],
flows: [flowWith({ approval_status: 'approved' }, { runAs: 'system' })],
});
expect(findings).toEqual([]);
});

// ── clean: create_record is engine-exempt from the readonly strip ─────
it('does NOT flag create_record writing a readonly field', () => {
const flow = {
name: 'seed_opp',
type: 'record_change',
runAs: 'user',
nodes: [
{ id: 'start', type: 'start', config: {} },
{ id: 'c', type: 'create_record', label: 'Create', config: { objectName: 'crm_opportunity', fields: { approval_status: 'approved' } } },
],
edges: [],
};
const findings = validateReadonlyFlowWrites({ objects: [opportunityObject], flows: [flow] });
expect(findings).toEqual([]);
});

// ── clean: plain writable field ──────────────────────────────────────
it('does NOT flag writes to a plain writable field', () => {
const findings = validateReadonlyFlowWrites({
objects: [opportunityObject],
flows: [flowWith({ notes: 'updated' }, { runAs: 'user' })],
});
expect(findings).toEqual([]);
});

// ── clean: not statically knowable ───────────────────────────────────
it('skips a templated objectName (dynamic target)', () => {
const findings = validateReadonlyFlowWrites({
objects: [opportunityObject],
flows: [flowWith({ approval_status: 'approved' }, { runAs: 'user' }, { objectName: '{targetObject}' })],
});
expect(findings).toEqual([]);
});

it('skips a non-literal fields map (dynamic write payload)', () => {
const findings = validateReadonlyFlowWrites({
objects: [opportunityObject],
flows: [flowWith('{allFields}', { runAs: 'user' })],
});
expect(findings).toEqual([]);
});

it('skips an object not defined in this stack (another package)', () => {
const findings = validateReadonlyFlowWrites({
objects: [], // crm_opportunity not present
flows: [flowWith({ approval_status: 'approved' }, { runAs: 'user' })],
});
expect(findings).toEqual([]);
});

it('does NOT flag an unknown field (not this rule’s concern)', () => {
const findings = validateReadonlyFlowWrites({
objects: [opportunityObject],
flows: [flowWith({ nonexistent_field: 'x' }, { runAs: 'user' })],
});
expect(findings).toEqual([]);
});

// ── shape robustness ─────────────────────────────────────────────────
it('returns [] for a stack with no flows', () => {
expect(validateReadonlyFlowWrites({ objects: [opportunityObject] })).toEqual([]);
expect(validateReadonlyFlowWrites({})).toEqual([]);
});

it('falls back to node id then index for the location label', () => {
const flow = {
name: 'f',
runAs: 'user',
nodes: [{ id: 'my_node', type: 'update_record', config: { objectName: 'crm_opportunity', fields: { approval_status: 'x' } } }],
edges: [],
};
const findings = validateReadonlyFlowWrites({ objects: [opportunityObject], flows: [flow] });
expect(findings[0].where).toBe('flow "f" › node "my_node"');
expect(findings[0].path).toBe('flows[0].nodes[0].config.fields.approval_status');
});
});
Loading