Skip to content

fix(lint)!: the save door refuses a flow written into a package whose trigger object is outside that package's closure (#24669) - #24839

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-24669-flow-trigger-out-of-closure
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-24669-flow-trigger-out-of-closure

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24669

Clause-②: yes (narrowing)

Under a package closure the runtime save door states, a flow whose trigger object is outside that closure is now refused: 422 INVALID_METADATA, one flow-trigger-unknown-object issue at error, at either position the rule reads (config.objectName on a record-* start node, or config.timeRelative.object). The dataset door (object-reference-unknown) and the view door (list-view-object-outside-closure) already give this verdict for the same reference under the same closure, so one mistake now gets one answer at all three doors (ruling #9612). A sys_ name is still not judged.

With no closure stated, the advisory is byte-identical to before. That covers the three CLI commands, a write that names no package, the sys_metadata sentinel, and a package the registry cannot produce. Drafts stay ungated, and a stored row is judged at its next publish.

Step 1: the census (triage 6279216806's first act, measured before any code)

Method. I parsed, with the TypeScript compiler API, every .ts / .tsx / .mts file in this repository at a43405d596 (this branch's base) that contains a type: 'start' literal: 379 files and 930 start nodes. I read each start node's config.objectName, config.triggerType and config.timeRelative.object. 136 start nodes carry a record trigger (a record-* token, or a non-literal one) or a time-relative descriptor.

Outside tests: 21 hits, 0 outside their package's closure. Each trigger object is defined by the same single-package example stack (com.example.showcase / com.example.todo): showcase_task ×12 (one of them a sweep), showcase_project ×3, showcase_expense_report ×2, and showcase_field_zoo, showcase_announcement, showcase_invoice and todo_task ×1 each.

file:line (at a43405d596) flow trigger object (position)
examples/app-showcase/src/automation/flows/approver-bindings.flow.ts:63 showcase_approver_bindings showcase_field_zoo (objectName)
examples/app-showcase/src/automation/flows/dynamic-approval.flow.ts:22 showcase_dynamic_approval showcase_announcement (objectName)
examples/app-showcase/src/automation/flows/index.ts:33 showcase_task_completed showcase_task (objectName)
examples/app-showcase/src/automation/flows/index.ts:161 showcase_task_assigned_notify showcase_task (objectName)
examples/app-showcase/src/automation/flows/index.ts:216 showcase_budget_approval showcase_project (objectName)
examples/app-showcase/src/automation/flows/index.ts:333 showcase_task_completed_slack showcase_task (objectName)
examples/app-showcase/src/automation/flows/index.ts:471 showcase_task_completed_rest_ping showcase_task (objectName)
examples/app-showcase/src/automation/flows/index.ts:530 showcase_declarative_connector_ping showcase_task (objectName)
examples/app-showcase/src/automation/flows/index.ts:583 showcase_mcp_connector_echo showcase_task (objectName)
examples/app-showcase/src/automation/flows/index.ts:632 showcase_task_follow_up showcase_task (objectName)
examples/app-showcase/src/automation/flows/index.ts:724 showcase_task_done_notify_owner showcase_task (objectName)
examples/app-showcase/src/automation/flows/index.ts:845 showcase_project_closure showcase_project (objectName)
examples/app-showcase/src/automation/flows/index.ts:1003 showcase_fan_out_notify showcase_task (objectName)
examples/app-showcase/src/automation/flows/index.ts:1210 showcase_resilient_sync showcase_task (objectName)
examples/app-showcase/src/automation/flows/index.ts:1309 showcase_invoice_signoff showcase_invoice (objectName)
examples/app-showcase/src/automation/flows/index.ts:1411 showcase_project_escalation showcase_project (objectName)
examples/app-showcase/src/automation/flows/index.ts:1758 showcase_expense_signoff showcase_expense_report (objectName)
examples/app-showcase/src/automation/flows/index.ts:1808 showcase_committee_quorum showcase_expense_report (objectName)
examples/app-showcase/src/automation/flows/index.ts:1878 showcase_task_due_reminder showcase_task (timeRelative.object)
examples/app-showcase/src/automation/flows/index.ts:1937 showcase_urgent_task_alert showcase_task (objectName)
examples/app-todo/src/flows/task.flow.ts:324 task_completion todo_task (objectName)

Zero flows in:

  • packages/apps/**, packages/platform-objects/**, packages/plugins/**, packages/services/**;
  • the create-objectstack blank template (its src/flows/index.ts is export {};);
  • examples/app-multi-package (the one multi-package example), examples/app-crm (one screen flow, no trigger), examples/embed-objectql;
  • any YAML/JSON flow file.

The os g flow scaffold binds --object or the stack's only object, so it stays in-stack by construction.

packages/qa/**. Every dogfood fixture's trigger object is declared by the same fixture stack. I resolved the 13 constant-named ones by hand. The upgrade-harness SQL holds only run records of showcase flows on showcase_task.

Tests. 115 trigger-bearing start nodes:

  • 47 bind an object the same file declares, and 36 bind a computed name;
  • 30 bind a name the file does not declare: unit tests that build engine stacks with no package, plus the deliberate CLI advisory pin validate-flow-trigger-readiness.test.ts (other_package_object → billing_invoice), which is unchanged;
  • 1 binds sys_user, and 1 has no object.

The fixtures that write a flow into a package through the real door are the ones the consumer sweep surfaced (below).

objectui (read-only, at 32b492b67): zero record-triggered or time-relative flow metadata outside tests. apps/console/src/preview-samples.ts holds one schedule-start preview sample, and nothing in it is written to a server.

Verdict: no real writer, so step 2's first branch applies and the code was written. Deployed rows were not measured.

Premises

  • P1. Holds at a43405d596. validate-flow-trigger-readiness.ts:446 / :448 (record trigger) and :498 / :500 (time-relative): severity: 'warning', keyed on !objectNames.has(objectName) && !objectName.startsWith('sys_'), not on the closure.
  • P2. Holds: a severity choice, not a new lookup.
    • The flow rule is its own registry entry (authoring-rules.ts, validateFlowTriggerReadiness, surfaces: CLI_AND_RUNTIME, runtimeTypes: ['flow']). It is not a reference-integrity-suite member, and its run callback did not read the rule context.
    • buildRuntimeWriteSnapshotSet narrows objects for every write type, flow included (runtime-gate.ts:721, narrowObjectsToPackageClosure). runRuntimeAuthoringRules puts packageScope on the rule context exactly when packageScopeInForce holds (:1121). So under a package write, objectNames already IS the closure.
    • The change hands ctx.packageScope to the rule and picks error when it is stated. runtime-gate.ts is unchanged. reference-integrity-suite.ts is touched only for one comment, after fix(lint)!: the overlay save door refuses a list view bound to an object that exists nowhere (#24668) #24789 landed.
  • P3. Holds: an advisory at the runtime door, through the real saveMetaItem.
    • Before, on origin/main a43405d596 with lint built from it, protocol.package-closure-gate.test.ts and protocol-publish-drafts-closure.test.ts were 15/15 green. That included "reports a reference into a package the written one never declared": a flow written into com.acme.crm triggering on a com.other.analytics object answered success: true with advisories = ['flow-trigger-unknown-object']. The same body with no package drew nothing, because the stranger's object is in the whole collection.
    • With the rule changed and those pins untouched: 3 red. Two read flow/crm_on_stranger failed author-time validation: 1 issue — flows[0].nodes[0].config.objectName [flow-trigger-unknown-object], and the third is the batch case in protocol-publish-drafts-closure.test.ts.

What changes

  • packages/lint/src/validate-flow-trigger-readiness.ts. validateFlowTriggerReadiness(stack, options = {}) takes an optional { packageScope }. Both unknown-object sites go through one helper. With no closure, the finding is the old advisory (same keys, same order, same strings). Under a closure, the same id is error, with a message that names the package and its closure, and a hint that names the remedy (declare the dependency in the manifest dependencies, or set the named key to an in-closure object). The header's Severity section says why the hedge is gone under a closure.
  • packages/lint/src/authoring-rules.ts. The flow entry runs validateFlowTriggerReadiness(stack, ctx.packageScope ? { packageScope: ctx.packageScope } : {}). The AuthoringRuleContext.packageScope doc names its two readers.
  • packages/lint/src/rule-explanations.ts. os explain flow-trigger-unknown-object said "at the runtime publish gate it is a save advisory … never a refusal", which this change makes false. It now gives the closure case, its remedy, and the draft and stored-row rules.
  • Comments made false by this change: validate-list-view-field-refs.ts (two lines) and reference-integrity-suite.ts (one line, after fix(lint)!: the overlay save door refuses a list view bound to an object that exists nowhere (#24668) #24789 landed), plus one sentence in content/docs/deployment/validating-metadata.mdx.
  • .changeset/24669-flow-trigger-out-of-closure.md: @objectstack/lint minor, a ! title, Clause-②: yes (narrowing), FROM / TO / Fix, the stored-row sentence, and the ADR-0087 disposition not-required (no-migration-prescription). .changeset/pre.json reads mode: exit, tag: next, so the next version pass cuts 18.0.0 and this ships in it. The changeset says so.

Pins

  • Lint (runtime-gate.package-closure.test.ts, through runRuntimeAuthoringRules). The new block, for both positions:

    • an undeclared package's object is refused at the key, with a hint naming the package and dependencies;
    • an object that exists nowhere is refused;
    • admitted: the own package, a declared dependency, isSystem objects (one of them stranger-owned), a carried sys_ object, a sys_ name the snapshot does not carry, and a provenance-less overlay row;
    • with no package, or an empty package id: the stranger's object is clean, and the ghost is advised. The door's severity, message and hint equal the CLI's byte for byte;
    • the CLI reading is unchanged.

    The three existing flow pins in that file now assert severity: "a REAL finding survives narrowing" (same rule and path, advisory on the whole collection, refused under the closure), the undeclared case, and the stored-sibling case.

  • Protocol (packages/metadata-protocol/src/protocol.package-closure-gate.test.ts, through the real saveMetaItem / publishMetaItem):

    • refused 422 INVALID_METADATA, asserting the ADR-0112 code + status, the issue's severity / path and the hint, with nothing stored. This holds for config.objectName, config.timeRelative.object, and an object that exists nowhere;
    • admitted at both positions: the own package, a declared dependency, an isSystem platform object and sys_user;
    • the draft save of the refused body succeeds and stores no active row, and its promotion is refused;
    • with no package, the ghost keeps its warning advisory at both positions;
    • the uninstalled-package case narrows nothing and resolves.

Fixtures the consumer sweep found (outside packages/lint / packages/spec), for the seat's #6367 addendum

All four are test files only. There is no protocol.ts or objectql source change, and no assertion was weakened.

  1. packages/metadata-protocol/src/protocol.package-closure-gate.test.ts: the protocol-layer pin block above. Its advisory pins are re-pinned to the refusal.
  2. packages/metadata-protocol/src/protocol-publish-drafts-closure.test.ts: "STILL reports a flow bound to an object that is in neither the batch nor the registry" becomes "STILL refuses". The batch fails, the ghost-triggered flow is INVALID_METADATA with the issue at error, and the healthy sibling is BATCH_ABORTED.
  3. packages/metadata-protocol/src/protocol.publish-item-rebind-announce.test.ts: the fixture is re-trued. Its stub registry now lists the flows' trigger object ticket under the package app.ops. The batch CONTROL case had gone red because ticket was outside the closure. Assertions are unchanged.
  4. packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts: "a name already claimed is refused up front" is re-pinned to three refusals. With the ticket copy refused, the copied dupsrc_on_ticket keeps the source's ticket as its trigger object, which is outside the copy package's closure, so the flow door refuses it. This is the same shape as fix(lint)!: the save door refuses a list view written into a package and bound outside its closure #24630's view re-pin (the seat's answer A, 6277565064). The changeset's door list names duplicatePackage.

Ablation (G5): run once, recorded here, no permanent file

The fix was committed first. scripts/ablation-replace.mjs --hold, armed with a trap restore, replaced the closure branch's severity: 'error', with severity: 'warning', plus a dist-visible marker.

  • Mutate leg: anchor 1 → 0, blob 48415eb218 → 255f3f2863. ablation-dist-preflight.mjs @objectstack/lint ABLATION_24669 found the marker in 4 built files.
  • The lint pins went red: 8 of 36. So did the protocol pins: 6 of 20, every refusal case (objectName, timeRelative, ghost, draft promotion, uninstalled control, batch). The direction was red, as expected.
  • Restore leg: blob == HEAD, git diff HEAD empty, whole tree clean. After a rebuild, --absent found the marker in none of the 20 built files. Both files went green again: 36/36 and 20/20.

Verification

  • Consumer sweep (--maxWorkers=2, under os-verify-lock.sh), on aa20228392. Results: lint 6953/6953; service-automation 2605; runtime 7587 (19 skipped); rest 6404 (344 skipped). The two first-run reds were fixtures 3 and 4 above, re-run green after re-truing: metadata-protocol was 1 red of 20834, and objectql 1 red of 8495. cli --project unit was 4612 passed, with 2 suites refused for a missing prerequisite (packages/cli is not built), not a measurement. Both re-ran green after a cli build, below.
  • After merging origin/main (fix(lint)!: the overlay save door refuses a list view bound to an object that exists nowhere (#24668) #24789 landed), on 009a2b2b09. Results: lint full suite and typecheck green; metadata-protocol full suite green; the objectql fixture green; the two cli suites green. metadata-protocol typecheck was green on the pre-merge head.
  • Gates, on the final head 3d4226ca4f. node scripts/pm/dispatch-gates.mjs --commands (no paths) derives 96 families. All 96 ran with their exit codes recorded, and --ran reconciles them: "96 derived famil(ies) accounted for — 96 run, 0 NOT-MEASURED (a DERIVED zero …)". 95 exit 0. One is red by design: check-empty-changeset --base origin/main (exit 1, the next section). On the 009a2b2b09 pass, check:skill-examples and check:dual-build-cjs-loads first exited 3 (missing prerequisite: dist absent). They re-ran green after the missing packages were built, and both are exit 0 in the final-head pass.
  • test:repo: not run locally. Nothing in its project changed, and CI's Test Core runs it.

A pending release note corrected: please confirm (Check Changeset stays red by design)

.changeset/24563-list-view-out-of-closure.md (#24563's, unreleased) said under Unchanged: "The flow door: flow-trigger-unknown-object stays an advisory." This PR makes that sentence false, so the line now reads: "The flow door: under the same closure, flow-trigger-unknown-object refuses an out-of-closure trigger object too (#24669, see that changeset)." This is check-empty-changeset's DELIBERATE CORRECTION class. Per ruling D, the gate stays red, and the correction needs written confirmation here. Check Changeset is not a required context.

File surface beyond the claim

The claim named the rule file, the closure plumbing, the lint pins, one changeset, and the riders. I also touched the following, each because this change made a sentence in it false:

Before touching these, I read the file lists of all open PRs. Only #24789 overlapped, and it has since landed.

Acceptance notes

  • Not measured: Studio's flow designer object picker (objectui). I did not check whether it offers objects outside the package's closure when you author inside a package. If it does, an author can now pick an object the publish refuses. That refusal is loud and names the fix, and the view door has the same shape. This is an observation only; no carrier is named.
  • Deployed stored rows were not measured. A stored flow with an out-of-closure trigger keeps loading and firing. It is refused only at its next publish, and the refusal names the fix.

Generated by Claude Code

…me save door

Under a package closure the runtime publish gate states, the flow rule's
snapshot is that closure, so flow-trigger-unknown-object is an error
there (both the config.objectName and config.timeRelative.object
positions). Without a stated closure (the CLI commands, a write that
names no package) it stays the advisory it was.

WIP: pins and changeset follow.

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
…the real saveMetaItem

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
…r's closure refusal

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
…r object into its package

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
…ow door's refusal

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
…oor's refusal

Also corrects the reference-integrity suite comment that called the flow
door's trigger-object verdict advisory.

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 8 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via config.objectName (literal, a string literal in validateFlowTriggerReadiness))
  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object))
  • content/docs/protocol/kernel/i18n-standard.mdx (via config.objectName (literal, a string literal in validateFlowTriggerReadiness))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via config.objectName (literal, a string literal in validateFlowTriggerReadiness))
  • content/docs/releases/v17/17-7.mdx (via config.objectName (literal, a string literal in validateFlowTriggerReadiness))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1366740c6c80be43b18b6b72a68736032245e3e9 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3b03a61f85f96c71cb262f8cdcc73d49bbce3a7b — the merge of head 3d4226ca4f62b6bead9bcfe71a832950f8103092 into base 1366740c6c80be43b18b6b72a68736032245e3e9, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3b03a61f85f96c71cb262f8cdcc73d49bbce3a7b && git checkout 3b03a61f85f96c71cb262f8cdcc73d49bbce3a7b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1366740c6c80be43b18b6b72a68736032245e3e9 3d4226ca4f62b6bead9bcfe71a832950f8103092 && git checkout -B drift-repro 1366740c6c80be43b18b6b72a68736032245e3e9 && git merge --no-ff 3d4226ca4f62b6bead9bcfe71a832950f8103092

node scripts/docs-audit/affected-docs.mjs --json 1366740c6c80be43b18b6b72a68736032245e3e9

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 1366740c6c80be43b18b6b72a68736032245e3e9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat confirmation · deliberate correction of a pending release note · domain:spec seat 2 (#18549) · session session_019oFY6Hi35J7atvgVFnmoaK · 2026-10-20T09:39Z

This PR modifies .changeset/24563-list-view-out-of-closure.md. That is the pending note of #24563, which this seat landed (PR #24630, 9f12d06770), so this seat owns the note.

Confirmed: option A. The corrected line stays. Restoring the base line would publish a false sentence beside its correction.

Check Changeset stays red, by design. These are the same three conditions this seat recorded for PR #24789 in 6284866516:

  1. scripts/check-empty-changeset.mjs names the DELIBERATE CORRECTION class and says to leave it red. .github/workflows/pr-automation.yml says "'Check Changeset' is not one of the required contexts".
  2. pr-automation.yml runs on pull_request only, with no merge_group.
  3. This comment names the gate and the reason.

⛔ No skip-changeset label.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3d4226ca4f62b6bead9bcfe71a832950f8103092
Local-runs: none
Scope: .changeset/24563-list-view-out-of-closure.md, .changeset/24669-flow-trigger-out-of-closure.md, content/docs/deployment/validating-metadata.mdx, packages/lint/src/authoring-rules.ts, packages/lint/src/reference-integrity-suite.ts, packages/lint/src/rule-explanations.ts, packages/lint/src/runtime-gate.package-closure.test.ts, packages/lint/src/validate-flow-trigger-readiness.ts, packages/lint/src/validate-list-view-field-refs.ts, packages/metadata-protocol/src/protocol-publish-drafts-closure.test.ts, packages/metadata-protocol/src/protocol.package-closure-gate.test.ts, packages/metadata-protocol/src/protocol.publish-item-rebind-announce.test.ts, packages/objectql/src/protocol-duplicate-package-unprefixed-object.test.ts + packages/lint/src/runtime-gate.ts, packages/lint/src/object-graph.ts, packages/lint/src/validate-object-references.ts, packages/lint/src/index.ts, packages/metadata-protocol/src/protocol.ts, packages/cli/src/commands/validate.ts, packages/cli/src/commands/compile.ts, packages/cli/src/commands/explain.ts, packages/cli/src/utils/format.ts, .changeset/pre.json, scripts/check-empty-changeset.mjs, .github/workflows/pr-automation.yml, packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts, packages/qa/dogfood/test/fixtures (the five trigger-bearing fixtures), examples/app-showcase/src/automation/flows, examples/app-todo/src/flows/task.flow.ts, and in objectui at 32b492b67 packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx, flowPresets.ts and metadata-admin/package-schema.ts

Read on the narrowing face, adversarially: the net diff against the merge base 6b222464ce (13 files, +524 / -82, equal to the PR's file list), the card's body and all six comments, the PR body, the seat confirmation 6288042708, the rider 6288051761 on #6367, the siblings #24563 / PR #24630 and #24668 / PR #24789, card #24796, and the cited code at base and head. Nothing was built, run or ablated here.

① Derived judgments

The census gate was honoured; I found no writer it missed. Triage 6279216806 made the measurement the first act and the dispatch 6285137442 made code conditional on it. Re-measured by git grep on the head tree (the diff touches no flow metadata, so head equals the merge base for this question): record-triggered or time-relative start nodes outside tests are the 21 the PR lists, all in examples/app-showcase (19 record-* plus 1 timeRelative, every one on a showcase_* object the same stack defines) and examples/app-todo (todo_task); zero in packages/apps, packages/platform-objects, packages/plugins, packages/services; the create-objectstack blank template's flows/index.ts is an empty export; examples/app-multi-package ships no flow; no YAML or JSON flow carries one. The two other non-test hits are not writers: packages/cli/src/commands/generate.ts:530 is the scaffold, which binds the stack's own object, and explain.ts:162 is a printed sample. In packages/qa, the five trigger-bearing dogfood fixtures bind objects their own fixture stack declares, and the one dogfood test that writes flows into a package through the HTTP door (flow-provenance-server-held.dogfood.test.ts, PUT /meta/flow/NAME?package=com.dogfood.flows20761) writes an autolaunched flow with config: {}, so it carries no trigger object and is not reached by this rule. objectui at 32b492b67 (the sibling checkout; the repo's .objectui-sha pin is 12dc7b47, but this change removes nothing, so the pin gate is not the question) ships no record-triggered or time-relative flow metadata outside tests; Studio's flowPresets.ts builds a trigger from a picker fed by client.list('object', { packageId }) (StudioDesignSurface.tsx:6963), the package's own objects, and saves it as a draft. The census anchor a43405d596 is an ancestor of the merge base; the 9 commits between add no flow. Right: no real cross-package writer exists, so code was the ruled branch, and the maintainer's 「都派」 (6282793835) pulled it into 18.0 without skipping that measurement.

The narrowing is exactly the ruled one. validate-flow-trigger-readiness.ts at head: both unknown-object sites (:516 record trigger, :572 time-relative) keep the guard objectName && !objectNames.has(objectName) && !objectName.startsWith('sys_') byte-identical to the base, so the sys_ exception is unchanged at both positions; each now calls one helper that returns severity: 'error' only when options.packageScope is present and otherwise returns { severity: 'warning', rule, where, path, message, hint } with the base's strings and the base's key order. No other severity site in the file moved. Right: with no closure the advisory is byte-identical, and the pin "a write that names no package keeps the advisory, byte-identical to the CLI's" compares severity, message and hint against the rule called directly.

No CLI caller can pass packageScope; proven by reading every caller. The only non-test caller of validateFlowTriggerReadiness is the AUTHORING_RULES entry, which forwards ctx.packageScope. A ctx is built in exactly two places: runRuntimeAuthoringRules (runtime-gate.ts:1121), which sets packageScope iff packageScopeInForce(args.packageScope), the same predicate narrowObjectsToPackageClosure uses, so the rule is told "closure" exactly when objects was narrowed; and runAuthoringRules (authoring-rules.ts:2304), the CLI's runner, whose ctx is { sduiManifest, loweredHookRefs, judgeFilter } and nothing else. compile.ts and validate.ts call authoringRulesFor only to count; no package outside lint builds an AuthoringRuleContext or calls rule.run. runtime-gate.ts and protocol.ts are unchanged on this branch. Right.

The doors and the closure. protocol.ts resolveWritePackageScope answers no closure for a missing id, an empty id, the sys_metadata sentinel, a registry without getPackage, or a package it cannot produce; assertRuntimeAuthoringRules returns [] for evt.state !== 'active' (drafts ungated) and for the package-author channel (unchanged). saveMetaItem passes request.packageId ?? null in publish mode; promoteDraftForPublish passes the package the caller stated, which publishPackageDrafts always does and publishMetaItem does when the HTTP caller named ?package=; duplicatePackage writes each copy through saveMetaItem({ mode: 'publish', packageId: target }). So the four doors the changeset names are the doors reached, and the uninstalled-package, no-package and sentinel cases state no closure. Right. One precision point, not a defect of this PR and shared with the view door: the promotion door states the closure only when the caller names the package, so the changeset's TO line holds for the package draft publish unconditionally and for publishMetaItem when ?package= is given.

duplicatePackage re-pin, mechanism verified. In the objectql fixture the source dupsrc_on_ticket triggers on ticket (:154). ticket is unprefixed and its target name is claimed, so it lands in refusedObjects and never in renameMap; the property rewrite rewrites a config.objectName only when it equals a renamed name exactly, so the flow copy keeps ticket; ticket is owned by com.qa.dupsrc, which com.qa.dupdst never declares, so the copy's publish is outside the closure and the door refuses it. failed[] runs object first (refused up front), then view and flow in row order, which is the pinned [refusal, viewRefusal, flowRefusal]. Right, and the same mechanism as the seat's answer A (6277565064).

Same id with closure-keyed severity (dev deviation c) is sound. Precedent: the dataset door's object-reference-unknown already carries two severities on one id. Nothing keys on an id's severity: AuthoringRule has tier, surfaces, runtimeTypes and no per-id severity; RuleExplanation is { rule, covers, paragraphs }, and os explain / format.ts:2063 print covers without a severity; the gate's fingerprint is rule, where, path, message, so severity never enters the differential and both passes share one ctx; the id stays exported from index.ts:135, so rule-id-barrel-exports.test.ts and the export surface are untouched; the only prose that keyed on it outside the diff is the released CHANGELOG, which is release-owned and correctly left alone. Nothing in skills/, .claude/, docs/ or content/docs outside the one corrected sentence calls the flow door advisory. Right.

Message and hint under a closure are true, and suggestName draws only from the closure. objectNames is built from stack.objects, which the gate narrowed to the closure, so the nearest-name suggestion can name only an in-closure object. Putting it in the message rather than the hint mirrors the view door's closure finding (reportOutsideClosure does the same); it is safe here because runtimeTypes: ['flow'] means the rule runs on flow writes only, where objects is identical in both passes, and flows is not a CONTEXT_STACK_KEYS member, so no stored sibling flow is judged in either pass. The message lists the closure as own objects, declared dependencies and platform objects, omitting limb 2 (provenance-less overlay rows) exactly as the view door's message does; an overlay row is kept by the closure, so the finding never fires for one and the omission cannot mislead. The hint names the package, dependencies and the exact key. Right.

rule-explanations.ts is true for both severities. The first paragraph is now scoped to a write that names no package (advisory, judged against the live registry); the new paragraph states the closure case, the remedy, that sys_ is unjudged there too, that a draft save is not gated and that a stored flow is judged at its next publish. Each sentence matches the code read above: buildRuntimeWriteSnapshotSet drops the written item's stored self from the baseline, so a stored row's own condition cancels nothing and its next publish is judged. covers changes to "warns, and when it refuses"; nothing pins the old string. Right.

The re-pins and re-trues outside the claim are each the ruled consequence, nothing loosened. (1) protocol.package-closure-gate.test.ts: the advisory case becomes a refusal asserting the ADR-0112 envelope (code and status), the issue's severity and path, the hint's package and dependencies, and no active flow row stored; new positives cover the sweep position, an object that exists nowhere, admitted targets at both positions (own, declared dependency, isSystem from a stranger package, sys_user), the ungated draft whose promotion is refused, the no-package ghost advisory at both positions, and the uninstalled-package case now split into refused-when-installed and resolved-when-not. (2) protocol-publish-drafts-closure.test.ts: res.failed equal [] plus an advisory becomes success: false, publishedCount: 0, the ghost flow INVALID_METADATA at error at flows[0].nodes[0].config.objectName, and the healthy sibling BATCH_ABORTED, the ADR-0067 all-or-nothing shape; the same-batch-object silence pin now guards a refusal. (3) protocol.publish-item-rebind-announce.test.ts: the only change is a listItems stub returning ticket under app.ops; every assertion is unchanged. (4) the objectql case: two refusals become three, with every existing assertion on the first two kept. I read every hunk; no assertion was weakened.

The file-surface breach (dev deviation a): each edit minimal and true. validating-metadata.mdx: "Two rules are told the closure is in force" is true (the two packageScope readers). validate-list-view-field-refs.ts: "an error there and only there" is true. reference-integrity-suite.ts: "from its own rule outside this suite" is true. authoring-rules.ts: the docblock's reader list and the two entry comments are true. The #24563 changeset line is one true sentence. Overlap with #24796: this PR edits the same AuthoringRuleContext.packageScope docblock #24796 names, but only its "Read by" sentence; the stale sentence #24796 names ("one missing from a whole one may come from another package") is still at head, runtime-gate.ts is untouched, and no os explain entry is added for list-view-object-unknown or list-view-object-outside-closure. So this PR fixes no part of #24796; the collision is textual adjacency in one docblock, and #24796 (unclaimed) rebases over it keeping the new "two rules" sentence.

Ablation (8/36 lint, 6/20 protocol) is consistent with the pins' control flow. With the closure branch at warning: in the lint file the four REFUSES cases (two per position), the second half of the CLI case, and the three re-asserted existing pins (real-finding severity, undeclared, stored-sibling) go red, eight; the admits and no-package cases stay green. In the protocol files the six refusal legs (objectName, timeRelative, ghost, draft promotion, installed promotion, batch) go red and every control holds. Right.

Check-runs on this head, read at 2026-10-20T09:51Z: success: Auto Label, Build Core, Build Docs, Check Documentation Links, Check PR Size, Dogfood Regression Gate, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, Lint · CI release & engine gates, Lint · ESLint, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Lint · scoped · tooling self-tests, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Test Core (1/6), The card this PR closes must claim this branch, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, TypeScript Type Check, filter. in_progress: Test Core (2/6) through (6/6). skipped: 18.x upgrade harness, Console Pin Gate, Dogfood Regression Gate shards, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance. failure: Check Changeset, and the job log names the cause exactly: .changeset/24563-list-view-out-of-closure.md exists on the merge base, with the two-class remedy printed and the DELIBERATE CORRECTION line; the same log reads the new changeset as [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription). The red-by-design holds. The landing waits on the five remaining Test Core shards reaching success; in_progress is not a pass.

② Semver level

What @objectstack/lint publishes from this diff: validateFlowTriggerReadiness gains an optional second parameter (additive); the runtime save door's accept set narrows for a flow written into a package whose trigger object is outside the closure (breaking for a consumer that stored such a flow with the advisory); no export is added, removed or renamed; the AuthoringRuleContext.packageScope docblock in the published .d.ts changes in prose only. .changeset/24669-flow-trigger-out-of-closure.md: '@objectstack/lint': minor with a ! title under the launch-window convention, .changeset/pre.json read as mode: exit, tag: next so the next version pass cuts 18.0.0; FROM names the four doors, TO names the 422 envelope, the batch failure and the duplicatePackage failed[] entry, Fix names the manifest dependencies and the in-closure retarget and that drafts stay allowed; the stored-row sentence ("stored flows keep loading and firing until then") is present and matches the gate's writes-only construction; the ADR-0087 marker is not-required (no-migration-prescription) with its facts, which the gate accepted. Clause-②: yes (narrowing) is on both carriers (PR body line 3, changeset line 7) and matches the claim's spelling. Right; this is the level the siblings #24630 and #24789 shipped the same narrowing at.

The deliberate correction of .changeset/24563-list-view-out-of-closure.md: one line, the right class, and the three red-by-design conditions hold as read — scripts/check-empty-changeset.mjs names the DELIBERATE CORRECTION class and says do not restore, get it confirmed; pr-automation.yml runs on pull_request only (no merge_group) and states that Check Changeset is not a required context; the seat's confirmation 6288042708 names the gate and the reason and chose option A. The PR carries no skip-changeset label. Option A is right: B would publish the false sentence beside its correction in the same 18.0.0 pass.

③ Boundary flags

  • Dev deviation (a), file surface beyond the claim: answered above; every edit is minimal and made true by this change; accepted.
  • Dev deviation (b), engine-lane fixtures touched before a rider: the seat's rider 6288051761 on [PM seat] domain:engine — 🟢 os-project-manager #6367 now declares all four files; accepted after the fact, on the fix(lint)!: a lookup or master_detail into a stored-metadata-body table is refused at author time #24800 pattern.
  • Dev deviation (c), same rule id: sound, answered above.
  • Dev deviation (d), test:repo not run locally: CI's Test Core carries it; five shards were in progress at the reading; the landing waits on them.
  • Dev deviation (e), branch eight commits behind origin/main with no conflict: acceptable; the queue rebuilds the merge group on current main.
  • Open question A/B on the A list view bound to an object outside the writing package's dependency closure skips the field-reference check: unknown columns fields are accepted (200 active, draft → publish) where the same body is refused 422 list-view-field-unknown #24563 note: the seat confirmed A; concur.
  • Out-of-scope note 1, Studio's flow designer object picker: read in objectui source at 32b492b67, the trigger picker lists client.list('object', { packageId }), the package's own objects, the automation is saved as a draft, and the package form (metadata-admin/package-schema.ts) carries a dependencies field, so the remedy the hint names is reachable from Studio. No trap with reach; no card. A browser measurement was not made and is not owed by this review.
  • Out-of-scope note 2, deployed stored flows: the door judges writes only, the changeset states the stored-row rule, and the two sibling doors shipped the same shape without a card. No card.
  • Observation, not a finding of this PR: the HTTP promotion POST /meta/flow/:name/publish states the closure only when ?package= is given, the same shape as the view door; the package draft publish always states it.
  • Governed surfaces: none in the file list; this record serves the narrowing clause, not a Tier S path.

Implemented-by: claude/issue-24669-flow-trigger-out-of-closure
Reviewed-by: session_019oFY6Hi35J7atvgVFnmoaK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review adopted · domain:spec seat 2 · session session_019oFY6Hi35J7atvgVFnmoaK · 2026-10-20T10:07Z


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants