Skip to content

Commit e8ccb7c

Browse files
committed
feat(lint): authoring learns the acting-organization requirement
`validate-flow-trigger-readiness.ts` contained zero occurrences of `organization`, against the invariant `engine.ts`'s own trigger-kind resolver states: it is shared with `defineStack`'s trigger-capability refusal and with this rule file, "so the runtime cannot drift from what authoring accepted". Both time triggers refuse to bind a flow declaring no `config.organization`, so `defineStack`, `os lint` and `verify_build` were all passing a flow the trigger then refused — an author's first signal was a production stderr line at boot. ⛔ No judgement is re-implemented: `resolveFlowTriggerKind` says which flows owe the key, `resolveScheduleOrganization` says whether one was declared (so a present-but-unusable value is judged identically here and at bind), and `describeMissingScheduleOrganization` writes the sentence. Severity is `warning`, and that is MEASURED rather than argued. On the family's own criterion (#5762 — is this stack enough to know the flow is dead?) it belongs at `error`. Flipping it to `error` and building the shipped example app was run: `objectstack build` on examples/app-showcase FAILS, naming `showcase_task_due_reminder` and `showcase_scheduled_digest` — and neither can be repaired by authoring, because the only legal value is a `sys_organization.id` minted per install at runtime and a placeholder id is strictly worse than the omission. Promoting the id is therefore a consequence of the open maintainer decision about package-shipped time-triggered flows, not a lint choice; the probe was restored byte-identically and the two example builds pass with the rule at `warning`, flagging exactly the four shipped flows. Fixture triage in the rule's own suite: every time-triggered fixture that is ABOUT another rule now declares an organization, so it keeps isolating its own subject; the fixtures that are about the missing key omit it deliberately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
1 parent 4e7c8ff commit e8ccb7c

4 files changed

Lines changed: 297 additions & 9 deletions

File tree

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,14 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
914914
// (the object may come from another installed package — a hedge this rule
915915
// cannot decide), as did `flow-draft-status-ambiguous` (draft flows DO fire;
916916
// that one is ambiguity of intent, not a dead flow).
917+
//
918+
// #16659 added a sixth id, `flow-schedule-organization-missing`, at
919+
// `warning`: a time-triggered flow declaring no `config.organization` is
920+
// refused at bind, so on the criterion above it belongs with the four — and
921+
// it is held at `warning` because an `error` gates `objectstack build`, and
922+
// the repo's own shipped example apps carry such flows with no authorable
923+
// repair (the only legal value is a `sys_organization.id` minted per install
924+
// at runtime). Its own docblock in the rule file records that.
917925
{
918926
name: 'validateFlowTriggerReadiness',
919927
tier: 'gating',

‎packages/lint/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export {
120120
FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID,
121121
FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE,
122122
FLOW_TRIGGER_UNROUTABLE,
123+
FLOW_SCHEDULE_ORGANIZATION_MISSING,
123124
} from './validate-flow-trigger-readiness.js';
124125
export type {
125126
FlowTriggerReadinessFinding,

‎packages/lint/src/validate-flow-trigger-readiness.test.ts‎

Lines changed: 174 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID,
1111
FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE,
1212
FLOW_TRIGGER_UNROUTABLE,
13+
FLOW_SCHEDULE_ORGANIZATION_MISSING,
1314
} from './validate-flow-trigger-readiness.js';
1415

1516
function recordFlow(overrides: Record<string, unknown> = {}) {
@@ -35,6 +36,20 @@ function recordFlow(overrides: Record<string, unknown> = {}) {
3536

3637
const candidateObject = { name: 'app_candidate', label: 'Candidate', fields: {} };
3738

39+
/**
40+
* [#16659] Every time-triggered fixture in this file that is ABOUT some other
41+
* rule now declares an acting organization.
42+
*
43+
* `flow-schedule-organization-missing` fires on any `schedule` /
44+
* `time_relative` flow without one, so a fixture that omits it would carry a
45+
* second finding and stop isolating the rule it exists to pin — the tests below
46+
* assert exact finding LISTS, which is what makes them worth having. ⛔ This is
47+
* not a relaxation: the fixtures that are about the missing key omit it
48+
* deliberately, in the `acting organization (#16659)` block and in the severity
49+
* table.
50+
*/
51+
const FIXTURE_ORG = 'org_2mtx1w9d0k4bqf7v';
52+
3853
describe('validateFlowTriggerReadiness', () => {
3954
it('passes a correctly wired, explicitly active record flow', () => {
4055
const findings = validateFlowTriggerReadiness({
@@ -118,7 +133,11 @@ describe('validateFlowTriggerReadiness', () => {
118133
name: 'digest',
119134
type: 'schedule',
120135
nodes: [
121-
{ id: 'start', type: 'start', config: { schedule: { type: 'interval', intervalMs: 60000 } } },
136+
{
137+
id: 'start',
138+
type: 'start',
139+
config: { schedule: { type: 'interval', intervalMs: 60000 }, organization: FIXTURE_ORG },
140+
},
122141
{ id: 'end', type: 'end' },
123142
],
124143
edges: [{ id: 'e1', source: 'start', target: 'end' }],
@@ -141,6 +160,7 @@ describe('validateFlowTriggerReadiness', () => {
141160
type: 'start',
142161
config: {
143162
timeRelative: { object: 'contracts', dateField: 'end_date', offsetDays: [60, 30, 7] },
163+
organization: FIXTURE_ORG,
144164
},
145165
},
146166
{ id: 'end', type: 'end' },
@@ -166,6 +186,7 @@ describe('validateFlowTriggerReadiness', () => {
166186
type: 'start',
167187
config: {
168188
timeRelative: { object: 'contract', dateField: 'end_date', withinDays: 60 },
189+
organization: FIXTURE_ORG,
169190
},
170191
},
171192
{ id: 'end', type: 'end' },
@@ -207,7 +228,7 @@ describe('validateFlowTriggerReadiness', () => {
207228
status: 'active',
208229
runAs: 'system',
209230
nodes: [
210-
{ id: 'start', type: 'start', config: { timeRelative } },
231+
{ id: 'start', type: 'start', config: { timeRelative, organization: FIXTURE_ORG } },
211232
{ id: 'end', type: 'end' },
212233
],
213234
edges: [{ id: 'e1', source: 'start', target: 'end' }],
@@ -991,11 +1012,17 @@ describe('validateFlowTriggerReadiness', () => {
9911012
// ── #5762 — the family's severity map ────────────────────────────────────
9921013
//
9931014
// The rules in this file were reviewed as ONE family and split on a single
994-
// question: is this stack enough to know the flow is dead? Three rules answer
995-
// yes and gate; two hedge and advise. The split is the contract, so it is
996-
// pinned as a map rather than as five scattered `severity` lines — a later
997-
// rule added to this file has to decide which side it is on, and a later edit
998-
// that quietly demotes one of the three has to come past this test.
1015+
// question: is this stack enough to know the flow is dead? Four rules answer
1016+
// yes and gate; three hedge and advise. The split is the contract, so it is
1017+
// pinned as a map rather than as scattered `severity` lines — a later rule
1018+
// added to this file has to decide which side it is on, and a later edit that
1019+
// quietly demotes one of the gating ones has to come past this test.
1020+
//
1021+
// [#16659] `flow-schedule-organization-missing` is the one entry whose side is
1022+
// NOT decided by that question: it answers YES and still advises, because an
1023+
// `error` gates `objectstack build` and the repo's own shipped example apps
1024+
// carry time-triggered flows with no authorable repair. Its docblock records
1025+
// that, and this table is where a later promotion has to come past.
9991026
//
10001027
// Every entry is provoked through a real stack, so an id whose criterion stops
10011028
// firing fails here instead of passing vacuously (the empty-verdict trap: an
@@ -1113,6 +1140,25 @@ describe('validateFlowTriggerReadiness', () => {
11131140
flows: [recordFlow()],
11141141
},
11151142
],
1143+
[
1144+
// [#16659] `warning`, and its own docblock records why the corpus, not
1145+
// the verdict, is what holds it there.
1146+
FLOW_SCHEDULE_ORGANIZATION_MISSING,
1147+
'warning',
1148+
{
1149+
objects: [{ name: 'task', label: 'Task', fields: {} }],
1150+
flows: [
1151+
{
1152+
name: 'orgless_digest',
1153+
type: 'schedule',
1154+
status: 'active',
1155+
nodes: [
1156+
{ id: 'start', type: 'start', config: { schedule: { type: 'cron', expression: '0 1 * * *' } } },
1157+
],
1158+
},
1159+
],
1160+
},
1161+
],
11161162
];
11171163

11181164
for (const [rule, severity, stack] of provoke) {
@@ -1159,7 +1205,15 @@ describe('validateFlowTriggerReadiness', () => {
11591205
{
11601206
id: 'start',
11611207
type: 'start',
1162-
config: { timeRelative: { object: 'task', dateField: 'due_at', withinDays: 30 } },
1208+
// [#16659] The clean fixture gained `organization`: after this
1209+
// card a CORRECT time-triggered flow declares the organization
1210+
// it runs as, so a fixture without one is no longer clean —
1211+
// the trigger would refuse to bind it. ⛔ Not a relaxation of
1212+
// the floor this case guards; the floor moved.
1213+
config: {
1214+
timeRelative: { object: 'task', dateField: 'due_at', withinDays: 30 },
1215+
organization: 'org_2mtx1w9d0k4bqf7v',
1216+
},
11631217
},
11641218
],
11651219
},
@@ -1169,6 +1223,118 @@ describe('validateFlowTriggerReadiness', () => {
11691223
});
11701224
});
11711225

1226+
// ─── the acting organization (#16659) ───────────────────────────────────
1227+
//
1228+
// `engine.ts`'s trigger-kind resolver states the invariant: it is shared with
1229+
// `defineStack`'s trigger-capability refusal and with this file, "so the
1230+
// runtime cannot drift from what authoring accepted". A key the two triggers
1231+
// REFUSE to bind without, and that authoring never mentions, is that drift —
1232+
// an author's first signal was a production stderr line at boot.
1233+
describe('acting organization (#16659)', () => {
1234+
const taskObject = { name: 'task', label: 'Task', fields: {} };
1235+
1236+
function timeTriggered(config: Record<string, unknown>, overrides: Record<string, unknown> = {}) {
1237+
return {
1238+
objects: [taskObject],
1239+
flows: [
1240+
{
1241+
name: 'digest',
1242+
type: 'schedule',
1243+
status: 'active',
1244+
nodes: [{ id: 'start', type: 'start', config }],
1245+
...overrides,
1246+
},
1247+
],
1248+
};
1249+
}
1250+
1251+
const orgFindings = (stack: Record<string, unknown>) =>
1252+
validateFlowTriggerReadiness(stack).filter((f) => f.rule === FLOW_SCHEDULE_ORGANIZATION_MISSING);
1253+
1254+
it('fires on a `schedule` flow that declares none', () => {
1255+
const findings = orgFindings(timeTriggered({ schedule: { type: 'cron', expression: '0 1 * * *' } }));
1256+
expect(findings).toHaveLength(1);
1257+
expect(findings[0].path).toBe('flows[0].nodes[0].config.organization');
1258+
expect(findings[0].message, 'the sentence is the trigger\'s own, so the two cannot drift').toContain(
1259+
'declares no acting organization',
1260+
);
1261+
expect(findings[0].message, 'the refusal names the flow — the ruling requires that').toContain('digest');
1262+
});
1263+
1264+
it('fires on a `time_relative` sweep that declares none, and says WHICH kind', () => {
1265+
const findings = orgFindings(
1266+
timeTriggered({ timeRelative: { object: 'task', dateField: 'due_at', withinDays: 7 } }),
1267+
);
1268+
expect(findings).toHaveLength(1);
1269+
expect(
1270+
findings[0].message,
1271+
'a sweep must be named as one — the two kinds take the same refusal for different reasons',
1272+
).toContain('time-relative flow');
1273+
});
1274+
1275+
it('names the near-miss spelling the open `config` record accepted and ignored', () => {
1276+
const findings = orgFindings(
1277+
timeTriggered({ schedule: { type: 'cron', expression: '0 1 * * *' }, organizationId: 'org_x' }),
1278+
);
1279+
expect(findings).toHaveLength(1);
1280+
expect(
1281+
findings[0].message,
1282+
'an author who wrote `organizationId` is told about THEIR spelling, not about "nothing"',
1283+
).toContain('organizationId');
1284+
});
1285+
1286+
it('judges a present-but-unusable value exactly as the trigger does', () => {
1287+
// ⛔ Not a separate opinion: both read `resolveScheduleOrganization`, so a
1288+
// flow admitted by one and refused by the other is the silent hole again.
1289+
for (const bad of ['', 123, { id: 'org_x' }, null]) {
1290+
expect(
1291+
orgFindings(timeTriggered({ schedule: '0 1 * * *', organization: bad })),
1292+
`organization: ${JSON.stringify(bad)} must be judged undeclared`,
1293+
).toHaveLength(1);
1294+
}
1295+
});
1296+
1297+
it('is silent once the flow declares one', () => {
1298+
expect(
1299+
orgFindings(timeTriggered({ schedule: { type: 'cron', expression: '0 1 * * *' }, organization: 'org_a' })),
1300+
).toEqual([]);
1301+
});
1302+
1303+
it('never fires on a record_change, api, or manual flow', () => {
1304+
// Those bindings carry no organization BY CONSTRUCTION — they are fired by
1305+
// a caller who already holds one, and lifting a declared one onto them
1306+
// would let a flow overrule the tenant of the write that triggered it. A
1307+
// rule that asked them for the key would be asking for a defect.
1308+
expect(orgFindings({ objects: [candidateObject], flows: [recordFlow({ status: 'active' })] })).toEqual([]);
1309+
expect(
1310+
orgFindings({
1311+
objects: [taskObject],
1312+
flows: [
1313+
{
1314+
name: 'by_api',
1315+
type: 'api',
1316+
status: 'active',
1317+
nodes: [{ id: 'start', type: 'start', config: {} }],
1318+
},
1319+
],
1320+
}),
1321+
).toEqual([]);
1322+
expect(
1323+
orgFindings({
1324+
objects: [taskObject],
1325+
flows: [
1326+
{
1327+
name: 'by_hand',
1328+
type: 'autolaunched',
1329+
status: 'active',
1330+
nodes: [{ id: 'start', type: 'start', config: {} }],
1331+
},
1332+
],
1333+
}),
1334+
).toEqual([]);
1335+
});
1336+
});
1337+
11721338
it('handles map-keyed flows/objects and stacks with no flows', () => {
11731339
expect(validateFlowTriggerReadiness({})).toEqual([]);
11741340
const findings = validateFlowTriggerReadiness({

0 commit comments

Comments
 (0)