Skip to content

Read-only workflows: the small feature that ships first #76

Description

@Silthus

Part of #68

Question

Ship the smallest read-only mode: a workflow marked as code-managed cannot be edited in the PostHog UI, and the editor says why.

This is the one feature Michael called out as shippable on its own. It builds fresh in product code and merges under the ship target the kickoff grilling set.

Scope, per the provenance ticket's minimum column:

  • Backend. The column (or table row) that marks a workflow as code-managed. A permission class with has_object_permission that rejects UI and MCP writes on a code-managed workflow with a 403 whose message names the owner and the way out, and lets the API-key push through. Cover every write action, including the ones that never reach perform_update (the research note lists nine). Generated types regenerated with hogli build:openapi.
  • Frontend. A badge on the workflow scene and in the list. The canvas mutation listeners and autosave refuse to fire on a code-managed workflow (the research note's trap: the enable button PATCHes the whole graph, so the guard belongs on the mutations, not the save loader). The save button carries a disabledReason.
  • Tests. One backend test per write action that must be blocked, one that shows the push still writes, one frontend test for the guarded mutation. Nothing else.
  • Docs. The workflows docs under docs/ gain the read-only state in the same PR.

Proof gate and adversarial review per SHIP.md. Anything that cannot be proved locally is a HITL park, not a merge.

Context

  • Research note on research/workflow-read-only-mode, sections 3, 4, and "The minimal 1.1 change".
  • Skills: /improving-drf-endpoints, /django-migrations, /writing-ui-components, /adopting-generated-api-types, /writing-tests, /writing-user-facing-copy.

Locked by #69

From the kickoff grilling (#69, resolution):

  • This is the first upstream PR of the whole effort. Ship authority is granted: a PR against upstream PostHog/posthog, on a branch cut from upstream/master. Nothing merges into this fork's master (it carries fork-only agent docs, which have leaked upstream from a branch cut off it before). This slice goes first because it stands on its own merit to the workflows team without them having to buy the whole idea.
  • Read-only means three things, and the block is server-side. A badge, a blocked save enforced in the serializer, and a link back to the source. The refusal carries status, message, why, fix.
  • A UI-only block does not count. A code-managed workflow would stay writable through the API and through MCP, and the next push would silently revert a person's edit, which is the worst version of the 2am-hotfix problem rather than an answer to it.
  • The editor has no read-only mode today: the only readOnly matches under products/workflows/frontend/Workflows/ are in Reputation/ and templates/TemplateJsonModal.tsx, both unrelated. The disabled-state work is new.
  • The link back to the source depends on provenance (#75) storing enough to build one, and the rendering is #78.

Locked by #75

The provenance grilling (#75, resolution) settled the storage, the lock and the guard. This ticket ships all of it in one PR.

Scope, exactly.

  1. Migration 0026_* against head 0025_hogflow_email_sending_paused_by, five AddField operations on HogFlow, all nullable, no backfill: managed_by ({gui, code}, NULL reads as gui), created_via ({web, api, mcp, wizard, self_driving}), source_repository, source_path, source_ref. All max_length=400, following name (hog_flow.py:140). Define both enums in the workflows product; do not import ExternalDataSourceCreatedVia from products/warehouse_sources/backend/facade/types.py across the product boundary, copy the values.
  2. Five serializer fields with help_text, because the text reaches api.schemas.ts and the MCP tool descriptions verbatim. created_via is writable on create and stripped in update() with validated_data.pop("created_via", None) (copying products/warehouse_sources/backend/presentation/views/external_data_source/source_setup.py:360-368); on create, resolve it from get_event_source(request) through an explicit EventSource to CreatedVia map, copying _EVENT_SOURCE_TO_CREATED_VIA (products/data_warehouse/backend/presentation/views/table.py:92), and ignore any claimed value. managed_by stays writable so the release can write it.
  3. The guard: override check_object_permissions on HogFlowViewSet, in the shape of external_data_source.py:1768-1772. On a row with managed_by == "code", refuse any write touching DRAFT_CONTENT_FIELDS plus name and description, unless get_event_source(request) is in {EventSource.API, EventSource.CLI}. Allow-list, not deny-list: fail closed so a new EventSource upstream is denied by default. Use get_event_source, not the viewset's existing _is_mcp_request (hog_flow.py:4033), which misses every first-party MCP surface in MCP_TRANSPORT_EVENT_SOURCES.
  4. bulk_delete gets its own check, because it is detail=False and never calls get_object(). Delete is refused on a code-managed workflow, single and bulk. The path out is release to gui, archive, delete.
  5. status stays writable by everyone, from the editor and from the MCP lifecycle tools, so a person can stop a workflow without a deploy. Add the status-only path to saveWorkflowPartial so the enable button stops sending the whole graph.
  6. The managed_by-only release: a PATCH whose body is managed_by alone sets it back to gui. A payload carrying managed_by alongside anything else is refused with code="immutable", not silently stripped.
  7. The four-field error: status, message, why, fix, naming the recorded source_repository and source_path. Do not copy the managed-viewset error: it raises a bare serializers.ValidationError (products/data_warehouse/backend/presentation/views/saved_query/editing.py:354-355) that drf-exceptions-hog renders with no why and no fix.
  8. canEditWorkflow in workflowLogic, combining code-ownership with workflowUserAccessLevel. This also closes a bug that exists today: the canvas never reads the access level, so a Viewer gets a fully interactive canvas and the autosave fires a real PATCH that only the backend rejects.
  9. The badge, plus the regenerated products/workflows/frontend/generated/ files. Those come from hogli build:openapi-schema then the orval step in frontend/bin/generate-openapi-types.mjs; a serializer field does not reach the UI types until that runs, so the regenerated files belong in this diff.
  10. Amend the comment at posthog/event_usage.py:472-476, which currently asserts that mis-declaring as the CLI "buys no extra write latitude". This guard makes that false. It is not an escalation (the UA needs a key that already holds hog_flow:write), but the sentence is an invariant a reviewer maintains, so correct it here and say why.

Not in scope. Nothing writes source_repository, source_path or source_ref yet: the CLI tickets do that, along with --allow-move and the path comparison. The columns ship null for every row, exactly as created_via did on ExternalDataSource. The source link is composed in #78; this ticket ships the fields and the badge. Per-revision git state is #77; HogFlowRevision is untouched here. HogFlowFilterSet.Meta.fields gains key from #72, not from this ticket.

Named cost to document: a push can re-enable what a person disabled, because status lives in the source (#69 decision 7) and stays UI-writable.

Activity

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

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions