diff --git a/.changeset/24563-list-view-out-of-closure.md b/.changeset/24563-list-view-out-of-closure.md index 5b2cdcb8934..f64ac8ca688 100644 --- a/.changeset/24563-list-view-out-of-closure.md +++ b/.changeset/24563-list-view-out-of-closure.md @@ -22,7 +22,7 @@ Clause-②: yes (narrowing) - A list view bound to an object inside the closure (the package's own, a declared dependency's, a platform/system object, an overlay row): same field verdicts, same refusals. - A write that names no package (an overlay write) is judged against the whole collection, exactly as before. An object that exists nowhere gets no closure verdict there; #24668 refuses it as `list-view-object-unknown` (see that changeset). -- The flow door: `flow-trigger-unknown-object` stays an advisory. +- The flow door: under the same closure, `flow-trigger-unknown-object` refuses an out-of-closure trigger object too (#24669, see that changeset). - `os validate`, `os build` and `os lint`: no closure is stated, so the rule skips an unresolvable binding as before. - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. - Measured before crossing: the 11 views shipped in this repository carry 39 list-view bindings, in `examples/app-crm`, `app-todo`, `app-showcase` and `app-multi-package`, the `create-objectstack` blank template, and `qa/downstream-contract`. None is outside its package's closure, and the door's `view` rule set refuses none of them, with the package's closure or without it. None of the 97 platform built-in list views retargets `data.object`. Deployed rows were not measured. diff --git a/.changeset/24669-flow-trigger-out-of-closure.md b/.changeset/24669-flow-trigger-out-of-closure.md new file mode 100644 index 00000000000..04d249c67ca --- /dev/null +++ b/.changeset/24669-flow-trigger-out-of-closure.md @@ -0,0 +1,34 @@ +--- +'@objectstack/lint': minor +--- + +fix(lint)!: the save door refuses a flow written into a package whose trigger object is outside that package's closure (#24669) + +Clause-②: yes (narrowing) + +**Why.** The runtime save door judges a write into a package against that package's closure (ruling #9612): the package's own objects, the objects of the packages its manifest declares in `dependencies`, platform/system objects, and provenance-less overlay rows. Every other object is dropped from the snapshot before any rule runs. The dataset door already refused a reference outside that closure (`object-reference-unknown`), and the view door refuses one too (`list-view-object-outside-closure`). The flow door only advised: `flow-trigger-unknown-object` stayed a `warning` there, with the hint "ignore this if another installed package declares it". Under a closure that hedge is false, because the snapshot is the closure: an object outside it is one the package never declared it may reference. So the same mistake was refused at two doors and stored with an advisory at the third. + +**What is refused.** A flow written into a package whose record trigger (`config.objectName` on a `record-*` start node) or time-relative sweep (`config.timeRelative.object`) names an object outside the package's closure is refused with one `flow-trigger-unknown-object` issue at `error`, located at that key. The hint names the remedy and the package. An object that exists nowhere is refused the same way, because the closure is all the snapshot sees. A `sys_`-prefixed name is still not judged, exactly as without a closure. + +**BREAKING — what moves for consumers.** This is an accept-set narrowing on the metadata save door, shipped as `minor` under the launch-window convention for accept-set narrowings (`.changeset/pre.json` is in `exit` mode, so the next version pass cuts 18.0.0 and this change ships in it). + +- FROM: a publish-mode write of a flow into a package, triggered on or sweeping an object outside the package's closure, answered `200` and was stored, with a `flow-trigger-unknown-object` save advisory. This covers `PUT /api/v1/meta/flow/:name?package=…` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/flow/:name/publish`, `publishMetaItem`), a package draft publish (`publishPackageDrafts`), and a package copy (`duplicatePackage`), which writes its copies through the same door. +- TO: `saveMetaItem` in publish mode, the draft promotion and `publishPackageDrafts` answer `422 INVALID_METADATA`, with the `flow-trigger-unknown-object` issue at `error` at the trigger key, and nothing is stored. A package draft publish fails the item, and the batch with it. In `duplicatePackage`, a copied flow whose trigger object stays outside the copy package's closure is refused with the same rule and listed in its `failed[]`. One way that happens is when that object's own copy was refused. +- Fix: declare a dependency on the package that defines the trigger object in the `dependencies` of the writing package's manifest, or trigger the flow on an object the package may reach (its own, a declared dependency's, or a platform object). Then save again. Saving the flow as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + +**The next save of a stored flow whose trigger object is outside its package's closure is refused with the fix; stored flows keep loading and firing until then.** The door judges writes only. A stored row is not re-judged at boot. + +**Unchanged.** + +- A flow triggered on an object inside the closure (the package's own, a declared dependency's, a platform/system object, an overlay row): no finding, as before. +- A write that names no package (an overlay write), a write naming a package the registry cannot produce, and the `sys_metadata` sentinel state no closure: an object the whole collection does not carry is still a `flow-trigger-unknown-object` save advisory, byte-identical to before. +- `os validate`, `os build` and `os lint`: no closure is stated, so the rule's verdict, severity and wording are unchanged. +- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. +- Measured before the change: every record-triggered or time-relative start node shipped in this repository (21, all in `examples/app-showcase` and `examples/app-todo`; no flow in `packages/apps`, `packages/platform-objects` or the `create-objectstack` template) triggers on an object its own stack defines, and objectui ships no such flow. Deployed rows were not measured. + +**API.** + +- `validateFlowTriggerReadiness(stack, options?)` accepts an optional `{ packageScope }` (`{ packageId, dependencies }`). Stated, an unknown trigger object is an `error`; omitted, the rule answers as before. +- The runtime publish gate hands `AuthoringRuleContext.packageScope` to this rule, which now reads it beside `validateListViewFieldRefs`. + + diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index 4165c1b8148..d1d4195b252 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -710,7 +710,9 @@ the written item's **package closure** — the written package, its declared dependencies, and unconditionally both platform/system objects and rows carrying no package provenance (#9612); a provenance it cannot read narrows nothing at all. That changes what a rule can **resolve**, never which rules run, and nothing at -this door walks `packages[]`. One rule is told the closure is in force: a list +this door walks `packages[]`. Two rules are told the closure is in force. A flow +whose trigger object is outside it is refused with `flow-trigger-unknown-object` +instead of advised (#24669), and a list view bound to an object outside it is refused with `list-view-object-outside-closure` instead of having its field references skipped (#24563). With no closure in force — a write that names no package — diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index e20b3e3df1a..a31bdab359a 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -384,9 +384,10 @@ export interface AuthoringRuleContext { * rule tell "outside what this package declared it may reference" from * "not in this stack": an object missing from a narrowed snapshot is the * first, one missing from a whole one may come from another package. Read - * by `validateListViewFieldRefs` only (through the reference-integrity + * by two rules: `validateListViewFieldRefs` (through the reference-integrity * suite), which refuses a list view bound outside the closure instead of - * skipping it. + * skipping it, and [#24669] `validateFlowTriggerReadiness`, which refuses a + * flow whose trigger object is outside it instead of advising. */ packageScope?: { readonly packageId: string; readonly dependencies: readonly string[] }; } @@ -1352,7 +1353,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // the flow fires. `flow-trigger-unknown-object` deliberately stayed `warning` // (the object may come from another installed package — a hedge this rule // cannot decide), as did `flow-draft-status-ambiguous` (draft flows DO fire; - // that one is ambiguity of intent, not a dead flow). + // that one is ambiguity of intent, not a dead flow). [#24669] The hedge is + // gone where the runtime publish gate states a package closure: the snapshot + // then IS that closure, so an unknown trigger object is one the package never + // declared it may reference, and the id is an `error` there — the dataset and + // view doors' verdict for the same reference. Without a stated closure (the + // three CLI commands, a write that names no package) it is still `warning`. // // Commit ecdfc9411 added a sixth id, `flow-schedule-organization-missing`, at // `warning`; #17396 RETIRED it. The criterion above is what retired it: this @@ -1372,10 +1378,13 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // stayed `warning` keep being logged as advisories. The gate judges a // snapshot whose `flows` holds only the written item and subtracts the // baseline's findings, so this refuses the dead flow's own publish — never - // another flow's save on account of a stored one. + // another flow's save on account of a stored one. [#24669] The gate's + // stated package closure is handed on, so an out-of-closure trigger object + // refuses the publish too; the CLI never states one. surfaces: CLI_AND_RUNTIME, runtimeTypes: ['flow'], - run: (stack) => validateFlowTriggerReadiness(stack), + run: (stack, ctx) => + validateFlowTriggerReadiness(stack, ctx.packageScope ? { packageScope: ctx.packageScope } : {}), }, // #20553 — `flow-api-trigger-secret-missing`, split out of the entry above as // its own exported rule. Same family, same `error`, all three commands. diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index 6ff756eaf8d..f76304a9c64 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -313,8 +313,9 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ // reference, or nowhere at all — and the member's skip 1 used to read that as // "another package's object" and judge nothing. It now refuses the binding // instead (`list-view-object-outside-closure`, `error`: the verdict the - // dataset door gives an out-of-closure base object under the same closure; - // the flow door's trigger-object advisory is a separate question). The + // dataset door gives an out-of-closure base object under the same closure, + // and [#24669] the one the flow door gives an out-of-closure trigger object, + // from its own rule outside this suite). The // missing-collection argument above is unchanged: the binding is judged // against `stack.objects`, the collection every per-write snapshot carries, // and the gate states the scope only when it narrowed that collection. diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 8ba09420acc..c0c671b20eb 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -1755,11 +1755,13 @@ const FLOW_API_TRIGGER_SECRET_MISSING_EXPLANATION: RuleExplanation = { }; // `flow-trigger-unknown-object` lives in the same rule file but is not one of -// the five: its verdict depends on packages a build cannot see, so it warns. +// the five: its verdict depends on packages a build cannot see, so it warns — +// except under a package closure the runtime publish gate states, where the +// snapshot is that closure and the id refuses. const FLOW_TRIGGER_UNKNOWN_OBJECT_EXPLANATION: RuleExplanation = { rule: 'flow-trigger-unknown-object', - covers: 'why an unknown trigger object only warns', + covers: 'why an unknown trigger object warns, and when it refuses', paragraphs: [ 'A record-triggered flow binds an ObjectQL hook filtered to the exact name in its start node\'s ' + '`config.objectName`, and a time-relative sweep queries the exact name in `config.timeRelative.object`. ' + @@ -1769,8 +1771,16 @@ const FLOW_TRIGGER_UNKNOWN_OBJECT_EXPLANATION: RuleExplanation = { 'another installed package, which a build of this stack cannot see, so the finding is a warning and ' + 'can be ignored when such a package declares the object; a `sys_` name is a platform object and is ' + 'not judged. Of the findings that say a flow never fires, it is the only one that stays advisory: ' + - 'the others are settled by contracts this repository ships. At the runtime publish gate it is a save advisory, ' + - 'judged against the live object registry, never a refusal.', + 'the others are settled by contracts this repository ships. At the runtime publish gate a write that ' + + 'names no package gets it as a save advisory, judged against the live object registry.', + 'A write into a package is judged against that package\'s closure instead: its own objects, the objects ' + + 'of the packages its manifest declares in `dependencies`, and platform objects. An object outside it ' + + 'is one the package never declared it may reference, and nothing it installs puts the object in ' + + 'reach until its manifest declares the dependency, so there the finding is an error and the publish ' + + 'is refused, the verdict the dataset and view doors give the same reference. Declare a dependency on ' + + 'the package that defines the object, or trigger the flow on an object inside the closure. A `sys_` ' + + 'name is not judged there either, a draft save is not gated, and a stored flow is judged at its next ' + + 'publish.', ], }; diff --git a/packages/lint/src/runtime-gate.package-closure.test.ts b/packages/lint/src/runtime-gate.package-closure.test.ts index 3c0a77bd2a6..7c0b97d4c48 100644 --- a/packages/lint/src/runtime-gate.package-closure.test.ts +++ b/packages/lint/src/runtime-gate.package-closure.test.ts @@ -32,6 +32,7 @@ import { runRuntimeAuthoringRules, type RuntimePackageScope, } from './runtime-gate.js'; +import { validateFlowTriggerReadiness } from './validate-flow-trigger-readiness.js'; import { validateListViewFieldRefs } from './validate-list-view-field-refs.js'; type AnyRec = Record; @@ -202,9 +203,20 @@ describe('the differential verdict survives package narrowing (#9612)', () => { it('a REAL finding survives narrowing — the equivalence is not vacuous', () => { // Asserted first and by content: every other case in this block compares // two empty verdicts, which would also be equal if the gate had stopped - // working. This one pins that the narrowed gate still FINDS things. + // working. This one pins that the narrowed gate still FINDS things — at the + // same rule and position. [#24669] Its severity is the one place it may + // differ: under the closure an object found nowhere is outside it, so the + // narrowed gate REFUSES what the whole-collection gate advises. + const at = (r: { errors: AnyRec[]; advisories: AnyRec[] }) => + [...r.errors, ...r.advisories].map((f) => `${f.rule}|${f.path}`); + const wholeRun = runRuntimeAuthoringRules({ type: 'flow', item: FLOW_INTO_GHOST, context: { objects } }); + const scopedRun = runRuntimeAuthoringRules({ + type: 'flow', item: FLOW_INTO_GHOST, context: { objects }, packageScope: SCOPE, + }); expect(whole('flow', FLOW_INTO_GHOST)).toHaveLength(1); - expect(scoped('flow', FLOW_INTO_GHOST)).toEqual(whole('flow', FLOW_INTO_GHOST)); + expect(at(scopedRun)).toEqual(at(wholeRun)); + expect(wholeRun.advisories.map((f) => f.severity)).toEqual(['warning']); + expect(scopedRun.errors.map((f) => f.severity)).toEqual(['error']); }); it('reaches the whole-stack answer exactly for a flow into its own package', () => { @@ -248,12 +260,17 @@ describe('the differential verdict survives package narrowing (#9612)', () => { // decision on the record rather than a surprise. A package's declared // `dependencies` bound what it MAY reference; a flow reaching into a // package this one never declared is not resolvable BY DECLARATION, and - // the closure says so. Measured severity here is advisory (`warning`), so - // it reports rather than refuses. + // the closure says so. [#24669] It REFUSES (`error`), the verdict the + // dataset and view doors give the same reference; it used to advise. expect(whole('flow', FLOW_INTO_UNDECLARED)).toEqual([]); const narrowed = scoped('flow', FLOW_INTO_UNDECLARED); expect(narrowed).toHaveLength(1); expect(narrowed[0]).toContain('flow-trigger-unknown-object'); + const run = runRuntimeAuthoringRules({ + type: 'flow', item: FLOW_INTO_UNDECLARED, context: { objects }, packageScope: SCOPE, + }); + expect(run.advisories).toEqual([]); + expect(run.errors.map((f) => [f.rule, f.severity])).toEqual([['flow-trigger-unknown-object', 'error']]); }); }); @@ -318,8 +335,8 @@ describe('the view door under a package closure (#24563)', () => { const result = scoped(listViewOn('other_0_0')); // An `error`, so the door refuses the write: the dataset door's verdict - // for an out-of-closure base object under the same closure. The flow - // door's `flow-trigger-unknown-object` (pinned above) stays advisory. + // for an out-of-closure base object under the same closure, and [#24669] + // the flow door's `flow-trigger-unknown-object` (pinned above and below). expect(result.advisories).toEqual([]); expect(result.errors).toHaveLength(1); expect(result.errors[0]).toMatchObject({ severity: 'error', rule: OUTSIDE }); @@ -398,7 +415,7 @@ describe('the view door under a package closure (#24563)', () => { expect(rules(scoped(listViewOn('nosuch_obj'), empty).errors)).toEqual(['list-view-object-unknown']); }); - it('the flow verdict is unchanged, and a stored object\'s out-of-closure list view is not charged to it', () => { + it('the flow verdict is the closure\'s, and a stored object\'s out-of-closure list view is not charged to it', () => { // A context object of the written package whose built-in list view // retargets an object outside the closure: the rule now refuses that list // view, in the baseline and the candidate alike, so it cancels — a flow @@ -420,6 +437,13 @@ describe('the view door under a package closure (#24563)', () => { const undeclared = flow(FLOW_INTO_UNDECLARED); expect(undeclared).toHaveLength(1); expect(undeclared[0]).toContain('flow-trigger-unknown-object'); + // [#24669] Refused, not advised: the flow's own trigger object is outside + // the closure. The stored sibling's list view still cancels. + const run = runRuntimeAuthoringRules({ + type: 'flow', item: FLOW_INTO_UNDECLARED, context: { objects: withSibling }, packageScope: SCOPE, + }); + expect(run.errors.map((f) => f.rule)).toEqual(['flow-trigger-unknown-object']); + expect(run.advisories).toEqual([]); }); it('the CLI reading is unchanged: no scope stated, skip 1 judges nothing', () => { @@ -434,3 +458,127 @@ describe('the view door under a package closure (#24563)', () => { ).toEqual([OUTSIDE]); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #24669 — the FLOW door under a package closure: a trigger object outside the +// closure is refused, at both positions the rule reads; one inside it, a +// platform object and a `sys_` name are admitted; with no closure stated the +// advisory is byte-identical to the CLI's. +// ───────────────────────────────────────────────────────────────────────────── + +/** A daily time-relative sweep over `object` — the rule's second position. */ +function sweepOf(name: string, object: string): AnyRec { + return { + name, + label: name, + status: 'active', + type: 'schedule', + runAs: 'system', + nodes: [ + { + id: 'start', + type: 'start', + label: 'Daily sweep', + config: { timeRelative: { object, dateField: 'name', withinDays: 7 } }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end', type: 'default', isDefault: false }], + }; +} + +describe('the flow door under a package closure (#24669)', () => { + const UNKNOWN = 'flow-trigger-unknown-object'; + const objects = [ + ...packagedTenant(3), + obj('tenant_overlay', undefined), + obj('stranger_flagged', STRANGER, { isSystem: true }), + ]; + const run = (item: AnyRec, scope?: RuntimePackageScope) => + runRuntimeAuthoringRules({ + type: 'flow', + item, + context: { objects }, + ...(scope !== undefined ? { packageScope: scope } : {}), + }); + const of = (fs: AnyRec[]) => fs.filter((f) => f.rule === UNKNOWN); + /** The two positions, each as (flow body, the finding path's tail). */ + const positions: Array<[string, (object: string) => AnyRec, string]> = [ + ['config.objectName', (o) => flowOn(`on_${o}`, o), 'config.objectName'], + ['config.timeRelative.object', (o) => sweepOf(`sweep_${o}`, o), 'config.timeRelative.object'], + ]; + + for (const [label, body, tail] of positions) { + it(`REFUSES an object of a package the written one never declared — ${label}`, () => { + const result = run(body('other_0_0'), SCOPE); + expect(of(result.advisories)).toEqual([]); + expect(of(result.errors)).toHaveLength(1); + const [f] = of(result.errors); + expect(f).toMatchObject({ severity: 'error', rule: UNKNOWN }); + expect(f.path.endsWith(tail)).toBe(true); + // The fix names the remedy and the package it applies to. + expect(f.hint).toContain(OWN); + expect(f.hint).toContain('dependencies'); + expect(f.hint).toContain(tail); + expect(f.message).toContain("'other_0_0'"); + expect(f.message).toContain(OWN); + }); + + it(`REFUSES an object that exists nowhere — the closure is all the snapshot sees — ${label}`, () => { + const result = run(body('ghost_object'), SCOPE); + expect(of(result.errors).map((f) => f.severity)).toEqual(['error']); + expect(of(result.advisories)).toEqual([]); + }); + + it(`admits the own package, a declared dependency, platform objects and overlay rows — ${label}`, () => { + for (const target of [ + 'crm_account', // the written package + 'core_user', // its declared dependency + 'platform_ledger', // a platform object by `isSystem` + 'stranger_flagged', // `isSystem` although a stranger package owns it + 'sys_audit_log', // a platform object by prefix, carried + 'sys_never_carried', // a `sys_` name the snapshot does not carry: unjudged, as without a closure + 'tenant_overlay', // a provenance-less overlay row + ]) { + const result = run(body(target), SCOPE); + expect([...of(result.errors), ...of(result.advisories)], target).toEqual([]); + } + }); + + it(`a write that names no package keeps the advisory, byte-identical to the CLI's — ${label}`, () => { + // The stranger's object is in the whole collection, so it is not + // unknown at all; an object that exists nowhere is ADVISED, not refused. + expect(of([...run(body('other_0_0')).errors, ...run(body('other_0_0')).advisories])).toEqual([]); + const advised = run(body('ghost_object')); + expect(of(advised.errors)).toEqual([]); + expect(of(advised.advisories)).toHaveLength(1); + const cli = validateFlowTriggerReadiness({ objects, flows: [body('ghost_object')] }) + .filter((f) => f.rule === UNKNOWN); + expect(cli).toHaveLength(1); + expect(cli[0]!.severity).toBe('warning'); + const [door] = of(advised.advisories); + expect({ severity: door.severity, message: door.message, hint: door.hint }).toEqual({ + severity: cli[0]!.severity, + message: cli[0]!.message, + hint: cli[0]!.hint, + }); + // An empty package id narrows nothing, so it states no closure either. + const empty = { packageId: '', dependencies: [] } as RuntimePackageScope; + expect(of(run(body('ghost_object'), empty).errors)).toEqual([]); + expect(of(run(body('ghost_object'), empty).advisories)).toHaveLength(1); + }); + } + + it('the CLI reading is unchanged: no scope stated, the advisory the rule always gave', () => { + // `os validate` / `os build` / `os lint` hand the rule no closure. + const [f] = validateFlowTriggerReadiness({ objects: [], flows: [flowOn('cli_probe', 'ghost_object')] }); + expect(f).toMatchObject({ severity: 'warning', rule: UNKNOWN }); + expect(f!.hint).toContain('ignore this if another installed package declares it'); + // And the same stack under a stated closure is the verdict above. + const [g] = validateFlowTriggerReadiness( + { objects: [], flows: [flowOn('cli_probe', 'ghost_object')] }, + { packageScope: SCOPE }, + ); + expect(g).toMatchObject({ severity: 'error', rule: UNKNOWN }); + }); +}); diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index b682e72a9f8..ba23081de9e 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -12,7 +12,10 @@ // to that exact name; if nobody writes it, the flow never fires. Names // match exactly (`eval_app_candidate`, not `candidate`). Objects owned by // other packages (`sys_*`, dependency packages) are legitimate targets, -// so this is a warning with the cross-package caveat, not an error. +// so this is a warning with the cross-package caveat, not an error — +// except under a package closure the runtime publish gate states, where +// an object outside it is one the package never declared and the same id +// is an error (#24669; see the Severity section below). // // 2. `status: 'draft'` on an auto-triggered flow — the schema default when // no status is authored (defineFlow parses at definition time, so by the @@ -120,12 +123,29 @@ // criterion is what disqualified it in the end: this stack is NOT enough to // know the flow is dead, because the deciding facts (the deployment switch, // the tenancy posture) are not in any stack. See 1g. -// - `warning` — `flow-trigger-unknown-object`, both halves. An object name -// this stack does not define may be defined by another installed package, -// and this rule cannot see that package's objects. The hedge is real, so the -// rule advises and says so in its own hint. It is the deliberate control for -// the paragraph above: the family was reviewed together (#5762) and this is -// the one that stayed advisory. +// - `warning` — `flow-trigger-unknown-object`, both halves, when no package +// closure is stated. An object name this stack does not define may be +// defined by another installed package, and this rule cannot see that +// package's objects. The hedge is real, so the rule advises and says so in +// its own hint. It is the deliberate control for the paragraph above: the +// family was reviewed together (#5762) and this is the one that stayed +// advisory. +// - `error` — the same id, both halves, when the runtime publish gate states +// a package closure (#24669, `options.packageScope`). The gate then hands +// this rule a snapshot whose `objects` ARE that closure (ruling #9612: the +// written package, the packages its manifest declares in `dependencies`, +// platform/system objects and provenance-less overlay rows), so the hedge +// is gone: an object outside it is one the package never declared it may +// reference, and nothing it can install without editing its own manifest +// puts the object in reach. It is the verdict the dataset door +// (`object-reference-unknown`) and the view door +// (`list-view-object-outside-closure`) give the same reference under the +// same closure, so one mistake gets one answer at three doors. Measured +// before the change: no flow in this repository or in objectui triggers on +// an object outside its package's closure. A `sys_`-prefixed name stays +// unjudged under a closure too, exactly as without one. The CLI commands +// and a write that names no package state no closure and keep the advisory +// unchanged. // - `warning` — `flow-draft-status-ambiguous`. Draft flows DO fire, so this is // an ambiguity of intent, not a dead flow. Gating it would refuse a stack // whose flows all work. @@ -155,7 +175,7 @@ import { TimeRelativeTriggerSchema, resolveFlowTriggerKind, } from '@objectstack/spec/automation'; -import { recordsOf } from './object-graph.js'; +import { recordsOf, suggestName } from './object-graph.js'; export type FlowTriggerReadinessSeverity = 'error' | 'warning'; @@ -405,8 +425,17 @@ function startNodeOf(flow: AnyRec): { node: AnyRec; index: number } | undefined * post-parse stacks. Its one dependency is the `@objectstack/spec` schema that * owns the `timeRelative` descriptor's contract, which is the point: the rule * ASKS that schema rather than restating it. + * + * @param options.packageScope [#24669] The package closure the runtime publish + * gate narrowed `stack.objects` to (`AuthoringRuleContext.packageScope`), + * present only when one was in force. Stated, an unknown trigger object is + * outside that closure and `flow-trigger-unknown-object` is an `error` (see + * the Severity section at the top of this file). The CLI never sets it. */ -export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadinessFinding[] { +export function validateFlowTriggerReadiness( + stack: AnyRec, + options: { packageScope?: { readonly packageId: string; readonly dependencies: readonly string[] } } = {}, +): FlowTriggerReadinessFinding[] { const findings: FlowTriggerReadinessFinding[] = []; const flows = recordsOf(stack.flows); if (flows.length === 0) return findings; @@ -416,6 +445,47 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines .map((o) => (typeof o.name === 'string' ? o.name : undefined)) .filter((n): n is string => !!n), ); + const closure = options.packageScope; + + /** + * [#24669] `flow-trigger-unknown-object` at one of its two positions, for a + * name the stack does not carry. With no closure stated it is the advisory + * it always was; under a stated closure the stack IS the closure, so the + * name is outside it and the finding refuses the publish, naming the + * remedy the dataset and view doors name: declare the dependency. + */ + const unknownTriggerObject = ( + flowName: string, + path: string, + objectName: string, + at: { verb: string; key: string }, + advisory: { message: string; hint: string }, + ): FlowTriggerReadinessFinding => { + if (!closure) { + return { + severity: 'warning', + rule: FLOW_TRIGGER_UNKNOWN_OBJECT, + where: `flow "${flowName}" › start node`, + path, + ...advisory, + }; + } + const pkg = closure.packageId; + return { + severity: 'error', + rule: FLOW_TRIGGER_UNKNOWN_OBJECT, + where: `flow "${flowName}" › start node`, + path, + message: + `${at.verb} object '${objectName}', which is outside the closure of package '${pkg}' (its own ` + + `objects, the objects of the packages its manifest declares in \`dependencies\`, and platform ` + + `objects), so the package may not bind a flow to it.` + + suggestName(objectName, objectNames), + hint: + `Declare a dependency on the package that defines '${objectName}' in the manifest \`dependencies\` ` + + `of package '${pkg}', or set ${at.key} to an object inside that closure`, + }; + }; flows.forEach((flow, flowIndex) => { const flowName = typeof flow.name === 'string' ? flow.name : `#${flowIndex}`; @@ -444,21 +514,25 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines if (isRecordTriggered && start) { const objectName = typeof config.objectName === 'string' ? config.objectName : undefined; if (objectName && !objectNames.has(objectName) && !objectName.startsWith('sys_')) { - findings.push({ - severity: 'warning', - rule: FLOW_TRIGGER_UNKNOWN_OBJECT, - where: `flow "${flowName}" › start node`, - path: `flows[${flowIndex}].nodes[${start.index}].config.objectName`, - message: - `targets object '${objectName}', which this stack does not define — if the name is wrong, ` + - `the flow will never fire (and the runtime stays silent about it).`, - // [#22161] One remedy line; that names match exactly, and why a - // name another package declares is not refused, are - // `os explain flow-trigger-unknown-object`. - hint: - `Set config.objectName to the object's exact registered name (e.g. 'app_candidate', not ` + - `'candidate'), or ignore this if another installed package declares it`, - }); + findings.push( + unknownTriggerObject( + flowName, + `flows[${flowIndex}].nodes[${start.index}].config.objectName`, + objectName, + { verb: 'triggers on', key: 'config.objectName' }, + { + message: + `targets object '${objectName}', which this stack does not define — if the name is wrong, ` + + `the flow will never fire (and the runtime stays silent about it).`, + // [#22161] One remedy line; that names match exactly, and why a + // name another package declares is not refused, are + // `os explain flow-trigger-unknown-object`. + hint: + `Set config.objectName to the object's exact registered name (e.g. 'app_candidate', not ` + + `'candidate'), or ignore this if another installed package declares it`, + }, + ), + ); } } @@ -496,18 +570,22 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines // nothing forever with no runtime output. const objectName = typeof tr.object === 'string' ? tr.object : undefined; if (objectName && !objectNames.has(objectName) && !objectName.startsWith('sys_')) { - findings.push({ - severity: 'warning', - rule: FLOW_TRIGGER_UNKNOWN_OBJECT, - where: `flow "${flowName}" › start node`, - path: `flows[${flowIndex}].nodes[${start.index}].config.timeRelative.object`, - message: - `sweeps object '${objectName}', which this stack does not define — if the name is wrong, ` + - `the sweep will match nothing (and the runtime stays quiet about it).`, - hint: - `Set config.timeRelative.object to the object's exact registered name, or ignore this if another ` + - `installed package declares it`, - }); + findings.push( + unknownTriggerObject( + flowName, + `flows[${flowIndex}].nodes[${start.index}].config.timeRelative.object`, + objectName, + { verb: 'sweeps', key: 'config.timeRelative.object' }, + { + message: + `sweeps object '${objectName}', which this stack does not define — if the name is wrong, ` + + `the sweep will match nothing (and the runtime stays quiet about it).`, + hint: + `Set config.timeRelative.object to the object's exact registered name, or ignore this if ` + + `another installed package declares it`, + }, + ), + ); } // 1b-ii. #5496 — the descriptor does not parse, so `TimeRelativeTrigger` diff --git a/packages/lint/src/validate-list-view-field-refs.ts b/packages/lint/src/validate-list-view-field-refs.ts index 2c46439f551..4a1359629b1 100644 --- a/packages/lint/src/validate-list-view-field-refs.ts +++ b/packages/lint/src/validate-list-view-field-refs.ts @@ -239,10 +239,11 @@ * the package's declared `dependencies` bound what it MAY reference, so the * binding is not resolvable BY DECLARATION. It is the verdict the dataset * door already gives the same reference under the same closure - * (`object-reference-unknown` on `datasets[].object`). The flow door's - * `flow-trigger-unknown-object` stays advisory; that is its own question - * and this rule does not settle it. An object that exists nowhere lands on - * the same verdict, because the closure is all this snapshot can see. The + * (`object-reference-unknown` on `datasets[].object`), and [#24669] the + * one the flow door gives a trigger object outside the same closure + * (`flow-trigger-unknown-object`, an `error` there and only there). An + * object that exists nowhere lands on the same verdict, because the + * closure is all this snapshot can see. The * door judges writes only: a stored row is judged at its next save, and a * draft is never gated. * - **The field positions stay unjudged** for that list view. Judging them @@ -765,7 +766,7 @@ export function validateListViewFieldRefs( const pkg = closure.packageId; findings.push({ // `error`: the save door refuses it — see the section of that name on - // this module for why, and for the flow door's advisory. + // this module for why, and for the flow door's matching verdict. severity: 'error', rule: LIST_VIEW_OBJECT_OUTSIDE_CLOSURE, where, diff --git a/packages/metadata-protocol/src/protocol-publish-drafts-closure.test.ts b/packages/metadata-protocol/src/protocol-publish-drafts-closure.test.ts index b363908321c..c61c3d5f67a 100644 --- a/packages/metadata-protocol/src/protocol-publish-drafts-closure.test.ts +++ b/packages/metadata-protocol/src/protocol-publish-drafts-closure.test.ts @@ -36,7 +36,11 @@ * severity: they do not refuse, they manufacture findings that describe * nothing. Both are pinned below, because a closure that is uniform is the * property #9612/commit 6f5a44976 declared and a per-collection patch is what produced - * this card. + * this card. [#24669] The `objects` gap now refuses on the flow door too: a + * batch publish states its package's closure, and a flow whose trigger object + * is outside it is an `error` there — so a phantom on that collection would + * roll a batch back exactly as the datasets one did, and the silence pin below + * guards a refusal, not an advisory. * * ## The discriminating tests are the ones that expect SILENCE * @@ -449,7 +453,7 @@ describe('publishPackageDrafts judges each draft against the BATCH closure (#103 expect(said).toMatch(/LIVE declarations only/); }); - // ── objects: the same gap at advisory severity ── + // ── objects: the same gap, now at refusal severity on the flow door (#24669) ── it('a flow bound to a same-batch OBJECT draft raises no phantom trigger advisory', async () => { const { engine } = makeStubEngine(); @@ -463,12 +467,16 @@ describe('publishPackageDrafts judges each draft against the BATCH closure (#103 expect( rulesOf(res, 'shyx_on_ticket'), `'shyx_ticket' is drafted in THIS batch, so the flow's trigger object resolves. A ` - + `[flow-trigger-unknown-object] here is the datasets defect at advisory severity: it does ` - + `not refuse, it describes nothing.`, + + `[flow-trigger-unknown-object] here is the datasets defect on the objects collection: ` + + `it describes nothing.`, ).not.toContain('flow-trigger-unknown-object'); }); - it('STILL reports a flow bound to an object that is in neither the batch nor the registry', async () => { + it('STILL refuses a flow bound to an object that is in neither the batch nor the registry', async () => { + // [#24669] The batch states the package's closure, so a trigger object + // found in neither the batch nor the registry is outside it: refused at + // `error`, and the batch rolls back (ADR-0067 D2). It used to publish + // with the finding as an advisory. const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); await stageDraft(protocol, 'object', detailObject('shyx_ticket')); @@ -476,8 +484,16 @@ describe('publishPackageDrafts judges each draft against the BATCH closure (#103 const res = await protocol.publishPackageDrafts({ packageId: PKG }); - expect(res.failed).toEqual([]); - expect(rulesOf(res, 'shyx_on_ghost')).toContain('flow-trigger-unknown-object'); + expect(res).toMatchObject({ success: false, publishedCount: 0 }); + expect(res.published).toEqual([]); + const causal = res.failed.find((f) => f.name === 'shyx_on_ghost')!; + expect(causal.code).toBe('INVALID_METADATA'); + const unknown = (causal as any).issues.filter((i: any) => i.rule === 'flow-trigger-unknown-object'); + expect(unknown).toHaveLength(1); + expect(unknown[0]).toMatchObject({ severity: 'error', path: 'flows[0].nodes[0].config.objectName' }); + expect(unknown[0].message).toMatch(/shyx_ghost/); + // All-or-nothing: the healthy sibling is aborted, not published around it. + expect(res.failed.find((f) => f.name === 'shyx_ticket')?.code).toBe('BATCH_ABORTED'); }); // ── permissions: same gap, reached through an object publish ── diff --git a/packages/metadata-protocol/src/protocol.package-closure-gate.test.ts b/packages/metadata-protocol/src/protocol.package-closure-gate.test.ts index 59756dbc8fa..a4b2f993b50 100644 --- a/packages/metadata-protocol/src/protocol.package-closure-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.package-closure-gate.test.ts @@ -25,6 +25,16 @@ * really ran and really put `com.acme.core` in the closure. That is the #4449 * wired-and-running-on-nothing shape, in the other direction. * + * ## #24669 — the out-of-closure trigger object is REFUSED, not advised + * + * A flow written into a package whose trigger object (`config.objectName`, or + * a sweep's `config.timeRelative.object`) is outside that package's closure is + * refused `422 INVALID_METADATA` with `flow-trigger-unknown-object` at + * `error`: the verdict the dataset and view doors give the same reference + * under the same closure. A write that names no package, the sentinel and an + * unreadable package state no closure and keep the advisory; a draft save is + * not gated, and its promotion is. + * * Harness: the real repository write path over a stub engine, the same shape as * `protocol.dashboard-dataset-publish-gate.test.ts`. */ @@ -59,6 +69,28 @@ const object = (name: string, packageId: string) => ({ fields: [{ name: 'title', type: 'text', label: 'Title' }], }); +/** A Zod-valid daily time-relative sweep over `object` — the rule's second position. */ +const sweepOf = (name: string, object: string) => ({ + name, + label: name, + description: `sweeps ${object}`, + version: 1, + status: 'active', + type: 'schedule', + runAs: 'system', + variables: [], + nodes: [ + { + id: 'start', + type: 'start', + label: 'Daily sweep', + config: { timeRelative: { object, dateField: 'title', withinDays: 7 } }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end', type: 'default', isDefault: false }], +}); + /** A Zod-valid autolaunched flow whose start node fires on `objectName`. */ const flowOn = (name: string, objectName: string) => ({ name, @@ -158,7 +190,14 @@ function makeStubEngine() { registerObject: () => {}, listItems: (type: string) => type === 'object' - ? [object('crm_account', OWN), object('core_user', DEP), object('other_widget', STRANGER)] + ? [ + object('crm_account', OWN), + object('core_user', DEP), + object('other_widget', STRANGER), + // [#24669] A platform object a stranger package registers, + // kept by the closure's `isSystem` limb. + { ...object('platform_ledger', STRANGER), isSystem: true }, + ] : [], getItem: () => undefined, getPackage: (id: string) => packages[id], @@ -178,6 +217,25 @@ const save = (protocol: any, item: any, extra: Record = {}) => const triggerAdvisories = (result: any): string[] => (result.advisories ?? []).map((a: any) => a.rule).filter((r: string) => r === 'flow-trigger-unknown-object'); +const activeFlowRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'flow' && r.state === 'active'); + +/** + * [#24669] A refused publish-mode flow write: the ADR-0112 envelope (`code` + * AND `status`), carrying the one closure issue at the trigger position. + */ +const expectRefusedOutsideClosure = (err: any, path: string, what: string) => { + expect(err, `${what}: the write resolved — the door still only advises on the out-of-closure trigger`) + .toBeInstanceOf(Error); + expect({ code: err.code, status: err.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const issues = (err.issues ?? []).filter((i: any) => i.rule === 'flow-trigger-unknown-object'); + expect(issues, JSON.stringify(err.issues)).toHaveLength(1); + expect(issues[0]).toMatchObject({ severity: 'error', path }); + // The fix names the remedy and the package it applies to. + expect(issues[0].hint).toContain(OWN); + expect(issues[0].hint).toContain('dependencies'); +}; + describe('the write package closure reaches the gate (#9612)', () => { let warn: ReturnType; beforeEach(() => { @@ -208,25 +266,76 @@ describe('the write package closure reaches the gate (#9612)', () => { expect(triggerAdvisories(result)).toEqual([]); }); - it('reports a reference into a package the written one never declared', async () => { + it('REFUSES a trigger object of a package the written one never declared — config.objectName', async () => { // ⭐ The ruling's intended consequence, pinned as a decision rather than // left to surprise someone: a package's declared `dependencies` bound // what it MAY reference, so a flow reaching into `com.other.analytics` - // is not resolvable BY DECLARATION. Advisory severity — it reports, it - // does not refuse. - const { protocol } = makeProtocol(); + // is not resolvable BY DECLARATION. [#24669] It is REFUSED — the + // verdict the dataset and view doors give the same reference; it used + // to be a save advisory on a 2xx. + const { protocol, rows } = makeProtocol(); - const result = await save(protocol, flowOn('crm_on_stranger', 'other_widget'), { packageId: OWN }); + const err = await save(protocol, flowOn('crm_on_stranger', 'other_widget'), { packageId: OWN }) + .catch((e: any) => e); - expect(triggerAdvisories(result)).toEqual(['flow-trigger-unknown-object']); - expect(result.success).toBe(true); + expectRefusedOutsideClosure(err, 'flows[0].nodes[0].config.objectName', 'stranger object'); + // A gate that refuses after persisting is a log line. + expect(activeFlowRows(rows)).toEqual([]); + }); + + it('REFUSES a sweep over an object of a package the written one never declared — config.timeRelative.object', async () => { + const { protocol, rows } = makeProtocol(); + + const err = await save(protocol, sweepOf('crm_sweep_stranger', 'other_widget'), { packageId: OWN }) + .catch((e: any) => e); + + expectRefusedOutsideClosure(err, 'flows[0].nodes[0].config.timeRelative.object', 'stranger sweep'); + expect(activeFlowRows(rows)).toEqual([]); + }); + + it('REFUSES a trigger object that exists nowhere the same way — the closure is all the gate sees', async () => { + const { protocol, rows } = makeProtocol(); + + const err = await save(protocol, flowOn('crm_on_ghost', 'nosuch_obj'), { packageId: OWN }) + .catch((e: any) => e); + + expectRefusedOutsideClosure(err, 'flows[0].nodes[0].config.objectName', 'nosuch_obj'); + expect(activeFlowRows(rows)).toEqual([]); + }); + + it('admits the own package, a declared dependency, a platform object and a `sys_` name, at both positions', async () => { + // `platform_ledger` is registered by the stranger package but carries + // `isSystem: true`, so the closure keeps it; a `sys_` name is never + // judged, carried or not — the exception is unchanged, never widened. + for (const target of ['crm_account', 'core_user', 'platform_ledger', 'sys_user']) { + for (const body of [flowOn(`crm_on_${target}`, target), sweepOf(`crm_sweep_${target}`, target)]) { + const { protocol } = makeProtocol(); + const result = await save(protocol, body, { packageId: OWN }); + expect(result.success, `${body.name}`).toBe(true); + expect(triggerAdvisories(result), `${body.name}`).toEqual([]); + } + } + }); + + it('a DRAFT save of the refused body is not gated — its promotion is', async () => { + const { protocol, rows } = makeProtocol(); + + const draft = await save(protocol, flowOn('crm_on_stranger', 'other_widget'), { packageId: OWN, mode: 'draft' }); + expect(draft.success).toBe(true); + expect(activeFlowRows(rows)).toEqual([]); + + const err = await protocol.publishMetaItem({ type: 'flow', name: 'crm_on_stranger', packageId: OWN }) + .catch((e: any) => e); + expectRefusedOutsideClosure(err, 'flows[0].nodes[0].config.objectName', 'draft promotion'); + expect(activeFlowRows(rows)).toEqual([]); }); it('narrows NOTHING when the write names no package', async () => { // ⛔ The fallback direction, and the whole reason this is not a size // threshold: an unstated package buys MORE validation input, never - // less. The identical body that reports above is silent here, because - // the gate is handed the whole collection exactly as before #9612. + // less. The identical body that is refused above is silent here, + // because the gate is handed the whole collection exactly as before + // #9612. const { protocol } = makeProtocol(); const result = await save(protocol, flowOn('crm_on_stranger', 'other_widget')); @@ -234,6 +343,19 @@ describe('the write package closure reaches the gate (#9612)', () => { expect(triggerAdvisories(result)).toEqual([]); }); + it('keeps the ADVISORY when the write names no package and the object exists nowhere', async () => { + // [#24669] The overlay door states no closure, so the hedge stands: + // advised on a 2xx, never refused, at both positions. + for (const body of [flowOn('crm_on_ghost', 'nosuch_obj'), sweepOf('crm_sweep_ghost', 'nosuch_obj')]) { + const { protocol } = makeProtocol(); + const result = await save(protocol, body); + expect(result.success, body.name).toBe(true); + expect(triggerAdvisories(result), body.name).toEqual(['flow-trigger-unknown-object']); + expect(result.advisories.find((a: any) => a.rule === 'flow-trigger-unknown-object').severity) + .toBe('warning'); + } + }); + it('narrows NOTHING when the registry cannot produce the written package', async () => { // Its `manifest.dependencies` is the ONLY declaration of what it may // reference; without it there is no bound, and an unbounded closure is @@ -253,8 +375,17 @@ describe('the write package closure reaches the gate (#9612)', () => { return protocol.publishMetaItem({ type: 'flow', name: 'crm_on_stranger', packageId: OWN }); }; - expect(triggerAdvisories(await promoted(false))).toEqual(['flow-trigger-unknown-object']); - expect(triggerAdvisories(await promoted(true))).toEqual([]); + // [#24669] Installed, the closure is in force and the promotion is + // refused; uninstalled, nothing narrows and the stranger's object + // resolves against the whole collection. + expectRefusedOutsideClosure( + await promoted(false).catch((e: any) => e), + 'flows[0].nodes[0].config.objectName', + 'promotion into the installed package', + ); + const unnarrowed = await promoted(true); + expect(unnarrowed.success).toBe(true); + expect(triggerAdvisories(unnarrowed)).toEqual([]); }); it('narrows NOTHING for the `sys_metadata` overlay sentinel', async () => { diff --git a/packages/metadata-protocol/src/protocol.publish-item-rebind-announce.test.ts b/packages/metadata-protocol/src/protocol.publish-item-rebind-announce.test.ts index 57d759c8f88..a93300db732 100644 --- a/packages/metadata-protocol/src/protocol.publish-item-rebind-announce.test.ts +++ b/packages/metadata-protocol/src/protocol.publish-item-rebind-announce.test.ts @@ -163,6 +163,13 @@ function makeStubEngine() { // manifest declares no namespace → the ADR-0028 prefix check is // skipped (legacy-grandfathered path). getPackage: (id: string) => (id === PKG ? { manifest: { id: PKG } } : undefined), + // [#24669] The flows' trigger object, owned by that package. A batch + // publish judges each draft against the package's closure, where a + // trigger object the registry does not carry is refused. + listItems: (type: string) => + type === 'object' + ? [{ name: 'ticket', label: 'Ticket', _packageId: PKG, fields: [{ name: 'title', type: 'text', label: 'Title' }] }] + : [], }, }; return { engine, rows, historyRows }; diff --git a/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts b/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts index 2c8971e6362..3730b0f02e9 100644 --- a/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts +++ b/packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts @@ -305,8 +305,8 @@ describe('ADR-0070 D4 — duplicatePackage of an object whose name lacks the sou const res = await protocol.duplicatePackage({ sourcePackageId: P, targetPackageId: Q }); expect(res.success).toBe(false); - expect(res.failed).toHaveLength(2); - const [refusal, viewRefusal] = res.failed; + expect(res.failed, JSON.stringify(res.failed)).toHaveLength(3); + const [refusal, viewRefusal, flowRefusal] = res.failed; expect(refusal).toMatchObject({ type: 'object', name: 'ticket' }); // Located: the object, the name it would take, who holds that name, // and the source rename that clears it. @@ -319,6 +319,11 @@ describe('ADR-0070 D4 — duplicatePackage of an object whose name lacks the sou // closure — and the save door refuses it with the same rule. expect(viewRefusal).toMatchObject({ type: 'view', name: 'ticket_views' }); expect(viewRefusal.error).toContain('views[0].object [list-view-object-outside-closure]'); + // [#24669] And the copy of `dupsrc_on_ticket` keeps the source's + // `ticket` as its trigger object, outside the copy package's closure + // too, so the flow door refuses it — the same verdict, at the third door. + expect(flowRefusal).toMatchObject({ type: 'flow' }); + expect(flowRefusal.error).toContain('flows[0].nodes[0].config.objectName [flow-trigger-unknown-object]'); // NEGATIVE CONTROL: the prefixed sibling took the name, with its own // body, and nothing of `ticket` was written under it.