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.
- 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.
- 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.
- 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.
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.
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.
- 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.
- 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.
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.
- 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.
- 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.
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:
has_object_permissionthat 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 reachperform_update(the research note lists nine). Generated types regenerated withhogli build:openapi.disabledReason.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/workflow-read-only-mode, sections 3, 4, and "The minimal 1.1 change"./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):
PostHog/posthog, on a branch cut fromupstream/master. Nothing merges into this fork'smaster(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.status,message,why,fix.readOnlymatches underproducts/workflows/frontend/Workflows/are inReputation/andtemplates/TemplateJsonModal.tsx, both unrelated. The disabled-state work is new.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.
0026_*against head0025_hogflow_email_sending_paused_by, fiveAddFieldoperations onHogFlow, all nullable, no backfill:managed_by({gui, code}, NULL reads asgui),created_via({web, api, mcp, wizard, self_driving}),source_repository,source_path,source_ref. Allmax_length=400, followingname(hog_flow.py:140). Define both enums in the workflows product; do not importExternalDataSourceCreatedViafromproducts/warehouse_sources/backend/facade/types.pyacross the product boundary, copy the values.help_text, because the text reachesapi.schemas.tsand the MCP tool descriptions verbatim.created_viais writable on create and stripped inupdate()withvalidated_data.pop("created_via", None)(copyingproducts/warehouse_sources/backend/presentation/views/external_data_source/source_setup.py:360-368); on create, resolve it fromget_event_source(request)through an explicitEventSourcetoCreatedViamap, copying_EVENT_SOURCE_TO_CREATED_VIA(products/data_warehouse/backend/presentation/views/table.py:92), and ignore any claimed value.managed_bystays writable so the release can write it.check_object_permissionsonHogFlowViewSet, in the shape ofexternal_data_source.py:1768-1772. On a row withmanaged_by == "code", refuse any write touchingDRAFT_CONTENT_FIELDSplusnameanddescription, unlessget_event_source(request)is in{EventSource.API, EventSource.CLI}. Allow-list, not deny-list: fail closed so a newEventSourceupstream is denied by default. Useget_event_source, not the viewset's existing_is_mcp_request(hog_flow.py:4033), which misses every first-party MCP surface inMCP_TRANSPORT_EVENT_SOURCES.bulk_deletegets its own check, because it isdetail=Falseand never callsget_object(). Delete is refused on a code-managed workflow, single and bulk. The path out is release togui, archive, delete.statusstays 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 tosaveWorkflowPartialso the enable button stops sending the whole graph.managed_by-only release: a PATCH whose body ismanaged_byalone sets it back togui. A payload carryingmanaged_byalongside anything else is refused withcode="immutable", not silently stripped.status,message,why,fix, naming the recordedsource_repositoryandsource_path. Do not copy the managed-viewset error: it raises a bareserializers.ValidationError(products/data_warehouse/backend/presentation/views/saved_query/editing.py:354-355) that drf-exceptions-hog renders with nowhyand nofix.canEditWorkflowinworkflowLogic, combining code-ownership withworkflowUserAccessLevel. 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 realPATCHthat only the backend rejects.products/workflows/frontend/generated/files. Those come fromhogli build:openapi-schemathen the orval step infrontend/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.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 holdshog_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_pathorsource_refyet: the CLI tickets do that, along with--allow-moveand the path comparison. The columns ship null for every row, exactly ascreated_viadid onExternalDataSource. The source link is composed in #78; this ticket ships the fields and the badge. Per-revision git state is #77;HogFlowRevisionis untouched here.HogFlowFilterSet.Meta.fieldsgainskeyfrom #72, not from this ticket.Named cost to document: a push can re-enable what a person disabled, because
statuslives in the source (#69 decision 7) and stays UI-writable.