Skip to content

feat(workflows): add a classify with jev workflow action - #111710

Closed
Silthus wants to merge 14 commits into
PostHog:masterfrom
Silthus:feat/workflows-ai-decision-minimal
Closed

Silthus wants to merge 14 commits into
PostHog:masterfrom
Silthus:feat/workflows-ai-decision-minimal

Conversation

@Silthus

@Silthus Silthus commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Note

🔵 Merge risk: low (merge-risk rubric)

  • New internal route that spends AI credits per classification, behind a new scoped JWT purpose (posthog:workflows:classify).
  • Limits: the template is hidden and flag-gated at save, the JWT secret is empty outside dev/test so the route refuses every token until provisioned, and the revert is clean.

Changes

Kind Files Lines
Production 16 +398 −1
Tests 4 +276 −0
Total 20 +674 −1
  • With the workflow-jev-classify-action flag on, the builder palette offers "Classify with Jev".
  • The author enters a question, a JSON context and 2 to 16 categories with descriptions.
  • The step returns {category, confidence, probabilities}. The palette entry maps them to the jev_category and jev_confidence variables.
  • The author branches on jev_category with the regular conditional branch.
  • A test run in the step panel mocks the call and returns the first category, so it spends no credits.
Before (flag off) After (flag on)
Dispatch section without Classify with Jev Dispatch section with Classify with Jev and a Beta tag

Adding Classify with Jev, its output variables, and a conditional branch on jev_category

Step configuration with output variables

Classify with Jev step selected, showing Question, Context, Categories, and the jev_category and jev_confidence output variables

flowchart LR
  Step[Classify with Jev step]:::phYellow --> Fn[postHogClassify async function]:::phBlue
  Fn -- scoped service JWT --> Route[workflow_classifications route]:::phBlue
  Route -- AI approval, limits --> GW[AI gateway, hosted Jev]:::phRed
  GW --> Vars[jev_category, jev_confidence]:::phGray
  classDef phBlue fill:#1d4aff,stroke:#1d4aff,color:#fff;
  classDef phRed fill:#f54e00,stroke:#f54e00,color:#fff;
  classDef phYellow fill:#f9bd2b,stroke:#f9bd2b,color:#000;
  classDef phGray fill:#e5e7eb,stroke:#c7ccd1,color:#000;
Loading
  • The Django route sends the question only as instructions and the context only as state, so event data never becomes instructions.
  • The route refuses an organization without AI data processing approval, a token for another audience or team or without a workflow claim, bad category counts, and category names over 100 characters.
  • A gateway 429, 5xx or network error returns 503, which the worker retries. Any other gateway refusal returns 422 with the gateway status and fails the step. An answer the route cannot read also returns 422, so a broken gateway is never asked three times.
  • The route uses build_system_one_client with ai_product="workflows" and the team, on the gateway only. No TypeSafe fallback.
  • A strict save (API or MCP) applies the route's question and category limits, so a bad step fails at save rather than on every run.
  • New scoped JWT purpose and secret: WORKFLOW_CLASSIFY_JWT_SECRET on web and the CDP worker.
  • Mechanical: Harley's six commits keep their authorship. Fixes sit on top:
    • The LLM client import moves into the request path for the startup import budget.
    • A mypy ignore for the context field name.
    • The question length and blank category names are checked before a live run.
    • Refusal copy names the gateway status.
    • Category names are capped at 100 characters in the route and the worker.
    • The JWT purpose is imported through the workflows facade.

Not in v1: a native action type, an Unsure path, thresholds, token-bucket admission, a credit pre-check (a gateway 402 surfaces as an error), a byte cap beyond the character cap, and builder UI beyond the palette entry.

How did you test this code?

  • Django route contract, DRF test client with the client builder stubbed: products/workflows/backend/tests/api/test_workflow_classifications_api.py.
  • Save-time limits: TestJevClassifyActionValidation in test_hog_flow.py.
  • Async function, live and mocked paths: nodejs/src/cdp/async-functions/classify.test.ts.
  • Ran locally: the two Django files, posthog/test/repo_invariants/, the async-function jest suites, repo-wide mypy, frontend and Node typechecks, diff-aware semgrep, hogli ci:preflight --strict.
  • Ran the real worker handler and tokens against a local Django server (gateway not configured). Output below.
  • Screenshots and the GIF come from the real components in Storybook with mocked API responses.
  • Not run: a live call against the AI gateway, and a manual walk through the running app.
  • Not run: GitHub Actions. The fork's runs wait for maintainer approval, so the local gate stands in.

Test rationale: #107832's two seams. The route tests catch a context leaking into instructions, a missing approval check, and a retry status mapped to a failure. The jest tests catch the worker and the route disagreeing on limits or the JWT audience.

End-to-end proof against a local Django server
step, org without AI approval (team 4): {"status":403,"body":{"detail":"Your organization has not approved AI data processing. Approve it in organization settings."}}
step, approved org, no gateway configured (team 5): {"status":501,"body":{"detail":"Jev is not available on this PostHog deployment."}}
raw, token for another audience: 401 {"type":"authentication_error","code":"authentication_failed","detail":"Invalid or expired service token.","attr":null}
raw, token for another team: 401 {"type":"authentication_error","code":"authentication_failed","detail":"Service token team does not match the requested team.","attr":null}
raw, one category: 400 {"type":"validation_error","code":"invalid_input","detail":"Enter between 2 and 16 categories.","attr":"categories"}
raw, blank category name: 400 {"type":"validation_error","code":"invalid_input","detail":"Give every category a name.","attr":"categories"}

"step" lines run postHogClassify itself, so the worker mints the token. "raw" lines post with tokens minted by the worker's ScopedServiceJwt.

Release status

  • No feature flag controls this change
  • This change is behind a feature flag and is not available to users
  • This change makes a previously flagged feature available to everyone

Keep the flag off until the CDP worker with the new async function deploys and both sides have WORKFLOW_CLASSIFY_JWT_SECRET.

Automatic notifications

  • Publish to changelog?

Docs update

None. The step is hidden behind a flag.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, claude-opus-5-5

  • Revives feat(workflows): add a classify with jev workflow action #107832 with Harley Alexander's commits and step name; added commits carry his Co-authored-by.
  • An earlier revision renamed the step to "AI decision (Jeeeeeeeeev)". Michael asked to keep Harley's name, so the branch was rebuilt on Harley's commits with the review fixes re-applied.
  • Skills: /tdd, /improving-drf-endpoints, /writing-ui-components, /writing-pr-descriptions, /reviewing-with-coderabbit, /qa-frontend (reel).
  • CodeRabbit CLI: skipped, signed out with no person available to sign in.
  • QA swarm: two review rounds with GPT-6.1 Sol and Claude lanes on the earlier revision. Fixed the question length, blank names, and the refusal copy. Out-of-scope items went to the follow-up list on AI decision v1: revive Classify with Jev as one minimal PR Silthus/posthog#252.
  • No duplicate: an open-PR search for Jev workflow steps found none.

🤖 Generated with Claude Code

@trunk-io

trunk-io Bot commented Oct 5, 2026

Copy link
Copy Markdown

Merging to master in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Important

Review skipped

We couldn't safely recover the incremental review. No full review was started, and the last reviewed checkpoint was preserved. Retry later, or explicitly request a full review by commenting @coderabbitai full review.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a feature-flagged AI decision action to workflow editing. The Node.js template validates inputs and sends requests to a new authenticated backend endpoint. The endpoint checks AI data-processing approval and calls Jev. The action exposes decision and confidence outputs, and workflow validation enforces option limits.

Priority: ⬇️ Low

Merge Risk: 🔵 Low · up to fb2de

A persistent malformed AI response can cause repeated billed requests for one workflow step. This is a bounded risk to address or explicitly accept before enabling the feature.

Security Architecture Review

Security architecture risk: 🔵 Low · up to fb2de

Access is restricted to project-scoped service requests, and organizational approval is required before AI processing. No introduced security failure was established, but production configuration and the effects of repeated requests remain unconfirmed.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — A leaked decision token is limited to its claimed team, decision audience, and short lifetime. Compromise of the dedicated signing key could authorize decision requests across consenting teams, not merely one workflow. Workflow arguments do not select the destination, audience, gateway credential, or tool configuration.

Security Findings and Attack Paths

  • observed — The supplied security assessment retains no findings. Its deferred candidate targets an audience-rejection test and lacks a valid verification receipt. Source independently requires a workflow claim; the recorded absence of a missing-claim regression test does not demonstrate a production bypass. The candidate remains deferred.

Trust Boundaries and Controls

  • observed — The helper overwrites team identity from the trusted invocation. Authentication verifies the scoped token and URL-team match and requires a workflow claim. The endpoint validates input and checks the team's organization approval before constructing the gateway client. Workflow identity is trusted from the worker claim rather than separately looked up in workflow storage.

Resilience and Maintainability Implications

  • observed — The gateway client excludes response error bodies from logged exceptions because they may echo workflow state. Service tokens are minted inline rather than persisted in job rows. These existing controls are preserved by the new caller.

Hardening Proposals

  • proposed — Establish the gateway's charging and cancellation contract for repeated requests. If retries can duplicate billable effects, propagate a stable logical-request identifier and enforce downstream deduplication. This is conditional hardening, not an observed duplicate-charge finding.
  • proposed — Document that flag dial-down prevents adoption rather than stopping active AI processing, and identify an incident-stop procedure. Add runtime gating only if immediate cessation is a required release-control guarantee.
🚥 Pre-merge checks | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description includes the required sections, but it describes a different classification feature. It names the wrong feature flag and JWT purpose, claims different inputs and outputs, and refers to… Update the Problem and Changes sections to describe the AI decision step, its question/context/options inputs, decision and confidence outputs, actual feature flag, JWT purpose, route, and test names. Replace the classification screenshots …
Full details: Description check

Explanation

The description includes the required sections, but it describes a different classification feature. It names the wrong feature flag and JWT purpose, claims different inputs and outputs, and refers to different routes and tests than this AI decision change. It also omits the required before-flow diagram.

Resolution

Update the Problem and Changes sections to describe the AI decision step, its question/context/options inputs, decision and confidence outputs, actual feature flag, JWT purpose, route, and test names. Replace the classification screenshots and flow details with accurate material. Add separate before-and-after Mermaid flowcharts, and verify the test and release-status details against this change.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (1)
products/workflows/backend/presentation/views/workflow_ai_decisions.py-145-148 (1)

145-148: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Keep malformed gateway responses out of the retryable 503 path.

When the gateway returns a missing or malformed answer, parse_system_one_response raises SystemOneRequestFailed without a status code. This handler maps that error to 503, and callInternalApi retries 503 up to three times. A gateway that keeps returning malformed answers can therefore trigger up to three billed model requests for one workflow action. Distinguish malformed-response failures from network failures and return a non-retriable result for malformed responses.

🧹 Nitpick comments (1)
products/workflows/backend/tests/api/test_workflow_ai_decisions_api.py (1)

122-125: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Test rejection of a valid token without hog_flow_id.

The endpoint rejects a missing workflow claim, but no API test protects this check. Add a correctly scoped token without the claim, then assert a 401 and that Jev construction does not run.

Suggested test
+    def test_rejects_a_token_without_a_workflow_claim(self) -> None:
+        token = encode_jwt(
+            {"team_id": self.team.id},
+            timedelta(minutes=5),
+            PosthogJwtAudience.WORKFLOW_AI_DECISION,
+            signing_key=SECRET,
+        )
+        with patch(_BUILD) as build:
+            response = self._post(token=token)
+
+        assert response.status_code == status.HTTP_401_UNAUTHORIZED
+        build.assert_not_called()

ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository: PostHog/posthog/.coderabbit.yaml
  • Review profile: QUIET
  • Plan: Enterprise
  • Run ID: 175e76a7-c0c4-46db-b71f-245c6ce8f2d6
📥 Commits

Reviewing files that changed from the base of the PR and between d5e1441 and fb2de78.

📒 Files selected for processing (21)
  • frontend/src/lib/constants.tsx
  • nodejs/src/cdp/async-functions/ai-decision.test.ts
  • nodejs/src/cdp/async-functions/ai-decision.ts
  • nodejs/src/cdp/async-functions/index.ts
  • nodejs/src/cdp/async-functions/internal-api-call.ts
  • nodejs/src/cdp/config.ts
  • nodejs/src/cdp/templates/_destinations/posthog_workflows/posthog-ai-decision.template.ts
  • nodejs/src/cdp/templates/index.ts
  • nodejs/src/cdp/utils/jwt-utils.ts
  • nodejs/tests/fixtures/hog-bytecode/76959e3b7e5dee74f023dde37eedcfa8e23115b4284a9c903e3698c72adac389.json
  • posthog/cdp/flag_gated_templates.py
  • posthog/jwt.py
  • posthog/settings/data_stores.py
  • products/workflows/backend/facade/service_jwt.py
  • products/workflows/backend/presentation/views/hog_flow.py
  • products/workflows/backend/presentation/views/workflow_ai_decisions.py
  • products/workflows/backend/routes.py
  • products/workflows/backend/service_jwt.py
  • products/workflows/backend/tests/api/test_hog_flow.py
  • products/workflows/backend/tests/api/test_workflow_ai_decisions_api.py
  • products/workflows/frontend/Workflows/hogflows/panel/HogFlowEditorPanelBuild.tsx

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 6 remain after this review.

@Silthus
Silthus force-pushed the feat/workflows-ai-decision-minimal branch from 323249a to ecb4772 Compare October 5, 2026 08:35
@Silthus
Silthus force-pushed the feat/workflows-ai-decision-minimal branch from ecb4772 to 1c2d0fe Compare October 5, 2026 09:12
@Silthus Silthus changed the title feat(workflows): add an ai decision step backed by jev feat(workflows): add a classify with jev workflow action Oct 5, 2026
@Silthus
Silthus marked this pull request as ready for review October 5, 2026 10:37
@Silthus
Silthus requested a review from a team as a code owner October 5, 2026 10:37
@parameterai

parameterai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Risk: No findings

Since the last review, the PR's substantive change is the feature-flag gate on the classification route: workflow_classifications.py now refuses requests with 403 unless the workflow-jev-classify-action flag is enabled for the team, and the check fails closed when flag evaluation errors. The rest of the diff since the last review snapshot is rebase drift from master (public template route registration, unrelated feature-flag constants, CDP DLQ config), all of which already exists in the PR base. No new security issues found: the scoped-JWT auth chain, per-purpose secret, team-claim matching, bounded inputs, and save-time validation reuse all hold.

Sentinel reviewed b07fd97 · Review settings

@pr-assigner-resolver-posthog

Copy link
Copy Markdown

👀 Auto-assigned reviewers

These soft owners were skipped because they only have minor changes here. Nothing blocks merge, so self-assign if you'd like a look:

  • @PostHog/team-ingestion (owners.yaml)

Soft owners come from each directory's owners.yaml and each product's product.yaml (resolved nearest-file-wins). For a skipped owner, the locator is the file that decided it. Generated files and lockfiles are ignored when deciding ownership.

@Silthus
Silthus force-pushed the feat/workflows-ai-decision-minimal branch from 1c2d0fe to 3957d7e Compare October 5, 2026 19:09
@Silthus

Silthus commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Note

🤖 Claude Opus 5.5 responding on behalf of Michael

@coderabbitai both findings from your review body are fixed in 3957d7e.

  • Malformed gateway answers: _is_transient in workflow_classifications.py now treats only an unreached gateway (an httpx.HTTPError cause), a 429, or a 5xx as retriable. An unreadable answer returns 422, so the worker fails the step without asking Jev again. The new malformed_answer case covers it.
  • Missing workflow claim: test_rejects_a_token_without_a_workflow_claim posts a correctly scoped token without hog_flow_id and asserts a 401 with no client built.

@coderabbitai

coderabbitai Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
fd -t f 'workflow_(classifications|ai_decisions).*\.py$' .
fd -t f 'workflow_(classifications|ai_decisions).*\.py$' . --exec ast-grep outline {} --items all
rg -n -C 12 --type py 'def _is_transient\b|malformed_answer|def test_rejects_a_token_without_a_workflow_claim\b' products/workflows
rg -n -C 8 --type py 'hog_flow_id|_is_transient\(' products/workflows/backend/presentation/views/workflow* 2>/dev/null

Length of output: 46072


@Silthus Both findings are addressed in the inspected code. The regression cases cover malformed answers returning 422 and missing workflow claims returning 401 without building a client.

I checked the source only; I did not run the tests. No further changes are needed for these two findings.

You are interacting with an AI system.

mayteio and others added 14 commits October 7, 2026 08:09
The worker used the default 3 second internal fetch budget, but Django gives the gateway 5 seconds and maps a gateway timeout to a 503 for the worker to retry. The worker aborted first, discarded late answers, and sent the same classification again.

callInternalApi now accepts an optional per-attempt timeoutMs. The classify step passes 7 seconds. Other callers keep the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: 8cb2ba00-a783-4633-a0ce-424140c553eb
The AI usage report groups workflow AI spend by the team_id event property. The classify call sent only hog_flow_id, so any billable Jev generation grouped under team 0. The call now sends team_id with the other labels, as the Anthropic gateway client does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: 8cb2ba00-a783-4633-a0ce-424140c553eb
The classification endpoint refuses more than 16 categories or a description over 500 characters with a 400, so such a step failed on every run. A strict workflow save now runs the endpoint's own categories serializer on the step inputs. The mocked test run checks the same limits, so the test panel no longer reports success for inputs a live run rejects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: 8cb2ba00-a783-4633-a0ce-424140c553eb
The classification endpoint mapped a gateway rejection to 502, which the worker retries, so a permanent rejection reached the gateway three times. A gateway 5xx also got the rejection message that blames the step inputs. A rejection now returns 422, which the worker does not retry, and a gateway 5xx returns 503 with the busy message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: 8cb2ba00-a783-4633-a0ce-424140c553eb
The classification endpoint forwarded any context size to the gateway. It now rejects a context over 65,536 characters of JSON with a 400 that names the limit, the same state limit the ml_inference decide API uses for the same model. The worker checks the same limit, so a mocked test run fails on the same input as a live run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Generated-By: PostHog Desktop
Task-Id: 8cb2ba00-a783-4633-a0ce-424140c553eb
The classification view imported posthog.llm.system_one_client at module
level, which pulls the OpenAI and Anthropic SDKs into Django startup
through the route registry and fails the startup import budget. The view
now imports it in the request path and keeps its own copy of the
16-category limit.

It also passes team_id to build_system_one_client, which labels the
gateway event and derives the distinct ID itself. The route test patches
the client where the view now looks it up.

Co-authored-by: Harley Alexander <harley.a@posthog.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The request field named context shadows DRF's Field.context in the type
stubs, so repo-wide mypy fails. DRF pops declared fields off the class,
so the runtime is unaffected. The ignore follows the signals scout rubric
serializers.

Co-authored-by: Harley Alexander <harley.a@posthog.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A question over 2,000 characters passed a strict save and the mocked test
run, then failed every live run with a 400 from the classification route.
The worker's checks and the save-time validator now apply the route's
question limit too.

A category row with a description but no name reached Jev as a category
named "", so a classification could match no branch. The route and the
worker now ask for a name on every category.

Co-authored-by: Harley Alexander <harley.a@posthog.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tion

Every gateway refusal told the author to check the step inputs, including
an out-of-credits 402 or a gateway key problem that the inputs cannot fix.
The step now fails with the gateway status instead.

Co-authored-by: Harley Alexander <harley.a@posthog.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A malformed gateway answer raised the same error as a network failure, so the
endpoint returned 503 and the worker asked Jev up to three times. Only an
unreached gateway is transient now; an unreadable answer returns 422.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Silthus

Silthus commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

Note

🤖 GPT-6.1 Sol responding on behalf of Michael

Replaced by the draft fork PR #366 in its native fork stack. @meikelmosby will start migrating the stack from the fork into PostHog/posthog for upstream review and CI. Closing this master-targeted copy to keep the handoff clear.

@Silthus Silthus closed this Oct 7, 2026
mayteio added a commit that referenced this pull request Oct 8, 2026
Revives the "Classify with Jev" step from #107832 and #111710 on current master.

The classification endpoint asks the model through the ml_inference facade with privacy mode on, and rejects answers it cannot branch on. The step and its endpoint stay behind the workflow-jev-classify-action flag.

Generated-By: PostHog Desktop
Task-Id: 8ed258e7-0201-436d-9625-5743d7c23174
mayteio added a commit that referenced this pull request Oct 12, 2026
Revives the "Classify with Jev" step from #107832 and #111710 on current master.

The classification endpoint asks the model through the ml_inference facade with privacy mode on, and rejects answers it cannot branch on. The step and its endpoint stay behind the workflow-jev-classify-action flag.

Generated-By: PostHog Desktop
Task-Id: 8ed258e7-0201-436d-9625-5743d7c23174
mayteio added a commit that referenced this pull request Oct 12, 2026
Revives the "Classify with Jev" step from #107832 and #111710 on current master.

The classification endpoint asks the model through the ml_inference facade with privacy mode on, and rejects answers it cannot branch on. The step and its endpoint stay behind the workflow-jev-classify-action flag.

Generated-By: PostHog Desktop
Task-Id: 8ed258e7-0201-436d-9625-5743d7c23174
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants