Skip to content

fix(#3426): build-time warning for unresolvable flow templates + guard the formula re-read#3472

Merged
os-zhuang merged 2 commits into
mainfrom
fix/3426-followups
Jul 25, 2026
Merged

fix(#3426): build-time warning for unresolvable flow templates + guard the formula re-read#3472
os-zhuang merged 2 commits into
mainfrom
fix/3426-followups

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Two follow-ups to #3426, after #3445 closed the formula half of {record.<path>} template resolution. Keeps #3426 open — see the third section for the remaining lookup-expand piece and why it's deferred.

1. Build-time signal for unresolvable flow templates (the issue's fallback ask)

os validate now flags a record-change flow node whose {record.<path>} template can't resolve, turning the previous silent blank into an advisory warning. New @objectstack/lint rule validateFlowTemplatePaths (pure (stack) => Finding[], ADR-0019), wired into os validate right beside the existing flow-trigger-wiring check.

Two findings:

  • flow-template-unknown-field{record.<x>} where <x> is neither a declared field nor a system column (a typo like {record.full_naem}).
  • flow-template-lookup-traversal{record.<lookup>.<field>}, a cross-object hop the seeded record carries only as a scalar id (still unsupported; see §3).

Deliberately quiet to keep false positives near zero: formula fields, bare lookup ids, numeric indexes into multiple lookups (#1872), json sub-paths, and system columns are not flagged, and flows bound to an object this stack doesn't define are skipped (no schema to compare against).

2. Hydration re-read guards (evaluation finding on #3445)

The trigger-record-change computed-field re-read from #3445 was unconditional and per-binding. Two guards keep it off the hot path:

  • Schema gate — skip the re-read entirely when the object declares no formula field (the only thing the re-read adds; summary is stored on write). Uses the engine's optional getObjectConfig; when unsure, re-reads (correctness over the optimization).
  • Per-write memoization — N flows bound to the same written record share one re-read, keyed on the shared HookContext (the engine passes one ctx reference to every handler for a single write). A WeakMap, so entries drop when the context is GC'd.

3. Lookup expansion ({record.account.name}) — intentionally deferred, needs a direction

Scoping shows the plumbing is easy (no spec/engine/automation changes: binding.config already carries the start-node config, findOne already accepts an expand map, and expandRelatedRecords infers the target from the field's reference). But it hits a read-identity security decision: the trigger's re-read runs as an elevated system principal, so expanding a relation there would read the referenced records as system — bypassing the triggering user's RLS/FLS. For a runAs:'user' flow, {record.account.secret} in a notify template would then leak fields the user can't see. Reading the expand as the user isn't feasible in the trigger: the hook session carries only userId, not the resolved grants (the engine resolves those only at flow-run time, #3356).

So safe lookup expansion belongs in the automation engine's flow run (which has the user's grants), not the trigger's system re-read. That's the "decide direction first (small ADR)" call from the issue thread — deferring rather than shipping a system-read expand. The new lint rule flags the traversal as unsupported in the meantime, and the formula workaround ({record.first_name} {record.last_name}, or a projecting formula field) still applies.

Testing

  • @objectstack/lint: 282 tests pass (15 new for validateFlowTemplatePaths); tsc --noEmit clean.
  • @objectstack/trigger-record-change: 46 tests pass (5 new for the gate + memoize, existing formula-context integration test still green); tsc --noEmit clean.
  • @objectstack/cli: tsc --noEmit clean; os validate runs end-to-end on examples/app-crm (new "Checking flow template references…" step present, exit 0, no crash on real metadata).

Closes part of #3426 (formula-side build signal + re-read guards); the lookup-expand piece stays open pending the direction decision in §3.

🤖 Generated with Claude Code

…+ guard the #3426 re-read (#3426)

Two follow-ups to #3426, after #3445 closed the formula half.

Build-time signal (the issue's fallback ask): `os validate` now flags a
record-change flow node whose `{record.<path>}` template can't resolve — the
previous SILENT blank becomes an advisory warning. New @objectstack/lint rule
`validateFlowTemplatePaths` with two findings: flow-template-unknown-field
(a typo) and flow-template-lookup-traversal (a cross-object hop still
unsupported). Wired into `os validate` beside the flow-trigger-wiring check.

Hydration re-read guards: the trigger-record-change computed-field re-read
(#3445) is now skipped when the object declares no formula field (via the
engine's optional getObjectConfig) and memoized per write on the shared
HookContext, so N flows on one written record share one re-read instead of N.

Lookup expansion ({record.account.name}) is intentionally NOT implemented here:
scoping shows it needs a read-identity design decision — expanding in the
trigger's elevated (system) re-read would bypass the triggering user's RLS/FLS
on the referenced object. Left for an ADR; the lint rule flags it unsupported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jul 25, 2026 12:46am

Request Review

# Conflicts:
#	packages/cli/src/commands/validate.ts
#	packages/lint/src/index.ts
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Jul 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cli, @objectstack/lint, packages/triggers.

19 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via packages/cli)
  • content/docs/api/client-sdk.mdx (via @objectstack/cli)
  • content/docs/api/data-flow.mdx (via @objectstack/cli)
  • content/docs/api/environment-routing.mdx (via @objectstack/cli)
  • content/docs/api/error-catalog.mdx (via @objectstack/cli)
  • content/docs/automation/hook-bodies.mdx (via packages/cli)
  • content/docs/deployment/backup-restore.mdx (via @objectstack/cli)
  • content/docs/deployment/cli.mdx (via @objectstack/cli)
  • content/docs/deployment/self-hosting.mdx (via @objectstack/cli)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/cli)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/cli)
  • content/docs/kernel/runtime-services/index.mdx (via packages/cli)
  • content/docs/permissions/authentication.mdx (via @objectstack/cli)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint)
  • content/docs/plugins/packages.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/cli)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/cli)
  • content/docs/releases/implementation-status.mdx (via @objectstack/cli)
  • content/docs/releases/v16.mdx (via @objectstack/cli)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang merged commit 169b58a into main Jul 25, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the fix/3426-followups branch July 25, 2026 00:53
os-zhuang added a commit that referenced this pull request Jul 25, 2026
…e flow templates (#3475) (#3482)

A record-change flow can declare `expand: ['<lookup>', …]` on its start-node
config so node templates resolve `{record.<lookup>.<field>}` (e.g.
`{record.account.name}` — the #3426 lookup gap).

The engine re-reads the declared relations AFTER identity resolution, as the
run's OWN principal (`resolveRunDataContext` honors `runAs`), and grafts the
expanded objects onto the run record. So a `runAs:'user'` run reads the
referenced object as the triggering USER — RLS/FLS enforced — not system-
elevated, which is exactly what made expansion unsafe in the trigger's re-read
(no resolved grants there). New `AutomationEngine.setRecordExpander`, bridged by
the plugin to the same data engine the CRUD nodes use.

Only declared keys are grafted, so bare lookup ids, `multiple` lookup arrays
(#1872), and trigger-hydrated formula fields are untouched. Opt-in ⇒ zero cost
otherwise; best-effort ⇒ a re-read failure leaves the record unexpanded and
never breaks the flow. The `flow-template-lookup-traversal` lint warning
(#3472) is suppressed once a relation is declared in `config.expand`.

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
os-zhuang added a commit that referenced this pull request Jul 25, 2026
…ly never firing (#3481) (#3484)

An array `triggerType` on a flow start node (e.g. ['record-after-create',
'record-after-delete']) — the shape an author or AI authoring pass naturally
reaches for to fire on multiple events — was accepted everywhere and armed
nowhere. Multi-event unions are deliberately unsupported (#3457), but nothing
said so: defineFlow passed the array, the engine's `typeof === 'string'` check
folded it to no trigger and misclassified the flow as manual (so it never
entered the binding audit), and the flow-trigger-readiness lint used the same
narrowing and produced no finding. The flow bound to nothing and never fired,
with zero output at any layer — the last authoring shape still slipping past
the #3427/#3472 silent-never-fire guards.

Defensive fix (arrays stay unsupported; they now fail loudly):

- lint (validate-flow-trigger-readiness): an array triggerType with any
  record-* element yields a flow-trigger-unknown-event warning at os validate
  time, steering to record-after-write or one flow per event.
- engine (resolveTriggerBinding): route such an array to the record_change
  trigger — as an unmappable single token is — instead of folding to a manual
  flow, so it reaches the trigger's bind-time rejection and the binding audit.
- trigger (record-change): the bind-time rejection detects the array shape and
  emits a targeted warning (names the flow, points at record-after-write and
  #3457) rather than the generic unknown-token line.

Each new test proves red against the pre-fix code first.

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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

Development

Successfully merging this pull request may close these issues.

1 participant