Skip to content

Commit 7e7e64b

Browse files
fix(lint): flow, hook, action and expression rule findings state each decision in words instead of a tracker number (stage 1) (#21462)
Part of #20749 Clause-②: no **Stage 1 of the `domain:spec` lane's share under the maintainer's A / A ruling (5902360492): the 53 ledgered tracker-number occurrences in nine `packages/lint/src` rule files (the flow, hook, action, approval and expression rules).** The card stays open for the later stages, so this PR carries no closing keyword. Text only: no rule, condition, code path, rule id or severity moves (AST skeleton proof below, 9 of 9 SAME). ## What this does These nine files print findings to authors through `os validate`, `os lint` and `os build` (and, for the startup-registry rule, to plugin authors through the exported decision procedure). 53 tracker numbers in those findings sent the reader to a card for the reason behind them. In form D, as the sibling lane's stages applied it, the number goes. Where the sentence already said what was decided, only the citation goes. Where it leaned on the number, it now says the decision in words. The surface, re-derived from the ledger and from `check-doc-authoring.mjs --census` on `origin/main` at `b94a2a7277` (the claim's numbers hold exactly): `lint-flow-patterns.ts` 19, `lint-startup-registry-verdict.ts` 11, `validate-flow-template-paths.ts` 5, `validate-expressions.ts` 5, `validate-flow-trigger-readiness.ts` 4, `validate-hook-body-writes.ts` 3, `validate-action-body-writes.ts` 2, `validate-readonly-flow-writes.ts` 2, `validate-approval-approvers.ts` 2. They sit in 49 census sites (43 string groups once a `+` chain is read as one string). By class: 41 occurrences in finding envelopes (a `message` or `hint`), 12 in prose that a finding carries (the startup-registry vocabulary notes and shared hint, the field-rule consequence and prescription constants in `validate-expressions.ts`, and the unprovisioned-anchor consequence helper). None is thrown or logged. Every cited card (28, plus `objectui#6010`) was read through REST, body and every comment, before its string was rewritten. Where a card closed with no comment, its landing commit was read. ### Rewritten in words Lines are the census lines at `b94a2a7277`. | Where | Cited | The text now says | Decision read from | |---|---|---|---| | `lint-flow-patterns.ts:1555`, record-change date-equality hint | 1874 | "Or declare the sweep instead: a `schedule` flow whose start node carries a `config.timeRelative` descriptor (the object, the date field, and `withinDays` or `offsetDays`) is swept daily and runs once per record whose date falls in the window." | no comments; landing commit `a2795f6412` ("declarative time-relative trigger", the commit that closed the card): a start node declaring `config.timeRelative` is swept daily and launched once per matching record | | `lint-flow-patterns.ts:642`, date-equality filter hint | 1874 | "A T-minus rule can declare the sweep instead: a `schedule` flow whose start node carries a `config.timeRelative` descriptor with `offsetDays` runs once per record on each offset day." | same; `offsetDays` builds one single-day window per offset (`trigger-schedule/src/time-relative-trigger.ts`) | | `lint-flow-patterns.ts:1199`, `:1202`, unbounded bulk-write hint | 3810, 5482, 5393 | "`multi: true` is how a flow declares bulk intent, and the engine admits a whole-object write declared that way, so this is a warning, not a gate ... Distinct from the run-time erased-condition guard, which REFUSES this node ..." | 5393: PM ruling A in the card body and ACCEPT 5192653833 (a bulk-intent key on both nodes, forwarded as `options.multi`); 5482: triage 5192651560 (a warning, not a spec refine, because explicit whole-object cleanup is an intent the engine admits) and ACCEPT 5198480630; 3810: 5102018528 (a CRUD node refuses when interpolation erased a condition the author wrote, judged on loss rather than emptiness) | | `lint-flow-patterns.ts:1266`, revise-target hint | 3823, 3801 | "because a 'wait' is resumable by anyone with the run id, while the run-resume route continues a pause on a service-owned node type only through the service that owns it." | 3801: card body (the resume gate keys on the suspended node's type); 3823: maintainer ruling 5194604990 (the revise pause becomes a typed, service-owned node so that gate covers it) and ACCEPT 5195638435 | | `lint-flow-patterns.ts:1609`, unscoped `runAs` hint | 1888, 3760 | "there is none, and `runAs` is enforced: `'user'` scopes each data operation to the triggering user's grants, and with no trigger user the runtime refuses the operation rather than run it unscoped. (ADR-0049, ADR-0073 D5)" | 1888: 4791554026 (ENFORCE: `system` elevates, `user` runs with the triggering user's grants); 3760: 5099989877 (option 2, runtime fail-closed) | | `lint-flow-patterns.ts:1706`, `:1714`, interpolation hints | 1315 | "a flow node value is a string template in which only single-brace `{…}` tokens resolve and all other text is literal" | 4709041508 (one value contract: a string template whose brace tokens resolve and whose other text is literal; the typed literal/expr/ref redesign declined) | | `lint-startup-registry-verdict.ts:201`, `:205`, `:209`, `:213`, open-vocabulary notes | 4771 | "... so the engine itself judges node types only once the vocabulary is sealed at `kernel:bootstrapped`" (the audit note: "reads it as a verdict only once ...") | ruling 5162805181 and ACCEPT 5163195020 (the check moves to the moment the vocabulary is closed, `sealNodeTypeVocabulary()` at `kernel:bootstrapped`) | | `lint-startup-registry-verdict.ts:359`, `:361`, the shared hint's cures 1 and 3 | 4772, 4769 | "(`createLazyCacheRateLimitStorage()` in plugin-auth, which takes the cache service only when a rate-limit counter is used)"; "(the ADR-0104 born-migrated attestation, written only after the first boot has seeded its data)" | 4772: ruling 5162806718 and ACCEPT 5163087460 (resolve the cache lazily where the counter is used); 4769: ruling 5162804083 and ACCEPT 5163311202 (the attestation moves after the first boot's seed) | | `lint-startup-registry-verdict.ts:762`, `:764`, assertive-wording finding | 4771, 4772 | "The flow engine's node-type check once printed ... the engine now judges node types only once the vocabulary is sealed at `kernel:bootstrapped`. The auth plugin's missing-cache warning prescribed "you need Redis" ... the plugin now resolves the cache where a rate-limit counter uses it." | as the two rows above | | `validate-expressions.ts:863`, field-level `visibleWhen` consequence; `:931`, the user-root prescription | `objectui#6010` | "evaluates it with the host scope bound, the `current_user` binding ADR-0089 D1 gives every runtime record surface"; "HAS bound these roots since the form renderer took up the `current_user` binding ADR-0089 D1 gives every runtime record surface" | `objectui#6010` claim 5395919798 and ACCEPT 5396359545 (ADR-0089 D1: runtime record surfaces bind `record` + `current_user`; form section and field predicates now receive the host scope) | | `validate-expressions.ts:1735`, retired `script` keys | 4343 | "retired in @objectstack/spec 17, which made `script` a call to a registered function and nothing else" | 5151704360 (the operator's direction change: `script` converges to a pure function call, the five branch keys retired) | | `validate-flow-trigger-readiness.ts:557`, array `triggerType` hint | 3457 | "multi-event arrays are deferred until two independent projects need a combination other than created-or-updated" | closing comment 5076318442 (not planned; restart when two independent real projects need a non-`write` combination). Same words as the `trigger-record-change` warning stage 2 of the services lane wrote | | `validate-action-body-writes.ts:371`, discarded `ctx.record` write | 4345 | "The snapshot stays read-only by design: an action's write channel is ctx.api." | no comments; landing commit `a4e268445e` (the snapshot stays read-only, writes go through `ctx.api`, and only the silence was the defect) | | `validate-readonly-flow-writes.ts:336`, `readonlyWhen` write | 3042 | "(a bulk update strips it from every matched row once any one of them is locked)" | 4990593694 (the bulk path drops a field locked in at least one matched row; an unlocked field lands) | | `validate-approval-approvers.ts:463`, unsupported approver type | 3508 | "it was deprecated rather than built, and is no longer offered for authoring" | 5087415288 (`queue`: deep deprecation, not an implementation) | | `validate-approval-approvers.ts:527`, empty-slate hint | 3424 | "recoverable only by a platform/tenant admin override, which may act on any pending request so that one nobody in its slate can decide never stays stuck" | 5071456833 (the admin-override recovery, not a refusal at creation). Same decision words as the services lane's `via_override` help | ### Citation only (the sentence already stated the decision) - `lint-flow-patterns.ts`: `:727` (3863, "Only runtime failures route — a guard refusal ... stays fatal by design"; ruling 5105017918); `:830`, `:849`, `:884`, `:886`, `:975`, `:1032` (4414, each hint already states its part of the routing model the fix landed in `529311469d` and `d449b0cf4d`: a branch label narrows, a `condition` gates, `isDefault` is the BPMN default flow, `config.condition` gates only a `start` node); `:1674` (1870, "the automation engine has no aggregate node ... Aggregation belongs in the data layer"). - `lint-startup-registry-verdict.ts`: `:360` (4771, the cure already names `sealNodeTypeVocabulary()` called at `kernel:bootstrapped`); `:742` (4771, 4772, the sentence states the two meanings of "absent" the record cannot tell apart). - `validate-flow-template-paths.ts`: `:656`, `:674`, `:712` (3810, "the node refuses to run at execution time"); `:719`, `:723` (3475, "add it to the start node's config.expand and the engine re-reads it as the run's identity"; plan 5076227883, landed in `5524f84764`). - `validate-expressions.ts`: `:870` (6146, the predicate faults and the renderer falls back to VISIBLE; landed in `8a88885cb2`); `:875` (4889, "`isReadonlyWhenLocked` will not waive a declared lock it could not evaluate"; ACCEPT 5169906972). - `validate-flow-trigger-readiness.ts`: `:530`, `:556`, `:743` (3427, "'write' fires on create OR update in one flow"; landed in `6f55c6300b`). - `validate-hook-body-writes.ts`: `:408`, `:937`, `:983` and `validate-action-body-writes.ts:431` (4271, the authoring warning these messages ARE, and "INVALID_FIELD / 400, identically on every driver"). - `validate-readonly-flow-writes.ts:311` (2948, the static strip under a non-system run, with `runAs:'system'` named as the intended channel in the hint; 4980638493). No occurrence was left in place: no cited decision was unclear, and each fit the string's space. ## Ledger (`scripts/doc-authoring-prose-id.baseline.json`) Recomputed with `node scripts/check-doc-authoring.mjs --census-ledger` (exit 0, no growth refusal) into a scratch file, then copied into place. The diff deletes 48 lines and adds none: exactly the nine file blocks. A scripted comparison of the 53 other keys: 0 moved. | | before (`b94a2a7277`) | after | |---|---|---| | the nine files | 53 occurrences, 30 pairs, 9 files | 0 | | `packages/lint` | 104 occurrences, 27 files | 51 occurrences, 18 files | | whole ledger | 227 occurrences, 157 pairs, 62 files | 174 occurrences, 127 pairs, 53 files | `pnpm check:doc-authoring` at the head: "sibling-package prose ids hold the baseline — 155 pinned site(s) across 53 file(s), 86566 string(s) read in 1257 parsed source(s), no growth, no burn-down unrecorded" (204 sites before). No gate is added or loosened; `scripts/check-doc-authoring.mjs` is untouched. Ledger serial: no open PR touches the ledger or any of the nine files (all 10 open PRs' file lists read before opening). `origin/main` has moved 6 commits past the branch point; none touches the ledger or the nine files, and the two `packages/lint` files it does touch census to the same counts on `origin/main` as this ledger pins (`authoring-rules.ts` six pairs equal; `validate-dataset-measure-aggregates.ts` none), so the recomputed ledger stands on the merged tree. ## Changeset `.changeset/20749-lint-strings-stage1-state-the-decision.md`: `patch` for `@objectstack/lint`, carrying `Clause-②: no`. Measured after building: every new sentence above is in `packages/lint/dist/index.js` and `index.cjs`, and every replaced id-bearing fragment is in neither (the one `(#3457)` left in `dist` is a source comment tsup keeps). ## Text-only proof A TypeScript-AST skeleton of each changed source: every string literal and template text is a placeholder, a `+` chain is flattened and a run of adjacent string operands is one string (only its embedded expressions are kept, in order), a chain made only of strings is a string, identifiers, numbers and regex literals keep their text, every child is visited, and comments are never read. A second leg compares the TEXT of every string group in order: each group that changed must have carried a tracker id before and carry none after, and every other group must be byte-identical. `b94a2a7277` against the head: 9 of 9 SAME on both legs, token counts identical per file, 43 groups changed, all of them id-bearing before and id-free after. Controls on a scratch copy of `lint-flow-patterns.ts`, each mutation counted on disk first: one identifier renamed reads DIFF; `===` flipped to `!==` reads DIFF; one template re-split into two `+` operands reads SAME with 0 groups changed; a text change in a string that never carried an id reads SAME on the skeleton and VIOLATION on the text leg. (The first draft of the tool read `lint-flow-patterns.ts` DIFF because the two interpolation hints went from one literal to a three-piece chain; the corrected tool reads an all-string chain as one string, which the re-split control pins.) ## Pins Every assertion that found a finding by its tracker number now finds it by the words that replaced it, at the same strength (`toContain` stays `toContain`, `toMatch` stays `toMatch`, the one `not.toContain` stays negative). No rule-id or severity assertion was touched. - `lint-flow-patterns.test.ts:1754`: `'the run-time erased-condition guard'` for `'#3810'`. - `lint-startup-registry-verdict.test.ts:69`: the wording finding by "the engine now judges node types only once the vocabulary is sealed at `kernel:bootstrapped`"; `:78`-`:80`: the shared hint by its three cures' words for the three ids. - `validate-flow-template-paths.test.ts:853`: "config.expand and the engine re-reads it as the run's identity". - `validate-expressions.test.ts:161`, `:1852`, `:1871`: the retired-`script` sentence, the ADR-0089 D1 binding sentence, and "will not waive a declared lock it could not evaluate". - `validate-flow-trigger-readiness.test.ts:738`: the deferral sentence. - `validate-hook-body-writes.test.ts:110`, `validate-action-body-writes.test.ts:166`: "INVALID_FIELD / 400, identically on every driver"; `validate-action-body-writes.test.ts:316`: the read-only snapshot sentence. - `validate-readonly-flow-writes.test.ts:59`, `:146`, `:236`: the update strip and bulk sentences; `:367` (negative): the create-side message must not contain "from the UPDATE payload, so this write never lands", the phrase `:59` proves the update message carries. - `validate-approval-approvers.test.ts:83`: "it was deprecated rather than built". ## Tests All through `scripts/pm/os-verify-lock.sh`, every verdict `VERDICT command-exit 0`: - Build: `pnpm --filter '@objectstack/lint^...' build` (the closure), then `pnpm turbo run build --concurrency=2 --filter=./packages/* --filter=./packages/*/*` (71/71) for the dist-reading gates. - `@objectstack/lint`: `vitest run --maxWorkers=2`: 119 files, 5587 passed, 5 skipped. `typecheck`: exit 0, including `check:test-typecheck: OK`. ## Gates - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (no paths): 70 commands over 20 paths, run one at a time from the worktree, each exit code recorded before any pipe. `--ran`: "70 derived famil(ies) accounted for — 70 run, 0 NOT-MEASURED (a DERIVED zero — all 70 recorded an exit code and none of them is 3)". Three dist-reading gates (`check:docs-transcript-drift`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`) first answered `PREREQUISITE NOT MET` with only the closure built; after the full build they and the other dist-reading gates were re-run, all exit 0. - Named by the dispatch: `check:doc-authoring` (self-test and run) exit 0; `check:nul-bytes` exit 0; `check-adr-0087-registration` exit 0; `check:empty-changeset` exit 0; `check-changeset-fixed` exit 0; `check-changeset-no-major` exit 0 on the plain run, and exit 0 when fed this body as a `pull_request` event (the `Clause-②: no` line read, `patch` judged against it). - Outside the derived set, all exit 0: the eleven declared wide-population families (`check:startup-registry-verdict` among them), the artifact-roster families whose roster sits under one of this PR's directories (`check-published-list-mirrors` and its self-test, `check:authz-resolver`, `check:console-injection`, `check:error-code-casing`, `check:filter-alias-parity`, `check:i18n-stale-fill`, `check:published-readme-exports`, `check-dts-references --self-test`), and `@objectstack/lint`'s `check:doc-formula-expressions`, which imports `fieldRuleRootIssue` and prints its message. - Repo-wide `pnpm lint` is CI's. ## Acceptance notes Noted, not filed: - Test titles and test-only strings in these nine files' tests still cite numbers, outside stage 1: 132 ids in test titles and 9 in other test strings, across `lint-flow-patterns.test.ts` (32 / 0), `lint-startup-registry-verdict.test.ts` (12 / 0) and its `.corpus.test.ts` (3 / 4), `validate-expressions.test.ts` (54 / 4), `validate-flow-template-paths.test.ts` (10 / 0), `validate-flow-trigger-readiness.test.ts` (10 / 1), `validate-hook-body-writes.test.ts` (4 / 0), `validate-action-body-writes.test.ts` (4 / 0), `validate-approval-approvers.test.ts` (2 / 0) and `validate-readonly-flow-writes.test.ts` (1 / 0). The ledger does not read test files. - `packages/lint/scripts/check-doc-formula-expressions.mjs` prints an epilogue that still says `since objectui#6010`, and its self-test pins that spelling; `scripts/check-startup-registry-verdict.mjs` keeps the `(#4771)` citation in two of its own vocabulary notes. Both are `.mjs` gate scripts the ledger does not read, outside this stage. Carrier: none. - `validate-approval-approvers.ts`'s `queue` hint still says "Queue approvers need a real ownership-queue implementation before they take effect", which reads as pending work where the decision was deprecation. It carries no number and is outside form D. Carrier: none. - Comments in the nine files keep their ids: a comment is the sanctioned home for an internal anchor. --- _Generated by [Claude Code](https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4c8363f commit 7e7e64b

20 files changed

Lines changed: 126 additions & 124 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
Flow, hook, action, approval and expression rule findings no longer cite tracker numbers; each one states the decision behind it in words
6+
7+
Clause-②: no
8+
9+
Some findings these rules show to authors through `os validate`, `os lint` and `os build`, and the startup-registry findings a plugin author reads, pointed at an issue-tracker number for the reason behind them. The number goes; where the sentence did not already say what was decided, it now does.
10+
11+
- Flow patterns: the record-change date-equality hint and the date-equality filter hint name the declarative alternative, a `schedule` flow whose start node carries a `config.timeRelative` descriptor; the unscoped `runAs` hint says `runAs` is enforced, so a run with no trigger user has its data operations refused rather than run unscoped; the unbounded bulk-write hint says `multi: true` is how a flow declares bulk intent and that the engine admits a whole-object write declared that way; the revise-target hint says the run-resume route continues a pause on a service-owned node type only through the service that owns it; the two interpolation hints say a flow node value is a string template in which only single-brace tokens resolve.
12+
- Startup-registry findings: the open-vocabulary notes say the engine judges node types only once the vocabulary is sealed at `kernel:bootstrapped`; the prescription describes the lazy cache resolution and the ADR-0104 attestation by what each does; the assertive-wording finding describes its two incidents, and how each was fixed, in words.
13+
- Expression findings: the field-level `visibleWhen` consequence names the `current_user` binding ADR-0089 D1 gives every runtime record surface; the retired `script` keys finding says spec 17 made `script` a call to a registered function and nothing else.
14+
- Trigger readiness: the array `triggerType` hint says multi-event arrays are deferred until two independent projects need a combination other than created-or-updated.
15+
- Body writes, readonly writes and approvals: the discarded `ctx.record` write says the snapshot stays read-only by design and an action writes through `ctx.api`; the `readonlyWhen` write finding says a bulk update strips the field from every matched row once any one of them is locked; the `queue` approver finding says the type was deprecated rather than built; the empty-slate hint says the admin override may act on any pending request, so that one nobody in its slate can decide never stays stuck.
16+
- The other findings drop a citation the sentence already explained.
17+
18+
Text only: no rule id, severity, condition or finding moves. A tool or test that matches the old text (for example a tracker-number suffix) needs the new spelling.

‎packages/lint/src/lint-flow-patterns.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1751,7 +1751,7 @@ describe('lintFlowPatterns — unbounded bulk write (#5482)', () => {
17511751
const [f] = lintFlowPatterns(purgeFlow('delete_record', { objectName: 'lead', multi: true }));
17521752
// Cross-naming, not duplication: the run-time guard refuses "a condition you
17531753
// WROTE is gone"; this rule warns "no condition was ever written".
1754-
expect(f.hint).toContain('#3810');
1754+
expect(f.hint).toContain('the run-time erased-condition guard');
17551755
expect(f.hint).toMatch(/REFUSES this node at run time/);
17561756
expect(f.hint).toMatch(/a written condition is gone/);
17571757
expect(f.hint).toMatch(/the filter is empty/);

‎packages/lint/src/lint-flow-patterns.ts‎

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -639,7 +639,9 @@ function scanFilterForDateEquality(
639639
`time component, so exact equality against \`${hit.src}\` (re-computed each run) silently matches nothing.`,
640640
hint:
641641
`Use a one-day window instead: \`${key}: { $gte: daysFromNow(N), $lt: daysFromNow(N+1) }\` ` +
642-
`(wrap multiple tiers in \`$or\`). The abutting windows tile the timeline so each row matches exactly once. (#1874)`,
642+
`(wrap multiple tiers in \`$or\`). The abutting windows tile the timeline so each row matches exactly once. ` +
643+
`A T-minus rule can declare the sweep instead: a \`schedule\` flow whose start node carries a ` +
644+
`\`config.timeRelative\` descriptor with \`offsetDays\` runs once per record on each offset day.`,
643645
rule: FLOW_DATE_EQUALITY_FILTER,
644646
});
645647
}
@@ -724,7 +726,7 @@ function scanErrorLabelledEdges(
724726
hint:
725727
`Add \`type: 'fault'\` to this edge. Only runtime failures route — a guard refusal (a filter token ` +
726728
`that resolved to nothing, a missing required config key, an unscoped run) stays fatal by design and ` +
727-
`must be fixed in the metadata, not handled. (#3863)`,
729+
`must be fixed in the metadata, not handled.`,
728730
rule: FLOW_ERROR_LABEL_NOT_FAULT,
729731
});
730732
}
@@ -827,7 +829,7 @@ function scanBranchRouting(
827829
`guarded branch. The condition wins and the default marker routes nothing.`,
828830
hint:
829831
`Drop one: keep \`condition\` for a guarded branch, or drop it and keep \`isDefault: true\` ` +
830-
`for the "otherwise" path. (#4414)`,
832+
`for the "otherwise" path.`,
831833
rule: FLOW_DEFAULT_EDGE_WITH_CONDITION,
832834
// Gating: the two keys contradict, the condition always wins, and the
833835
// marker never routes. No reading makes it do what it says.
@@ -846,7 +848,7 @@ function scanBranchRouting(
846848
`when no condition matches, which is a parallel fan-out, not an "otherwise".`,
847849
hint:
848850
`Keep \`isDefault: true\` on the single fallback edge and give the others a \`condition\` ` +
849-
`(or leave them unconditional if the fan-out really is intended). (#4414)`,
851+
`(or leave them unconditional if the fan-out really is intended).`,
850852
rule: FLOW_MULTIPLE_DEFAULT_EDGES,
851853
});
852854
}
@@ -881,9 +883,9 @@ function scanBranchRouting(
881883
hint:
882884
nodeType === 'decision'
883885
? `Branching lives on the OUT-EDGES: give each branch its own \`condition\` and mark the ` +
884-
`fallback \`isDefault: true\`. If the edges already carry the predicate, delete this copy. (#4414)`
886+
`fallback \`isDefault: true\`. If the edges already carry the predicate, delete this copy.`
885887
: `Delete it, or move the predicate to the incoming edge's \`condition\` if this step was ` +
886-
`meant to be conditional. (#4414)`,
888+
`meant to be conditional.`,
887889
rule: FLOW_INERT_NODE_CONDITION,
888890
});
889891
}
@@ -972,7 +974,7 @@ function scanBranchRouting(
972974
hint:
973975
`Make an out-edge's \`label\` match the declared branch exactly, or drop \`config.conditions\` ` +
974976
`and branch on the edges instead (\`condition\` per branch + \`isDefault: true\` on the ` +
975-
`fallback) — one mechanism per decision, never both. (#4414)`,
977+
`fallback) — one mechanism per decision, never both.`,
976978
rule: FLOW_BRANCH_LABEL_UNMATCHED,
977979
// Gating: a label nothing claims cannot route under ANY reading, on
978980
// every run. See the severity policy at the top of this file.
@@ -1029,7 +1031,7 @@ function scanBranchRouting(
10291031
`decision declares a matching \`conditions[].label\`.`,
10301032
hint:
10311033
`Mark the fallback \`isDefault: true\` so it is taken only when no sibling condition matched ` +
1032-
`(BPMN default flow), or give it its own \`condition\`. (#4414)`,
1034+
`(BPMN default flow), or give it its own \`condition\`.`,
10331035
rule: FLOW_DECISION_UNCONDITIONAL_BRANCH,
10341036
});
10351037
}
@@ -1195,11 +1197,13 @@ function scanUnboundedBulkWrites(
11951197
hint:
11961198
`Write the constraint you mean into \`filter\` (e.g. \`{ status: 'closed' }\` — see ` +
11971199
`examples/app-showcase \`showcase_inquiry_purge\`, bulk intent bounded by a predicate). If emptying ` +
1198-
`'${objectName}' really is the intent, keep it: this is a warning, not a gate, and the run-time path ` +
1199-
`stays open. Distinct from the #3810 erased-condition guard, which REFUSES this node at run time when ` +
1200+
`'${objectName}' really is the intent, keep it: \`multi: true\` is how a flow declares bulk intent, and ` +
1201+
`the engine admits a whole-object write declared that way, so this is a warning, not a gate, and the ` +
1202+
`run-time path stays open. Distinct from the run-time erased-condition guard, which REFUSES this node ` +
1203+
`at run time when ` +
12001204
`a condition you WROTE interpolated to nothing — that guard is keyed on "a written condition is gone" ` +
12011205
`and deliberately not on "the filter is empty", which is the fact this rule judges at authoring ` +
1202-
`time. (#5482, #5393)`,
1206+
`time.`,
12031207
// Warning, not `error`: see the severity policy at the top of this file.
12041208
// The shape has a legitimate reading the engine grants on purpose, so it is
12051209
// not provably wrong — unlike the gating members of this family.
@@ -1263,7 +1267,8 @@ function scanApprovalReviseLoops(
12631267
`Set node '${target}' to \`type: '${APPROVAL_REVISE_NODE_TYPE}'\` (drop any \`waitEventConfig\` \u2014 the ` +
12641268
`window is ended by POST /api/v1/approvals/requests/:id/resubmit, not by a signal). ADR-0044 D3 ` +
12651269
`originally said 'wait' here; its 2026-07-28 amendment reversed that, because a 'wait' is ` +
1266-
`resumable by anyone with the run id (#3823, #3801).`,
1270+
`resumable by anyone with the run id, while the run-resume route continues a pause on a ` +
1271+
`service-owned node type only through the service that owns it.`,
12671272
rule: FLOW_APPROVAL_REVISE_TARGET_NOT_SERVICE_OWNED,
12681273
});
12691274
}
@@ -1552,7 +1557,10 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] {
15521557
`record happens to be written on that exact day, so unattended "N days before" rules never run.`,
15531558
hint:
15541559
`Use a SCHEDULE trigger (daily cron) + a range query instead — e.g. a scheduled flow whose ` +
1555-
`get_record filters \`end_date\` BETWEEN {TODAY()} and {TODAY()+N}. (#1874)`,
1560+
`get_record filters \`end_date\` BETWEEN {TODAY()} and {TODAY()+N}. Or declare the sweep ` +
1561+
`instead: a \`schedule\` flow whose start node carries a \`config.timeRelative\` descriptor (the ` +
1562+
`object, the date field, and \`withinDays\` or \`offsetDays\`) is swept daily and runs once per ` +
1563+
`record whose date falls in the window.`,
15561564
rule: FLOW_TIME_RELATIVE_ANTIPATTERN,
15571565
});
15581566
}
@@ -1605,8 +1613,10 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] {
16051613
`will be REFUSED at run time.`,
16061614
hint:
16071615
`Declare \`runAs:'system'\` to make the elevation explicit and intended (the run reads/writes ` +
1608-
`every record). A ${userLessKind} flow cannot scope to a user — there is none. ` +
1609-
`(ADR-0049, ADR-0073 D5, #1888, #3760)`,
1616+
`every record). A ${userLessKind} flow cannot scope to a user — there is none, and \`runAs\` is ` +
1617+
`enforced: \`'user'\` scopes each data operation to the triggering user's grants, and with no ` +
1618+
`trigger user the runtime refuses the operation rather than run it unscoped. ` +
1619+
`(ADR-0049, ADR-0073 D5)`,
16101620
rule: FLOW_RUNAS_UNSCOPED,
16111621
severity: 'error',
16121622
});
@@ -1671,7 +1681,7 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] {
16711681
`silently ignored and this node computes nothing at runtime.`,
16721682
hint:
16731683
`Aggregation belongs in the data layer: use \`Field.summary\` for a cross-object rollup ` +
1674-
`(sum/count of children), or \`Field.formula\` for a per-record computed value. (#1870)`,
1684+
`(sum/count of children), or \`Field.formula\` for a per-record computed value.`,
16751685
rule: FLOW_PHANTOM_AGGREGATION,
16761686
});
16771687
}
@@ -1703,15 +1713,21 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] {
17031713
findings.push({
17041714
where: nodeWhere,
17051715
message: `double-brace interpolation \`${str.trim().slice(0, 80)}\` — flow node values use SINGLE braces.`,
1706-
hint: `Use \`{var}\` (e.g. \`{record.title}\`). Double-brace \`{{ }}\` is the formula/template-field dialect, not flow node values. (#1315)`,
1716+
hint:
1717+
`Use \`{var}\` (e.g. \`{record.title}\`): a flow node value is a string template in which only ` +
1718+
`single-brace \`{…}\` tokens resolve and all other text is literal. Double-brace \`{{ }}\` is ` +
1719+
`the formula/template-field dialect, not flow node values.`,
17071720
rule: FLOW_DOUBLE_BRACE_INTERP,
17081721
});
17091722
}
17101723
if (BARE_DOLLAR_REF.test(str)) {
17111724
findings.push({
17121725
where: nodeWhere,
17131726
message: `\`${str.trim().slice(0, 80)}\` looks like a reference written as a literal — a bare \`$ref.field\` is NOT interpolated.`,
1714-
hint: `Wrap it and bind a variable: \`{source.id}\` (or \`{$User.Id}\` for the current user). (#1315)`,
1727+
hint:
1728+
`Wrap it and bind a variable: \`{source.id}\` (or \`{$User.Id}\` for the current user) — a flow ` +
1729+
`node value is a string template in which only single-brace \`{…}\` tokens resolve and all ` +
1730+
`other text is literal.`,
17151731
rule: FLOW_BARE_DOLLAR_REF,
17161732
});
17171733
}

‎packages/lint/src/lint-startup-registry-verdict.test.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ describe('the #4771 shape — a flow node-type verdict drawn while the vocabular
6666
const wording = findings.filter((f) => f.rule === STARTUP_VERDICT_ASSERTIVE_WORDING);
6767
expect(wording).toHaveLength(1);
6868
expect(wording[0].message).toContain('"will fail"');
69-
expect(wording[0].message).toContain('#4771');
69+
expect(wording[0].message).toContain(
70+
'the engine now judges node types only once the vocabulary is sealed at `kernel:bootstrapped`',
71+
);
7072
expect(wording[0].message).toContain('the identical eight');
7173
});
7274

@@ -75,9 +77,11 @@ describe('the #4771 shape — a flow node-type verdict drawn while the vocabular
7577
expect(finding.hint).toBe(STARTUP_VERDICT_HINT);
7678
expect(finding.hint).toContain('createLazyCacheRateLimitStorage()');
7779
expect(finding.hint).toContain('sealNodeTypeVocabulary()');
78-
expect(finding.hint).toContain('#4769');
79-
expect(finding.hint).toContain('#4771');
80-
expect(finding.hint).toContain('#4772');
80+
expect(finding.hint).toContain(
81+
'the ADR-0104 born-migrated attestation, written only after the first boot has seeded its data',
82+
);
83+
expect(finding.hint).toContain('`AutomationEngine.sealNodeTypeVocabulary()`, called at `kernel:bootstrapped`');
84+
expect(finding.hint).toContain('which takes the cache service only when a rate-limit counter is used');
8185
}
8286
});
8387

0 commit comments

Comments
 (0)