Repository navigation
Email templates: refuse edits to code-managed templates on the server #491
Description
Map: Map: send email from code with one PostHog call
Spec: Spec: code-managed email templates, section "Lock (later slices)", server part
Cast: Opus 5.5, high
Repository: PostHog/posthog
Question
Does PostHog refuse every edit to a code-managed template that is not an upload, and tell the caller why and how to change it in code instead?
Delivers end to end. One ownership policy in the messaging product, checked on the row locked by the save path, keyed on action and whole payload. On managed_by = code: update, partial update (including draft-only writes when PostHog#118664 is in), design and soft delete answer 403 immutable with detail, why, fix (naming the file when the source is known) and docs_url. Allowed: upload, send, make transactional, and a release PATCH whose only field is managed_by: "gui" from session, personal API key or OAuth callers. A release keeps the key; the next upload claims the template again. The MCP update and design tool descriptions say a code-managed template changes through upload.
Port and adapt services/code_ownership.py from the fork's feat/workflows-code-managed-read-only (ebdfa6b6be, b84d8d7116, 5941fb9486), including the TOCTOU re-check on the locked row.
Test seams. The template API: one refusal per blocked action (update, partial update, design, delete, and draft-only when PostHog#118664 is in) and one pass per allowed action (upload, send, release, then a normal update after release). An MCP-style call (personal key with x-posthog-client: mcp) gets the same refusal. Prior art: test_hog_flow_code_managed.py on the fork.
Write scope. products/messaging/backend/services/ (ownership policy), products/messaging/backend/presentation/views/message_templates.py, products/messaging/backend/tests/api/, products/workflows/mcp/email_templates.yaml (descriptions), generated files.
Proof.
- Red: an update of a code-managed template succeeds.
- Green: every refusal and pass above.
- Local: upload a template, try a
PATCHwith a personal key, show the verbatim 403; release it,PATCHagain, it saves; upload again, it is managed by code again.
Acceptance criteria
- Server-side refusal on every write path except the allowed ones.
- Works whether or not feat(workflows): draft and publish edits to transactional email templates PostHog/posthog#118664 has landed.
- The refusal carries
why,fixanddocs_url.
How to build (map #461)
/tddred-green-refactor, outside-in, at the seams named above. Real Postgres, Valkey and ClickHouse; mock only what the seam section allows.- Repo skills apply where the diff touches their area:
/writing-tests,/improving-drf-endpoints,/django-migrations,/adding-api-scopes,/implementing-mcp-tools,/writing-ui-components,/writing-kea-logics,/writing-user-facing-copy,/writing-code-comments. - Read code from
upstream/master(PostHog/posthog). The fork'smasteris stale. - Public repo: no usage numbers, customer names, internal metrics, Slack content or security reasoning in code, commits or the PR. Fixture addresses use
example.com. The private note the map points at is background only; never quote it. - Shipped means a draft PR handed off for review (see the map's Notes for remotes and branches). Never mark ready, enqueue or merge.
Activity
Silthus commentedon Oct 16, 2026
Runtime: T3 child task
Task:
node:delegated-task:command%3Amcp%3A955e36b3-3986-469c-b816-09cf656780af%3Adelegate-task%3Awf-email-impl-491-1Cast: Opus 5.5, high. Cap 180 minutes. Lands on
spec/code-managed-templates.Silthus commentedon Oct 16, 2026
Resolution
Landed:
bfe29ccc10donspec/code-managed-templates(fast-forward65d441ac6e9..bfe29ccc10d, one commit).Branch:
spec/code-managed-templates/491. No PR: the ship ticket #498 opens it.What landed
products/messaging/backend/services/template_ownership.py. Onmanaged_by = codeit refuses every write except the actions it names:upload,send,make_transactional, and a release. It keys on the action and the whole payload, so a write action added later is refused until someone allows it.check_object_permissionsruns the policy on every non-safe request that reads the template. So update, partial update, soft delete (PATCH {"deleted": true}) anddesignare refused before body validation.save_templateruns it again on the row it locks. An upload that claims the template between the first read and the lock still wins. The upload has its own path (save_locked_template), so it never meets the check.403with{type: "invalid_request", code: "immutable", detail, attr: null, why, fix, docs_url}.fixnames the file as far as the template records it:`path` in `repository`,`path`,the template's file in `repository`, or "the template's file". The view renders it in onehandle_exceptionbranch.PATCHwhose only field ismanaged_by: "gui"setsmanaged_by = guiunder the row lock, withupdate_fields. It writes no workflow version and does not change the content. It keeps the key, so the next upload claims the template again. It needs the same workflow editor access as an update. Project secret API keys still can't reachpartial_update.save_template_fieldsandsave_template_contenttake anintent: TemplateWriteIntent(action and payload), so every save says what it is. Also new:check_template_write,is_releaseandrelease_template.TemplateWriteIntentandTemplateManagedByCodeare infacade/contracts.py.update,partial_updateanddesigndeclare the403response.managed_by'shelp_textexplains the lock and the release.TemplateUploadErrorSerializeris renamedTemplateErrorSerializer(andUploadRefusedis renamedTemplateRefusal), because uploads and edits now share it. The generatedTemplateUploadErrorApibecomesTemplateErrorApi, and nothing outside generated files used it.workflows-update-email-templateandworkflows-patch-email-templatedescriptions say a code-managed template answers403 immutableand changes throughworkflows-upload-email-template. Regenerated withhogli build:openapi.Proof
Red (the new test file run before the change):
11 failed, 1 passed. Every refusal case fails because the update succeeds, for example the MCP-style call:The release test fails on
managed_by: it stays'code'because the field was read-only. The one test that passes is the allowed-actions guard (send and make transactional), which also passes after the change.Green:
test_code_managed_template_lock.pyhas12 passed. The wholeproducts/messaging/backendsuite has343 passed, nothing skipped.PUT,PATCHname,PATCH deleted,PATCH managed_by + name,PATCH design/immutable; name, hash and workflow version unchangedx-posthog-client: mcpPATCHnameimmutablefixwith full source, path only, no sourcephs_key,make_transactionalguiwith key kept, 200,updatedandcodeagain, 403Mutation: removing the locked re-check in
save_templatefailstest_an_upload_that_claims_the_template_mid_request_still_refuses_the_edit(1 failed, 11 passed).Local gate on the landed tree:
hogli ci:preflight --strict --against origin/spec/code-managed-templates: 0 failures. The pre-push hook ran it again on the push: 0 failures.uv run mypy --cache-fine-grained .:Success: no issues found in 24086 source files.tsgo --noEmit --checkers 1infrontend/: exit 0, no errors.pnpm --filter=@posthog/mcp run typecheck: exit 0.lint-tool-names: pass. Tool schema snapshot test: 3 passed.hogli build:openapi: exit 0, regenerated output committed.Local proof. Django ran from this worktree on a scratch database cloned from a migrated test database, with a personal API key (
hog_flow:write) and a verified default sender. A wrapper forced onlyworkflows-transactional-email-apion. Verbatim:curl transcript
Step 3 printed only the selected fields. Step 4 is the same call shape and shows the 200. The upload in step 5 answers
updatedbecause the release left the name and then the edit changed it.Calls made
APIBaseTest, CDP mocked atinternal_requests.post) and one MCP-style personal key call. The upload pass is covered by the release flow (re-upload of a released template) and the existing upload tests, which upload into code-managed templates.managed_bytocoderight afterdangerously_get_objectreturns, the moment between the permission check and the row lock, as the prior art'stest_hog_flow_code_managed.pydoes. A real two-transaction race would needTransactionTestCase.invalid_request, not DRF'sauthentication_error, because the refusal is about the row, not the caller. This follows the prior art.{"managed_by": "gui"}onpartial_update, so a form spread into aPATCHnever releases by accident. On a template that is not managed by code it changes nothing and answers 200. Before, the same body ran a full save and wrote a workflow version.managed_bystays read-only in the serializer, so{"managed_by": "code"}is still ignored on a GUI template and refused on a code-managed one.managed_by(the field is read-only), so its description does not tell agents to release. The UI release lands with Email templates: read-only view of code-managed templates #492.designorder: the route now reads the template before validating the operations, so a code-managed template answers 403 before any 400 about the ops.rewrite_template_content(the asset URL rewrite management command) is an operator tool, not an edit path, so it stays as it is. Workflow edits stay possible, as the map records.test_a_publish_moves_the_replaced_hash_to_the_superseded_listedited a code-managed template. It now releases the template first, then edits it.Gaps
updateandpartial_updatewhatever the fields, so a draft-onlyPATCHis refused once feat(workflows): draft and publish edits to transactional email templates PostHog/posthog#118664 lands, without new code. There is no test for it until then.Follow-ups
ALLOWED_ACTIONSinservices/template_ownership.pyonly if it routes throughcheck_object_permissionson this viewset. A separateMessagingSendViewSetdoes not meet the policy. Expect a merge inmessage_templates.py(theUploadRefused→TemplateRefusalandTemplateUploadErrorSerializer→TemplateErrorSerializerrenames) and in the generated files. Regenerate the generated files instead of merging them by hand.PATCH {"key": ...}) is an update, so it is refused on code-managed templates. That is consistent: a code-managed template already has a key.desktopAPI client was not regenerated locally (generator not installed). Backend CI regenerates it.