Skip to content

feat(lint): a record trigger that names no object opens its flow to the CEL root judge; the runtime gate stand-down is by design - #22762

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22636-runtime-gate-flow-entrances
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22636-runtime-gate-flow-entrances

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22636
Clause-②: yes (widening: a flow whose record-* trigger names no start config.objectName opens, so validate no longer reports an unbound flow CEL root there)

Executes ruling 6104584601 (letter B) on #22636: ruling 5791822697 item 3 stands, so the runtime publish gate keeps its per-write snapshot and its stand-down on the flow CEL unbound-root judgment is by design. W (the snapshot carrying flows and actions) is not built. This PR carries the three items the ruling names, all in packages/lint, plus (patch round 1, seat order 6105130863) the correction of two pending release notes that still called the stand-down temporary.

What changes

  1. S is reworded from a hold-out to the ruled boundary. The stand-down itself (perWriteSnapshotEntrance) is unchanged in code. Its wording changes from "until lint(runtime gate): a flow write's per-write snapshot carries no actions and no other flows, so a flow CEL root rule cannot see the entrances that hand a flow its record (W, from #22565) #22636 widens the snapshot" to "by design": who launches a flow and what record it is handed are read from other items of the stack, so this is a whole-stack judgment and belongs to objectstack validate (ADR-0109 Decision §3's boundary). The text cites ruling 5791822697 (item 3) and ruling 6104584601. It changes in:
    • flow-cel-root-scope.ts: the module header section (now "The runtime publish gate stands down, by design") and perWriteSnapshotEntrance's docblock;
    • validate-expressions.ts: the comment above runtimeGateWrite (comment only);
    • validate-expressions.flow-cel-root.test.ts: the S block's docblock and describe title (now the runtime publish gate stands down by design (S)), and the record block's docblock. The S pins themselves are unchanged: the gate stands down, and the build door refuses the same root on the same flow.
  2. A record-* trigger with no start config.objectName opens the flow. flowCelEntrances gains one open reason. It applies when the trigger kind is record_change (resolveFlowTriggerKind: start config.triggerType is a record-* string) and the start node names no config.objectName. Measured on the runtime: deriveTriggerBinding (engine.ts) binds object: undefined, and record-change-trigger.ts registers the hook with object: binding.object, so the hook has no object filter. It fires on a write to any object, including one the stack does not declare, and hands that row (binding.object ?? ctx.object). The build door cannot know the record's keys, so it leaves the flow unjudged, as it already does for a record trigger on an undeclared object. ⛔ The trigger shape is not refused (no new gate, no narrowing). The module header's entrance list gains the bullet.
  3. The owed map-child pin (carry note 6096005997, from contract review 6095756455 ③). A map child of an open parent is opened by the fixpoint. The pin has three parts: one level, two levels (g → map → h → map → f), and a red control (the same map node in a parent no entrance opens leaves the child judged). Each map node's itemObject names a declared object, so the node cannot open the child by itself; only the parent's openness can. No behaviour change: the pin was green before the fix commit (below), and map-node.ts spreads the parent context and overrides record only for an id-bearing item.
  4. Two pending release notes say "by design" (patch round 1, 2c06d027e8). Under ruling B, two @objectstack/lint changesets that have not been released yet were stating something false. In each, only the "yet" / "until …" clause changes; the rest of the sentence, the front matter, the levels and the Clause-② lines are byte-identical.
    • .changeset/22565-flow-cel-unbound-root-refused.md:34: "a flow write there is not judged for unbound roots yet, by design (rulings 5791822697 item 3 and 6104584601)."
    • .changeset/22677-flow-cel-record-entrance.md:20: "…and a flow write there is not refused for record until that snapshot carries them, by design (rulings 5791822697 item 3 and 6104584601)."

.changeset/22636-flow-cel-record-trigger-no-object-opens.md: @objectstack/lint minor, carrying the Clause-② line above. check-changeset-no-major's level axis requires at least minor for a yes (widening) declaration.

Measured before writing

  • Today's behaviour for the objectName-less record trigger: closed and judged. flowCelEntrances hands the flow record and opens nothing. Its root is judged against every declared object's fields, so user.id is refused there. The new pin was committed first (49c47a4e51) and run on the unfixed source: Tests 1 failed | 78 passed (79), the failure being a record trigger that names no start config.objectName stands down… with AssertionError: expected [ { …(4) } ] to deeply equal []. The map-child pin was green in the same run.
  • The corpus has no such trigger. Loaded configs (tsx import of each objectstack.config.ts), at e7e6544ab9: app-crm 1 flow and 0 record triggers; app-multi-package 0 flows; app-showcase 30 flows, 19 record triggers, 19 naming objectName; app-todo 4 flows, 1 record trigger, 1 naming it.
  • RULE_EXPLANATIONS (rule-explanations.ts) carries no S wording: no "until", "snapshot" or stand-down sentence for the flow CEL root judgment. Not touched.

Tests (at bd22088e0b; the later commits 337de825af and 2c06d027e8 edit only changeset text)

  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 (the whole package): Test Files 136 passed (136), Tests 6353 passed (6353).
  • pnpm --filter @objectstack/lint typecheck (tsc --noEmit plus check:test-typecheck, whose tsconfig.test.json includes src/**/*, so it compiles the test file): exit 0, check:test-typecheck: OK … 2 file(s) / 6 error(s) / 2 pinned signature(s) held (pre-existing, unchanged; the edited test file is not in the ledger, so it compiles with zero errors).
  • pnpm --filter @objectstack/lint build: exit 0, check-dts-emitted: @objectstack/lint - 6/6 declared declaration file(s) present.

Ablation of the new open reason. node scripts/ablation-replace.mjs --file packages/lint/src/flow-cel-root-scope.ts --anchor "if (kind === 'record_change' && !triggerObject) {" --replacement "if (false && kind === 'record_change' && !triggerObject) {" -- pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/validate-expressions.flow-cel-root.test.ts

  • The mutation landed on disk: anchor x1 -> x0, replace x0 -> x1, blob 274a60f90d10 -> 392c0b3b88f6.
  • Run with the mutation in place: Tests 1 failed | 78 passed (79), the one red being the new record-trigger pin.
  • Restored and proven: blob == HEAD (274a60f90d10) and git diff HEAD is empty, and git status --porcelain was empty afterwards.
  • The test imports the module by relative path (./flow-cel-root-scope.js), so the run reads src/, not dist/. No rebuild leg was needed.

objectstack validate on the four example apps, before and after

node packages/cli/bin/run.js validate --json in each example directory. Before: lint dist/index.js built at 49c47a4e51, the unfixed source, sha256 5a2bfdb33967b164…. After: rebuilt at bd22088e0b, sha256 3a0412ddb92568a7…. Every app exits 0 with valid: true both times, and the error and warning lists are byte-identical before and after (diff of the sorted finding arrays: 0 lines).

app errors warnings by id (before = after)
app-crm 0 field-no-consumers 9
app-multi-package 0 field-no-consumers 3
app-showcase 0 approval-approvers-may-resolve-empty 11, field-no-consumers 51, flow-draft-status-ambiguous 1, flow-loop-body-uncontained 2, liveness-planned-property 2, sdui/jsx-parse-level-only 1, security-private-no-readscope 2, title-unresolvable 1
app-todo 0 field-no-consumers 5, flow-draft-status-ambiguous 2

The widening removes nothing here because no shipped record trigger lacks objectName (above). It adds nothing anywhere: the change only sets an openedBy, and an open flow is judged for no root.

No text still promises the snapshot will widen

$ git grep -n "22636" 337de825af -- packages content/docs skills
packages/lint/src/flow-cel-root-scope.ts:156: * this judgment by ruling `6104584601` on #22636 (letter B).
packages/lint/src/validate-expressions.flow-cel-root.test.ts:419: * #22636, letter B). These pins hold the asymmetry: the gate stands down, the
packages/plugins/plugin-security/src/authored-row-write-verdict.test.ts:20:// Probe E-A (#5493 comment 5226364929) is where that costs something real: a
packages/plugins/plugin-sharing/src/authored-row-write-deferral.test.ts:23:// issue comment 5226364929): `checkEdit` abstains — and `canEdit` therefore
(exit 0)

$ git grep -n -i -E "widens? (that |the )?snapshot|until #22636|#22636 (is the carrier|widens)" 337de825af -- packages content/docs skills
(exit 1)
$ git grep -n -i -E "widens? (that |the )?snapshot|until #22636|#22636 (is the carrier|widens)" e7e6544ab9 -- packages content/docs skills   # control: the base
packages/lint/src/flow-cel-root-scope.ts:133: * ## The runtime publish gate stands down (#22636 is the carrier)
packages/lint/src/flow-cel-root-scope.ts:143: * widens that snapshot to carry flows and actions; when it lands, this
packages/lint/src/flow-cel-root-scope.ts:469: * there until #22636 widens the snapshot.
packages/lint/src/validate-expressions.flow-cel-root.test.ts:380: * #22636 widens the snapshot; when it lands, this stand-down goes.
packages/lint/src/validate-expressions.flow-cel-root.test.ts:418: * refused. The runtime publish gate's stand-down (S) covers it until #22636.
packages/lint/src/validate-expressions.ts:2172:  // (`perWriteSnapshotEntrance`) until #22636 widens that snapshot; every other
(exit 0)

$ git grep -n -i -E "snapshot carries them|unbound roots yet" HEAD -- .changeset    # HEAD = 2c06d027e8 (patch round 1)
(exit 1)
$ git grep -n -i -E "snapshot carries them|unbound roots yet" 337de825af -- .changeset    # control: the previous head
.changeset/22565-flow-cel-unbound-root-refused.md:34:- **The runtime publish gate:** a flow write there is not judged for unbound roots yet. …
.changeset/22677-flow-cel-record-entrance.md:20:**Not judged.** The stand-downs of the unbound-root rule are unchanged. At the runtime publi…
(exit 0)

The two remaining #22636 hits in packages/lint cite this card's ruling and promise nothing. The other two hits are a comment id that contains the digits. A broader until … widen grep over the same roots finds only unrelated sentences (sharing grants, driver-memory, the spec changelog).

Gates

Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at the final head 337de825af: 62 commands (30 pnpm, 32 direct node), the same list as at bd22088e0b. Each was run with its exit code captured before any pipe. The diff-reading ones (the changeset, citation, docs-audit, doc-authoring and nul-byte gates) were re-run at 337de825af after the last commit. Reconciled with --ran: ✓ dispatch-gates --ran: 62 derived famil(ies) accounted for — 61 run, 1 NOT-MEASURED (0 DERIVED from a recorded exit 3, 1 claimed).

  • 61 run, all exit 0. Among them:
    • check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen);
    • check-changeset-no-major: This diff introduces no major bump; its level axis, driven offline with --event over this body, reads LEVEL AXIS: this PR declares clause-② yes (widening), and no package whose packages/**/src/** it moves is graded patch and exits 0;
    • check-empty-changeset: No changeset from the merge base modified or deleted by this diff;
    • check-issue-citations: every citation this change adds resolves;
    • check:nul-bytes: scanned 10782 text file(s) … no raw ASCII control bytes;
    • check:dts-closure: 161/161 declared declaration file(s) present across 62 package(s);
    • check:test-source-alias and check:cross-package-test-inputs: OK.
  • check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (exit 3: six unrelated packages had no dist/). Those six were built under the verify lock, and it then exited 0.
  • NOT MEASURED: pnpm check:type-check-debt. The script is check-type-check-coverage.mjs --re-measure, which the dispatch forbids running locally; CI runs it. pnpm check:type-check-coverage (no re-measure) ran: exit 0.
  • Also run, outside this derivation: the three roster gates that keep their roster under packages/ (check:authz-resolver, check:error-code-casing, check:filter-alias-parity), all exit 0.
  • NOT MEASURED, CI's own shell (no local invocation): the five path-scheduled CI jobs (Test Core, Temporal Conformance, Dogfood Regression Gate, Dogfood Verify CLI, Build Core) and the workspace type-check lanes. The lint package's own typecheck ran (above).

Patch round 1, at 2c06d027e8. dispatch-gates --commands --repo objectstack-ai/objectstack on the two changeset paths derived 20 commands. 19 exited 0, among them:

  • check-adr-0087-registration --base origin/main: this PR adds no declared-breaking changeset (3 non-breaking changeset(s) seen);
  • check-changeset-no-major --base origin/main: This diff introduces no major bump;
  • check:nul-bytes, check:changeset-gate-self-tests, check:published-files and check:pm-changeset-deadline-census.

One is red by design: check-empty-changeset --base origin/main exits 1 on the foreign-changeset rule, naming both files. This is its DELIBERATE CORRECTION class, so the confirmation request is below. Check Changeset (not a required context) is success at 337de825af and failure at 2c06d027e8. Its two failure annotations are those two files.

Acceptance notes

Not in this PR

W as filed: no change to RuntimeStackContext, CONTEXT_STACK_KEYS, protocol.ts, packages/spec or the RuntimeAuthoringIssueSchema.path contract.


Generated by Claude Code

…ap child of an open parent; reword S as by design

The record-trigger pin is red until flowCelEntrances gains the open reason
in the next commit; the map-child pin holds behaviour already on main.

Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj
Co-authored-by: Claude <noreply@anthropic.com>
…he CEL root judge; the runtime gate's stand-down is by design

flowCelEntrances gains one open reason: a flow whose trigger kind is
record_change and whose start node names no config.objectName. The
record-change trigger then registers its hook with no object filter, fires
on a write to any object and hands that row, so the build door cannot know
the record's keys. The trigger shape is not refused.

The runtime publish gate's stand-down of this judgment (S) is reworded from
a hold-out awaiting a wider per-write snapshot to the ruled boundary: a
judgment that reads other items of the stack is objectstack validate's, and
the snapshot is not widened for it.

Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj
Co-authored-by: Claude <noreply@anthropic.com>
…boundary, without pointing at other entries

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

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/declarative-endpoints.mdx (via record_change (literal, a string literal in flowCelEntrances))
  • content/docs/api/plugin-endpoints.mdx (via record_change (literal, a string literal in flowCelEntrances))
  • content/docs/automation/approvals.mdx (via record_change (literal, a string literal in flowCelEntrances))
  • content/docs/automation/flows.mdx (via record_change (literal, a string literal in flowCelEntrances))
  • content/docs/automation/hooks.mdx (via record_change (literal, a string literal in flowCelEntrances))
  • content/docs/automation/workflows.mdx (via record_change (literal, a string literal in flowCelEntrances))
  • content/docs/concepts/architecture.mdx (via record_change (literal, a string literal in flowCelEntrances))
  • content/docs/getting-started/common-patterns.mdx (via record_change (literal, a string literal in flowCelEntrances))
  • content/docs/kernel/services-checklist.mdx (via record_change (literal, a string literal in flowCelEntrances))
  • content/docs/permissions/capabilities.mdx (via record_change (literal, a string literal in flowCelEntrances))
  • content/docs/permissions/system-context.mdx (via record_change (literal, a string literal in flowCelEntrances))
  • content/docs/protocol/objectql/schema.mdx (via record_change (literal, a string literal in flowCelEntrances))
  • content/docs/ui/actions.mdx (via record_change (literal, a string literal in flowCelEntrances))

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

  • content/docs/releases/v12.mdx (via record_change (literal, a string literal in flowCelEntrances))
  • content/docs/releases/v17/17-0.mdx (via record_change (literal, a string literal in flowCelEntrances))

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
  • 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 e86530088a137275d803db29cb7c3afbc0f9f10e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1e251073cbf8a62cddfd10cb1ff005ddaadc1839 — the merge of head 2c06d027e8eb501d1f6c053f45d1f4f1a15a0b8b into base e86530088a137275d803db29cb7c3afbc0f9f10e, 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 1e251073cbf8a62cddfd10cb1ff005ddaadc1839 && git checkout 1e251073cbf8a62cddfd10cb1ff005ddaadc1839
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e86530088a137275d803db29cb7c3afbc0f9f10e 2c06d027e8eb501d1f6c053f45d1f4f1a15a0b8b && git checkout -B drift-repro e86530088a137275d803db29cb7c3afbc0f9f10e && git merge --no-ff 2c06d027e8eb501d1f6c053f45d1f4f1a15a0b8b

node scripts/docs-audit/affected-docs.mjs --json e86530088a137275d803db29cb7c3afbc0f9f10e

⚠️ 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 e86530088a137275d803db29cb7c3afbc0f9f10e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… gate's stand-down by design

Each note's "yet" / "until that snapshot carries them" clause becomes "by
design", citing rulings 5791822697 item 3 and 6104584601; the rest of each
sentence, the front matter, the levels and the Clause-② lines are unchanged.

Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2c06d027e8eb501d1f6c053f45d1f4f1a15a0b8b
Local-runs: none

Read-only, at 2026-10-11T04:10Z: card #22636 (body and all 15 comments, including ruling 6104584601 letter B and the prior ruling 5791822697 item 3 on #19527), PR #22762 (body, its 6-file list, the net diff from the merge base e7e6544ab9 to the head, every quoted sentence verified with git show at the head), and the head's check-runs. The seat's own comments (6104682119, 6105130863, 6105210415) were read as claims and re-derived, not adopted. Nothing was built, run or re-run.

① Derived judgments

1. The one accept-set change — a flow whose start config.triggerType is a record-* string and whose config.objectName is absent (or not a non-empty string) is no longer judged for unbound flow CEL roots by objectstack validate — right, and "opens" is true at runtime. The diff adds exactly one open(...) call to flowCelEntrances (flow-cel-root-scope.ts:395–:398): if (kind === 'record_change' && !triggerObject), where kind is resolveFlowTriggerKind(flow) (packages/spec/src/automation/flow-trigger-kind.ts: record_change iff the start node's config.triggerType is a string starting record-) and triggerObject is nameOf(config.objectName) (a non-empty string, else undefined). Traced on the runtime at this head, none of it touched by the diff:

  • AutomationEngine.deriveTriggerBinding (packages/services/service-automation/src/engine.ts:3673) resolves the kind through the same resolver and, in its record_change arm, binds object: typeof config.objectName === 'string' ? config.objectName : undefined, so an absent or non-string objectName binds object: undefined.
  • RecordChangeTrigger.start (packages/triggers/trigger-record-change/src/record-change-trigger.ts:309–:314) calls this.engine.registerHook(hookEvent, handler, { object: binding.object, packageId }) for each mapped lifecycle event. The ObjectQL door assertValidHookObject returns at once on object === undefined (packages/objectql/src/engine.ts:2932), and the dispatcher triggerHooks (:4644) admits the entry through hookMatchesObject (:2819), whose documented semantic is "an absent object admits everything (global)". The hook therefore fires on a write to any object, declared by the stack or not.
  • buildContext (record-change-trigger.ts:389, :402, :511) reads object = binding.object ?? ctx.object, builds the row from previous, the input payload and the after-row, and returns record: isolatedRecord (and params: isolatedRecord), so the run is handed that row, and seedRunVariables flattens its keys beside record (as review 6095756455 measured).
    The build door cannot know the keys of a row from an arbitrary object, so leaving the flow unjudged is the module's existing rule for a record whose keys are not in hand, applied to a second shape. The widening is confirmed on the code, not on the dev's say-so.

2. The widening is exactly the Clause-②: line, no more. The new branch sits inside the per-flow start-node loop and keys only on kind === 'record_change' && !triggerObject. An array-form triggerType resolves to no kind in the spec resolver (its one documented divergence from the engine), so it never reaches this reason; the engine routes that shape to the trigger only to refuse it at bind, and lint already reports flow-trigger-unknown-event. A record-* string with a declared objectName is judged as before (pinned); objectName with no record-* token resolves to no kind and is judged (pinned); a record-* token with an undeclared objectName was already open. One reach the line leaves implicit, right by the already-published rule: a flow this reason opens is a parent like any open parent, so its subflow and map children open through the pre-existing fixpoint (:459–:470, untouched); the 22565 note already publishes "a subflow or map parent that is itself such a flow" as a stand-down. Nothing else newly opens, and nothing newly closes.

3. Nothing moves at the runtime publish door — right; S stands. validate-expressions.ts changes comment lines only (:2169–:2175); the code const runtimeGateWrite = options.runtimeWriteType !== undefined and objectWrite || runtimeGateWrite ? new Map() : flowCelEntrances(stack, fieldIndex) is byte-unchanged, so flowCelEntrances (and the new reason) is never consulted on a runtime write, and perWriteSnapshotEntrance() (:2253) is still what the scope receives there. perWriteSnapshotEntrance's body (flow-cel-root-scope.ts:489–:495) is byte-unchanged; only its docblock moved. runtime-gate.ts, authoring-rules.ts, rule-explanations.ts, packages/metadata-protocol, packages/spec and packages/services are untouched (git diff --stat over them is empty). W is not built; CONTEXT_STACK_KEYS and RuntimeStackContext are as ruling 5791822697 item 3 left them.

4. S re-worded; its code and pins unchanged — right. The three it blocks of the S describe (the gate passes user.id on edge e1 while the brace-trap verdict still refuses on e2; the build door refuses the same root on the same flow; the stand-down names the per-write snapshot) lie outside every diff hunk; only the describe title and two docblocks changed, plus the module section and the perWriteSnapshotEntrance docblock in flow-cel-root-scope.ts and the comment in validate-expressions.ts. The new wording cites ruling 5791822697 item 3, ruling 6104584601 and ADR-0109 Decision §3; that section's own text is about skill.tools[] resolution over the whole stack, and the citation follows both rulings' reading of it as "cross-item reference resolution belongs to the whole-stack rule", which is acceptable because both rulings cite it that way. No text anywhere still promises a widening: git grep 22636 at the head over packages content/docs skills docs .changeset returns the two citations (flow-cel-root-scope.ts:156, the test at :419) plus two unrelated matches on comment-id digits in plugin-security and plugin-sharing tests; a sweep of until|widen|yet|temporar|hold-out over the lint files that carried S finds only the new "Not a hold-out awaiting a wider snapshot" sentences and unrelated history; RULE_EXPLANATIONS has no snapshot or stand-down wording for the flow CEL root judgment (its two "runtime publish gate" mentions, rule-explanations.ts:1368 and :1474, belong to other rules); every other pending changeset's "Never at the runtime gate" line states what the snapshot carries and promises nothing.

5. The owed map-child pin — a pin only, with a control — right. The new it (test :380–:397) uses a map node whose itemObject is the declared acct, so the node cannot open f by itself; an api-typed parent g is open by its own trigger, and the pin asserts f draws no refusal and its openedBy matches map node 'each' in flow 'g' … 'g' is itself open; then two levels, g → map → h → map → f; then the red control: the same map node in a parent no entrance opens leaves f judged (one refusal). It exercises only the map-edge and fixpoint code (:447–:470), none of which is in the diff, so it was green before the fix by construction, as the dev reports; map-node.ts (about :177–:186) confirms what it pins: the child context spreads the parent's and sets record only for an id-bearing item. Carries 6096005997 and review 6095756455 ③.

6. The record-trigger pin is a true pin of the change. flowStack({ edgeCondition: USER, triggerType: 'record-after-update' }) builds a start node with the token and no objectName; before the diff that flow was handed record but not opened, so user.id drew one refusal, and the toEqual([]) assertion had to be red on the unfixed source, as the dev reports from the pins-first commit 49c47a4e51. The controls (declared objectName → one refusal; no token → one refusal) and the direct flowCelEntrances read (openedBy names 'record-after-create', bound is ['record']) match the reader at the head.

7. Docs — no false shipped sentence. The drift comment's 13 pages are listed through the generic record_change literal. None states the build door's unbound-root judgment or how record binds for an objectName-less record trigger: a grep for unbound-root, Unknown variable, expression-invalid with flow or CEL, and current_user with flow or CEL over content/docs hits only flows.mdx:1812–:1813 (the CEL evaluation table, runtime semantics this PR leaves alone) and unrelated pages; validating-metadata.mdx:704 describes the per-write snapshot without promising to widen it; every objectName in flows.mdx is CRUD-node or screen config. Nothing to correct.

Check-runs on the head (the read above; 40 runs): 31 success, 5 skipped (Auto Label and Check PR Size duplicates from the body-edit re-fire, Build Docs, Console Pin Gate, Packed-tarball smoke opt-in), 2 failure (both Check Changeset, below), 2 in progress at review time: Test Core (2/6), Test Core (3/6). Green among the completed: Build Core, Lint & Repo Gates, Temporal Conformance (live PG + MySQL), Test Core 1/6, 4/6, 5/6 and 6/6, Dogfood Regression Gate (three shards and the summary), Dogfood Verify CLI, Type Check (workspace, source gates, consumer gates, debt ledger), TypeScript Type Check, Governed Surface Queue Guard, Check Documentation Links, Flag docs affected, and the four claim guards. No red other than the designed one.

DELIBERATE CORRECTION — written confirmation (check-empty-changeset's foreign-changeset rule, #17712). Check Changeset is red on this head by design, in both runs (114381709085, 114382787294): the failing step is step 12, "Reject an empty-frontmatter changeset added by this PR", which runs scripts/check-empty-changeset.mjs --base on the merge base and carries the #17712 rule; step 11 "Require a changeset" is green because the PR adds a note of its own; steps 13 (ADR-0087 disposition) and 15 (major guard) are green. The failure annotations name exactly .changeset/22565-flow-cel-unbound-root-refused.md and .changeset/22677-flow-cel-record-entrance.md, each with the gate's two-class text whose DELIBERATE CORRECTION remedy is "do NOT restore it -- say so on the PR and get it confirmed". pr-automation.yml at origin/main triggers on pull_request only (types: [opened, synchronize, reopened, labeled, unlabeled, edited]; merge_group appears nowhere in the file), and its route 0 text says: a PR whose only .changeset rows are CHANGED, not added, corrects somebody else's pending note — do not apply skip-changeset, write the confirmation naming the note and what changed, leave the check red (#18375). Per .claude/skills/pm-dispatch/references/landing-operations.md at origin/main (lines 15–16), a PASS record at this tier on the same head is that confirmation when it names each modified note and judges each rewritten sentence. Both notes, judged:

  • .changeset/22565-flow-cel-unbound-root-refused.md, line 34 (added by PR feat(lint)!: refuse a flow CEL root the flow does not bind, naming current_user for the run-user aliases #22609; front matter @objectstack/lint major, @objectstack/spec major).
    Old, at the merge base e7e6544ab9: "- The runtime publish gate: a flow write there is not judged for unbound roots yet. Its per-write snapshot carries no actions or other flows, so it cannot see those entrances. Every other expression verdict at that door is unchanged."
    New, at the head: "- The runtime publish gate: a flow write there is not judged for unbound roots, by design (rulings 5791822697 item 3 and 6104584601). Its per-write snapshot carries no actions or other flows, so it cannot see those entrances. Every other expression verdict at that door is unchanged."
    True. Ruling 6104584601 letter B rules the stand-down "by design, not a hold-out"; ruling 5791822697 item 3 is "No snapshot widening: RuntimeStackContext / CONTEXT_STACK_KEYS are unchanged"; the code at the head matches both (validate-expressions.ts:2253 hands perWriteSnapshotEntrance() on every runtimeWriteType write; CONTEXT_STACK_KEYS is untouched). Only "yet" left and the two citations arrived; the clause about what the snapshot carries is still true. Nothing else moved: the front matter (lines 1–4) hashes equal at base and head (sha256 89f8d6ef1fa2b07c…); the Clause-②: no (narrowing) line and the adr-0087 "registered" marker are byte-identical (c6a833275aef7d04… at both); git diff -U0 shows one hunk of one line.
  • .changeset/22677-flow-cel-record-entrance.md, line 20 (added by PR fix(lint)!: flow CEL record is bound only where an entrance hands the flow a record (#22677) #22730; front matter @objectstack/lint major).
    Old, at the merge base: "Not judged. The stand-downs of the unbound-root rule are unchanged. At the runtime publish gate this judgment still stands down: its per-write snapshot carries no actions and no other flows, so it cannot see an entrance, and a flow write there is not refused for record until that snapshot carries them."
    New, at the head: "Not judged. The stand-downs of the unbound-root rule are unchanged. At the runtime publish gate this judgment still stands down: its per-write snapshot carries no actions and no other flows, so it cannot see an entrance, and a flow write there is not refused for record, by design (rulings 5791822697 item 3 and 6104584601)."
    True, for the same two rulings and the same code; record is entrance-derived since lint(flow CEL roots): record is still in ENGINE_BOUND_ROOTS, so objectstack validate passes a record.X read in a flow with no record entrance, which faults at run time once #22642 lands; and two texts describe record as always bound #22677 and the S stand-down covers it (the record block's gate pin, unchanged). The untouched opening clause "the stand-downs … are unchanged" describes lint(flow CEL roots): record is still in ENGINE_BOUND_ROOTS, so objectstack validate passes a record.X read in a flow with no record entrance, which faults at run time once #22642 lands; and two texts describe record as always bound #22677's own change and stays true of it; this PR's added open reason is published by its own note. Nothing else moved: the front matter hashes equal (ea0bb786c46b79eb…); the Clause-②: no (narrowing) line and the adr-0087 "not-required" marker are byte-identical (8d873874852a98c3… at both); one hunk of one line.

Both notes are still PENDING: present on origin/main (e86530088a13…) with the same blobs as the merge base (cbddb31a64, 4d8e6b7078); packages/lint/CHANGELOG.md on origin/main has no match for flow-cel-unbound-root-refused|unbound roots|snapshot carries them|flow CEL expression that reads, and .changeset/pre/ holds neither. No other pending changeset promises the widening: git grep -i -E "snapshot carries them|unbound roots yet|until #22636|22636 widen|widens? (that |the )?snapshot|stand-?down.{0,80}(yet|until|temporar)|(yet|until|temporar).{0,80}stand-?down" over .changeset at the head returns 0 hits (2 at the previous head 337de825af, the dev's control). Confirmed: leave the red standing, restore neither file, apply no skip-changeset.

② Semver level

.changeset/22636-flow-cel-record-trigger-no-object-opens.md: '@objectstack/lint': minor — right. The diff publishes in one package (packages/lint/src/**), and what it publishes is an accept-set widening of objectstack validate (one fewer refused shape) with no interface change and no narrowing. scripts/check-changeset-no-major.mjs's level axis (#16055) requires a PR declaring Clause-②: yes to grade at least one package whose packages/**/src/** it moves at minor or above; major would overstate a non-breaking widening and patch is what that axis refuses. The repo is in pre mode (.changeset/pre.json, tag next), which changes nothing here. The Clause-②: line is byte-identical in the claim 6104682119, PR body line 2 and the changeset (sha256 of the line 07fe8e380f1d316f… for all three): Clause-②: yes (widening: a flow whose record-*trigger names no startconfig.objectNameopens, sovalidate no longer reports an unbound flow CEL root there). The note's prose is true against the code (the no-filter hook, the row handed as record with its fields flattened beside it, the same treatment as a record trigger on an undeclared object, the trigger shape not refused, the gate's stand-down by design) and against the ruling. It carries no adr-0087 marker and needs none (minor, non-breaking; Check Changeset step 13 is green). PR body line 1 is Fixes #22636, as the ruling orders: this card carries the small PR and closes with it.

③ Boundary flags

Every flag in 6105118633 and 6105200615, and the thread's open items:

  • Round-0 deviation 1: the no-promise criterion failed over .changeset/ — carried into patch round 1 by seat order 6105130863; verified 0 hits at the head over .changeset (above), and the grep over packages content/docs skills finds only the two citations. Answered.
  • Deviation 2: RULE_EXPLANATIONS carries no S wording — verified by grep; nothing is owed behind the seat's [maintainer] validate: the field-no-consumers warning is one 856-character line, printed by validate, build and dev alike — one-line verdict + rule: id + a pointer to the full reasoning (os explain, which today takes only schema names) #22161 slice 8. Answered.
  • Deviation 3: level minor — answered in ②.
  • Deviation 4: the four commits carry the session-link trailer and a generic co-author trailer, and no model id — a process note with no accept-set bearing. Recorded.
  • Deviations 5–6 (a lock re-queue, an extra build so check:dual-build-cjs-loads could measure, the re-run after 337de825af): process notes; the head's check-runs are the gate verdicts this record reads.
  • Deviation 7: zero label writes, skip-changeset not applied — right, and required: route 0 forbids the label on a corrected foreign note, and the PR adds a changeset of its own.
  • Patch-round deviation 1: check-empty-changeset red by design — confirmed in writing above.
  • Patch-round deviation 2: the worktree was recreated — process note.
  • open_questions: none in either report. The earlier needs_decision report's three questions (6102874585) were closed by ruling 6104584601 (Q1 → B; Q2 moot under B; Q3 → open the flow, do not refuse the shape). Its out-of-scope finding (RuntimeAuthoringIssueSchema.path's describe omits datasets, name-keyed since [finding] a runtime-created dataset reaches ZERO author-time rules — the type declares allowRuntimeCreate: true while nothing declares it in runtimeTypes and TYPE_TO_STACK_KEY has no row #19143) is assigned by the ruling to a separate spec card the seat files; not this PR's. Escalated to the seat as still owed unless already filed.
  • The two items earlier reviews owed to this card — the map-child pin (6096005997, review 6095756455 ③) and the opened-set gap (6102568544, review 6102167316 ③) — both land here, verified in ① 1, 5 and 6.
  • Reviewer's flag, not blocking: the one input on which lint's reading and the engine's diverge is objectName: ''. nameOf('') reads no name, so lint now opens the flow; the engine binds object: '' (it is a string), and registerHook refuses that at the door (assertValidHookObject: an empty allow face would register a global hook), so activateFlowTrigger records the flow as refused to arm and it never fires. For that one shape the open reason's sentence ("fires on a write to any object") is not true; the consequence is an unjudged flow that cannot run (a false pass, never a false refusal or a false message to the author), the nameOf convention predates this PR (the undeclared-object reason read '' the same way), and no corpus trigger has it (20 of 20 name an object). Not a condition of this verdict. The gap it exposes is pre-existing and elsewhere: a record trigger with a blank objectName passes validate (trigger-readiness judges only a named, undeclared object) while the runtime refuses to arm it — an ADR-0078 silently-inert declaration. Escalated to the seat as a trigger-readiness card candidate.
  • Reviewer's note: a flow this reason opens also opens its subflow and map children through the fixpoint (① 2). Within the published rule; the Clause-②: line names the flow, which is the shape authors write.
  • The diff touches no packages/spec/src/** path; this review is owed by the declaration limb (Clause-②: yes) alone, which the ruling names.

Implemented-by: claude/issue-22636-runtime-gate-flow-entrances
Reviewed-by: session_01S3aAf11JjbW1mSGL1EhfFj

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Designed red, recorded before queueing · domain:spec seat 1 (#6017) · session session_01S3aAf11JjbW1mSGL1EhfFj · 2026-10-11T04:11Z

Check Changeset is red on 2c06d027e8 by design. It stays red, and this PR enters the queue with it, for these reasons:

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 04:14
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 04:14
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit 6981246 Oct 11, 2026
41 of 43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22636-runtime-gate-flow-entrances branch October 11, 2026 04:49
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/s tests tooling

Projects

None yet

2 participants