Found while implementing #5633 (widening flow-runas-unscoped to nested regions). Filed unassigned for triage, deliberately out of scope for that PR — the finding is pre-existing and unchanged by it.
What
content/docs/automation/flows.mdx teaches, as its worked "Scheduled flow" example:
export const contractExpirationCheck: Flow = {
name: 'contract_expiration_check',
label: 'Contract Expiration Check',
type: 'schedule',
status: 'active',
nodes: [
{ id: 'start', type: 'start', label: 'Start', config: {
triggerType: 'schedule',
schedule: { type: 'cron', expression: '0 0 * * *', timezone: 'UTC' },
} },
{ id: 'find_expiring', type: 'get_record', label: 'Find Expiring Contracts' },
{ id: 'notify_owners', type: 'notify', label: 'Notify Owners' },
{ id: 'end', type: 'end', label: 'End' },
],
...
};
No runAs. A schedule run resolves no trigger user, so the effective runAs is the spec default 'user' and the get_record has no identity to scope to — which flow-runas-unscoped reports at severity: 'error', i.e. an author who copies this snippet gets a failed build, and if they force it past, a run the engine refuses (#3760).
Measured
Run against the rule as-is on origin/main (414395b9a), the snippet transcribed verbatim:
1 x flow-runas-unscoped <- flows.mdx contract_expiration_check
error: schedule-triggered flow runs as the default `runAs:'user'`, but a schedule run has
no trigger user - so its data node 'find_expiring' (get_record) has no identity to scope
to and will be REFUSED at run time.
The same probe over the neighbouring examples returns 0 — they are correct:
0 x flow-runas-unscoped <- flows.mdx renewal_reminder (runAs: 'system')
0 x flow-runas-unscoped <- SKILL.md scheduled update (runAs: 'system')
Why it matters
Two independent reasons, and the second is the sharper one:
- The corpus contradicts itself in the same file. ~35 lines below the broken snippet, the time-relative example gets it right and even carries the rationale as a comment:
runAs: 'system', // a sweep has no trigger user - elevate explicitly. And the property table in the same document (flows.mdx, the runAs row) already states the rule in prose: "declare 'system' for schedule / time-relative / API triggers". So the document explains the constraint twice and then violates it once, in the example most likely to be copied wholesale.
- This is the corpus AI authors from.
check-doc-authoring.mjs's own header states the case for treating content/docs/ as corpus: a bad sample there is copied into app code by the next agent that reads it. Nothing type-checks or lints fenced blocks against the authoring rules, so a snippet can teach a shape the gate rejects indefinitely — this one has.
Fix
One line: add runAs: 'system' to contract_expiration_check, ideally with the same one-line rationale the neighbouring example carries. Docs-only.
Worth considering separately (not proposed here, and larger): nothing extracts fenced flow snippets and runs the authoring rules over them, which is the general mechanism that let this survive. check-doc-authoring.mjs scans for the bare-literal anti-pattern and check:skill-examples type-checks; neither evaluates a snippet's semantics. If that gate is wanted it should be its own issue.
Not caused by #5633
The evidence node here is find_expiring, a top-level get_record, so the rule flagged this snippet before #5633 and flags it identically after. The A/B over the docs/skills flow fixtures was run in both directions while implementing #5633 and is byte-identical — 1 hit on this snippet on both sides, 0 everywhere else. It is a corpus defect, not a regression, and it is not in #5633's scope.
Related
Found while implementing #5633 (widening
flow-runas-unscopedto nested regions). Filed unassigned for triage, deliberately out of scope for that PR — the finding is pre-existing and unchanged by it.What
content/docs/automation/flows.mdxteaches, as its worked "Scheduled flow" example:No
runAs. A schedule run resolves no trigger user, so the effectiverunAsis the spec default'user'and theget_recordhas no identity to scope to — whichflow-runas-unscopedreports atseverity: 'error', i.e. an author who copies this snippet gets a failed build, and if they force it past, a run the engine refuses (#3760).Measured
Run against the rule as-is on
origin/main(414395b9a), the snippet transcribed verbatim:The same probe over the neighbouring examples returns 0 — they are correct:
Why it matters
Two independent reasons, and the second is the sharper one:
runAs: 'system', // a sweep has no trigger user - elevate explicitly. And the property table in the same document (flows.mdx, therunAsrow) already states the rule in prose: "declare'system'for schedule / time-relative / API triggers". So the document explains the constraint twice and then violates it once, in the example most likely to be copied wholesale.check-doc-authoring.mjs's own header states the case for treatingcontent/docs/as corpus: a bad sample there is copied into app code by the next agent that reads it. Nothing type-checks or lints fenced blocks against the authoring rules, so a snippet can teach a shape the gate rejects indefinitely — this one has.Fix
One line: add
runAs: 'system'tocontract_expiration_check, ideally with the same one-line rationale the neighbouring example carries. Docs-only.Worth considering separately (not proposed here, and larger): nothing extracts fenced flow snippets and runs the authoring rules over them, which is the general mechanism that let this survive.
check-doc-authoring.mjsscans for the bare-literal anti-pattern andcheck:skill-examplestype-checks; neither evaluates a snippet's semantics. If that gate is wanted it should be its own issue.Not caused by #5633
The evidence node here is
find_expiring, a top-levelget_record, so the rule flagged this snippet before #5633 and flags it identically after. The A/B over the docs/skills flow fixtures was run in both directions while implementing #5633 and is byte-identical — 1 hit on this snippet on both sides, 0 everywhere else. It is a corpus defect, not a regression, and it is not in #5633's scope.Related
flow-runas-unscoped(severity error) still searches only top-level nodes — a scheduled flow whose data ops all live in aloopbody passes the build and is refused at run time #5633 — where this was found (widens the same rule to nested regions).error.content/docs/and.claude/count as corpus.