Skip to content

feat(messaging): upload, send and lock code-managed email templates - #118924

Closed
Silthus wants to merge 17 commits into
PostHog:masterfrom
Silthus:spec/code-managed-templates
Closed

Silthus wants to merge 17 commits into
PostHog:masterfrom
Silthus:spec/code-managed-templates

Conversation

@Silthus

@Silthus Silthus commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Note

Carries the migration from #118769 (key, owner, source and hash columns), renumbered 0009 and 0010 after master's template drafts (0007) and sensitive flag (0008). Rebased onto master on 2026-10-21, twice: master gained template tags while the first rebase ran its gates. #118769 alone now conflicts with master; this PR replaces it.

Problem

Engineers, and the coding agents working for them, keep transactional emails (welcome, password reset) as HTML files in their repository and want PostHog to send them (spec).

  • Master sends only templates made in the PostHog UI, addressed by a UUID that differs per project.
  • A project secret API key (phs_) can send but cannot create or update a template, so code cannot upload one.
  • Nothing marks a template as owned by code, so an edit in PostHog and the next change from code overwrite each other.

Merge Danger

Caution

🔴 Merge risk: high · one-way door: the new columns and index stay after a revert (merge-risk rubric)

  • Every template save in the UI, flag or not, now runs through one shared save-and-sync service (services/template_saves.py) that also writes the content hash and refuses edits to code-managed templates. A shadow diff at 756474ff699 over 14 ordinary edits shows identical responses and workflow versions. It ran no draft save and no tags save, which also go through that service.
  • Two actions open to phs_ keys with hog_flow:write: upload by key and the new send route. Both answer a plain 404 while workflows-transactional-email-api is off. With the flag on, the API tab of every transactional template shows the new send route.
  • Migrations 0009 (nullable columns) and 0010 (a concurrent unique (team, key) index) stay after a revert. The routes, the v1: hash format and the error codes become a public contract once the flag rolls out and posthog-node ships emails.send.

Changes

Kind Files Lines
Production 38 +2796 −489
Tests 11 +1627 −27
Generated types and snapshots 12 +1134 −38
Total (this layer) 61 +5557 −554

Each ticket in reading order, with its commits. The commits come from this PR's history before the review split it into three parts, so they are not ancestors of this branch. This branch carries them as ab6174d3f9d, 29b841ca21b and 745b6a388ee, each followed by its review fixes.

Ticket What a caller or member sees Commits
#487 Upload by key PUT /messaging_templates/by_key/{key}/ creates (201), updates or answers unchanged (200). The first upload makes the template transactional with the default verified sender. Every upload keeps the stored sender and reply-to. a7ef8ccf4d9, 459a9fea1ce, 98c0ca25f73
#491 Server lock Edits, design and soft delete of a code-managed template answer 403 immutable with why, fix and docs_url. PATCH {"managed_by": "gui"} releases it. 84d4cd46c1d
#488 Send route POST /messaging/send/email/ names the template as {key, hash?} or {id}. An unknown hash answers 409 template_outdated. A replaced hash sends the current content with a template_superseded warning. 8120cbf7ce1
#489 Library UI A "Managed by code" tag, the source file line, the key, a notice, and a send-by-key API example. The API tab of every transactional template shows POST /messaging/send/email/ with template.id, or template.key for a template with a key. f772d0d38f1
#490 Adoption A PATCH whose only field is key gives a template made in PostHog a key. The next upload takes it over with the same UUID and workflow. 0c2a8d86106, 0dae40b0e8c
#492 Read-only view A code-managed template shows a read-only preview, no Save, a disabled Delete, Duplicate, and "Stop managing in code". 39fa349c482
#499 Upload race if_not_superseded: true makes an upload of replaced content write nothing and answer superseded. posthog-node sets it on its automatic upload. ae5f26ff1de
#508 Claimed back while editing When an upload claims back a released template mid-edit, the 403 immutable of a save, draft autosave or draft discard locks the page, reloads it read-only with the published content, and offers "Duplicate with my changes". Leaving the page first asks. Closing the offer discards the edits. 62794d871e4, bae2da8f375, 86ddda560fc, 9589874b059
Review fixes See "Review" below. 449e7f0b780, 49c271e66e0, 99f3cfb0d2e
Rebase onto master See "Rebased onto master" below. 2c9d949f146, 89a2a8fb665, 74fc42607df, a1b3edb883e, 4845efd98a9, 99163db3f9d, 8932b7657c6
  • One save path. The locked save-and-sync block moved out of the view into services/template_saves.py. Update, design and upload all call it, so every content save writes the hash.
  • Content hash. SHA-256 over the length-prefixed subject, preheader, HTML and text, stored as v1:<hex>. Replaced hashes move to superseded_content_hashes (at most 100).
  • Upload keeps the sender. An upload replaces only the subject, preheader, HTML, text and design. An adopted template keeps its from and replyTo, so the workflow sync no longer writes them empty.
  • Refused upload names the sender. When the template has its own sender and that sender is not verified, the refusal says "The template's email sender isn't verified." It no longer blames the project default (5b45b9b3b51).
  • Refused edits. While edits refused by an upload wait, the banner, menu and Quill Duplicate actions are disabled or hidden, so only "Duplicate with my changes" copies. A second refusal keeps the edits the first one rescued, a refused discard stores nothing, and a release puts the waiting edits back in the form (33147f4be18, d83bc5d7611, 4b0ca4756ed).
  • Body that is not an object. A JSON array or string body on the upload or the send answers 400 invalid_request with attr: null and the fix "Send the body as a JSON object and upload again." (or "send again").
  • Rolling deploys. A send never puts old content back. The workflow holds one copy of the content, so an old hash is either refused or answered with a warning.
  • MCP. workflows-upload-email-template and workflows-send-email are generated from the new serializers and gated by the same flag.
  • Mechanical: regenerated OpenAPI types, MCP tool definitions and snapshots.

Rebased onto master

master gained template drafts (#118664), the sensitive flag, an Idempotency-Key on the per-template send, and template tags (#120931) while this PR waited. The rebase keeps all four:

  • Drafts. An upload publishes, so it now clears a draft made in PostHog (89a2a8fb665). The lock refuses a draft-only PATCH on a code-managed template, like every other edit, and checks ownership and workflow access again under the row lock. So an upload that claims the template mid-request can't get a stale draft written back, and a workflow linked mid-request still needs editor access (74fc42607df, 4845efd98a9, 8932b7657c6). So a code-managed template never holds a draft, and its read-only preview and tags need no draft handling.
  • Sensitive. A code-managed template takes a PATCH of only sensitive, like a send. The setting protects what PostHog keeps about each email and is not part of the file (4845efd98a9).
  • Tags. master's template tags (#120931) save inside the locked save when a form carries them, and a tags-only PATCH keeps master's narrow path. A code-managed template takes a PATCH of only tags, like sensitive: tags organize the library and are not part of the file (756474ff699).
  • Quill actions behind today-rail-nav offer "Stop managing in code", disable Delete and hide "Save as new template" on a code-managed template, like the current menu (99163db3f9d). Duplicate is disabled while its copy is made (e26bad4d07f).
  • One autosave listener. The code-managed reset and master's draft autosave share one setTemplateValue/setTemplateValues listener (a1b3edb883e). Two listener pairs in one object kept only the second, which stopped the draft autosave.
  • Idempotency. POST /messaging_templates/{id}/send/ keeps master's Idempotency-Key and replayed. The new POST /messaging/send/email/ answers replayed: false and takes no key yet: the key arrives with the stacked send command.
  • Migrations renumbered 0009_messagetemplate_code_managed_fields and 0010_messagetemplate_unique_key_per_team.

Screenshots

UI walk at 4b0ca4756ed, in the order a member meets it: a production frontend build on a preview stack, the demo project, LemonUI, and the today-rail-nav variant for the last two. All eight stages ran in both variants (38 screenshots, 13 shown), and each matched apart from the two items in Decisions. No recording was made: gap.

1. The library tags Weekly digest "Managed by code". The GUI template and the legacy template carry no tag. 2. Opening it shows a read-only preview with the key, source links and an info banner.
The library tags Weekly digest "Managed by code". The GUI template and the legacy template carry no tag Opening it shows a read-only preview with the key, source links and an info banner
3. The API tab of a keyed template sends with template.key. 4. The API tab of a GUI template sends with template.id.
The API tab of a keyed template sends with template.key The API tab of a GUI template sends with template.id
5. "Stop managing in code" asks first and names templates/weekly-digest.html. 6. After the release the editor is editable, the tag and banner are gone, and the key stays.
"Stop managing in code" asks first and names templates/weekly-digest.html After the release the editor is editable, the tag and banner are gone, and the key stays
7. An upload claims it back mid-edit. The next keystroke shows the refused-edits banner. The info banner stays below it without Duplicate. 8. While the refused edits wait, menu Duplicate is disabled and points to "Duplicate with my changes".
An upload claims it back mid-edit. The next keystroke shows the refused-edits banner. The info banner stays below it without Duplicate While the refused edits wait, menu Duplicate is disabled and points to "Duplicate with my changes"
9. "Duplicate with my changes" opens an editable copy that holds the refused subject. 10. Released, edited again and claimed again: the copy holds the second edits, not the first.
"Duplicate with my changes" opens an editable copy that holds the refused subject Released, edited again and claimed again: the copy holds the second edits, not the first
11. A legacy template with no managed_by stays a normal editable template. 12. With today-rail-nav, the library tags only the code templates.
A legacy template with no managed_by stays a normal editable template With today-rail-nav, the library tags only the code templates
13. With today-rail-nav, an upload mid-edit shows the same refused-edits banner.
With today-rail-nav, an upload mid-edit shows the same refused-edits banner
Earlier Storybook evidence (mocked data, at the commits named)

Storybook renders of the real scene against mocked data (example.com repository, invented copy).

Library card Code-managed template
Library card with the Managed by code tag Read-only preview with key, source line and notice
Stop managing in code Duplicate (editable copy)
Release confirmation Editable copy without key or tag
Claimed back while editing (#508)
Read-only template with a warning: your changes weren't saved, Duplicate with my changes

The read-only preview's field labels (Subject, Preheader, HTML, Plain text) now use LemonField.Pure, the label of other Lemon forms. Rendered on 89f57b2cd3e and its parent at 560px (Storybook, mocked data); 1440px before, 1440px after:

Before: small muted labels After: Lemon form labels
Read-only preview with small muted labels Read-only preview with Lemon form labels

More states, before and after, at 560px and 1440px: #489 screenshots, #492 screenshots, #508 at 775px. The #508 render shows Storybook's own mock toasts in the corner.

Review fixes, rendered on 07d7e0ee544 at 560px (Storybook, mocked data):

A 90-character key truncates and keeps its copy button Duplicate while its request runs
Long key with an ellipsis and the copy button Duplicate with a spinner

Quill actions behind today-rail-nav on a code-managed template, rendered on 756474ff699 at 1280px (Storybook, mocked data). In "Before", only the actions menu is master's:

Before (master) After
Before: Save as new template, Duplicate and an enabled Delete After: Duplicate, Stop managing in code, and Delete disabled with its reason

A target="_self" link in the preview opened a new tab, and the preview stayed: after the click.

How did you test this code?

Test rationale: each ticket drove its slice at the template and send API (APIBaseTest with real phs_ and personal keys, CDP mocked at internal_requests.post), plus messageTemplateLogic kea tests for the UI. The hash has one unit test against the spec's test vectors, shared with posthog-node.

Test file Regression it catches
test_template_upload.py an upload that duplicates on a race, rewrites unchanged content, keeps a stale hash, changes the sender or status, drops the stored sender or reply-to, refuses a list body without a fix, or republishes a replaced hash
test_transactional_email.py (extended) a send route or upload without the flag, scope, throttle or workflow access check, a key with a trailing newline, a send body that is not a JSON object, or a hash rule that sends outdated content
test_code_managed_template_lock.py a write path that edits a code-managed template, including an upload that claims it mid-request
test_template_adoption.py a key that changes, is set with other fields, has a trailing newline, or is set by a phs_ key
test_content_hash.py a hash that differs from posthog-node's for the same content
messageTemplateLogic.test.ts a code-managed template that saves, a duplicate that keeps the key, a release that stays read-only, a save that sends server-owned fields, a refused save, draft autosave or draft discard that leaves the page editable or still unsaved (also when its reload fails), a navigation that drops refused edits without asking, a duplicate of refused edits that drops them or carries ownership fields
MessageTemplateMenu.test.tsx a "Stop managing in code" menu whose Cancel still releases, or whose confirm does not
EmailHtmlFrame.test.ts, gitMetadataParser.spec.ts preview links that replace the preview, wrong Bitbucket or GitLab subgroup links

Proofs

Proof Result
Live run At 987b927630e, the top of the stack this PR starts, which holds this head unchanged. Django, cdp-api and the email worker ran on a migrated clone of the local test database, with maildev catching mail. A template seeded with the key password-reset sent through POST messaging/send/email/ with template.key. It answered 202 and arrived with a tracked link (case 02).
UI walk At this head, both navigation variants: library, read-only preview, API tab, release, edit, upload claims it back, refused-edits actions, release again, and a legacy template. Uploads used PUT messaging_templates/by_key/<key>/ with a personal API key. Every stage matched, apart from the two items in Decisions below. Screenshots above.
Earlier live run At 756474ff699, before the review split. It covers paths the final runs did not: data written by master code, adoption (403 with phs_), template_superseded, 409 template_outdated, if_not_superseded, the stale hash repair, tags-only and sensitive-only PATCH, draft save 403, and flag off 404. Transcript below.
Shadow diff At 756474ff699 only. Base e23fa706bd0 and that head ran the same 14 ordinary template edits (create, patch, put, design, make transactional, sendability refusals, soft delete), flag on and off, each on a fresh database. Normalized responses are identical. Both end at workflow version 2 with the same two revisions. Gap: the input set had no draft save and no tags save, and the diff did not run again at this head.
Local gate At 987b927630e, all green: pytest 1764, CDP jest 3690 (1 skipped), cdp-api serial 288, frontend jest 84, ClickHouse 460, MCP 5035, repo-wide mypy, tsc and the frontend typecheck. The pre-push preflight ran in strict mode with 0 failures. makemigrations messaging --check at ab6174d3f9d (part 1) finds no missing migration. Each review fix went red first.
Prod shape Gap, accepted until read-only production access arrives on 2026-11-02. Unknown: the share of stored templates whose content shape the hash recording meets for the first time, and how long the concurrent index build takes on the largest template table.
Cross-version Not owed. The hog flow rows and the CDP payload keep their format.
Earlier live run transcript on 756474ff699, before the review split (condensed; keys masked)
### master code (e23fa706bd0) writes a template, makes it transactional, edits it
POST   /messaging_templates/                                   201
POST   /messaging_templates/{id}/make_transactional/           200
PATCH  /messaging_templates/{id}/                              200   key, managed_by, content_hash all null

### this branch, on that template
POST   /messaging/send/email/ {"template":{"id":…}}            202   {"warnings":[]}
PATCH  /messaging_templates/{id}/ {"key":"order-shipped"} (phs_)  403  permission_denied
PATCH  /messaging_templates/{id}/ {"key":"order-shipped"} (phx_)  200  key set, managed_by null
PUT    /messaging_templates/by_key/order-shipped/ (phs_)       200   result "updated", same id and workflow, managed_by "code"
POST   /messaging/send/email/ {"template":{"key":"order-shipped","hash":"v1:59f9…762c"}}  202  {"warnings":[]}

### this branch, new key
PUT    by_key/welcome/                                         201   created, v1:f74c…d2e6
PUT    by_key/welcome/ (same)                                  200   unchanged
PUT    by_key/welcome/ (new subject)                           200   updated, v1:2465…da58
POST   send/email/ {"key":"welcome","hash":"v1:f74c…d2e6"}     202   template_superseded
POST   send/email/ {"key":"welcome","hash":"v1:2465…da58"}     202   {"warnings":[]}
POST   send/email/ {"key":"welcome","hash":"v1:0000…0000"}     409   template_outdated
POST   send/email/ {"key":"welcome\n"}                        400   invalid_template_key; nothing queued by these two
PUT    by_key/welcome/ (old content, if_not_superseded)        200   superseded, content and ref unchanged
stored hash set back to the old hash (psql), then PUT by_key/welcome/   200  updated, hash repaired; send with it 202
PATCH  /messaging_templates/{id}/ {"name":…}                   403   immutable, fix names emails/welcome.html
PATCH  /messaging_templates/{id}/ {"managed_by":"gui"}         200   released, key kept
PUT    by_key/welcome/                                         200   updated, managed_by "code" again
POST   /messaging_templates/ then PATCH {"key":"order\n"}      400   invalid_template_key; {"key":"order"} 200
PATCH  /messaging_templates/{id}/ {"tags":["onboarding"]}      200   tags ["onboarding"]
PATCH  /messaging_templates/{id}/ {"sensitive":true}           200
PATCH  /messaging_templates/{id}/ {"draft_content":…}          403   immutable
PATCH  /messaging_templates/{id}/ {"deleted":true}             403   immutable
POST   send/email/ {"key":"no-such-template"}                  404   template_not_found
flag off: PUT by_key/welcome/ 404, POST send/email/ 404, nothing queued
CDP stub received 5 sends, one per 202 above

Review

QA swarm before the rebase: two rounds with GPT-6.1 Sol (correctness, risk, design, systems, and the author's review perspective) and Opus 5.5 (fresh eyes, UI), then a final read of the last commit. Round 1 found 20 items: 13 fixed, 4 follow-ups, 3 rejected. Round 2 found 7 items in the fixes, all fixed. The final read found none. The fixes:

  • An upload checks workflow editor access before it answers unchanged or superseded.
  • An upload repairs a stored hash that does not describe the content, so a save that skipped the hash no longer loops on template_outdated.
  • Keys validate as whole strings, and the send no longer trims the key.
  • The PATCH schema declares the release. The MCP update tool leaves it out.
  • The shared transactional email API pieces have their own module. One PATCH matcher, one refusal base and one usage capture replace the pairs two tickets each built.
  • Editor saves stop sending server-owned fields. Duplicate shows a loading state. Preview links open in a new tab, even with their own target. A long key truncates. Bitbucket and GitLab subgroup links are right.
  • An upload refused for missing workflow access still reports its usage event.

#508 added two commits after that, reviewed by GPT-6.1 Sol in two more final reads. The first found 2 medium items, both fixed in d746c355e26: a failed reload after the refusal left the page editable, and a duplicate navigated after the person left the page. The second found none. #508 changes only the template page, so the live run and shadow diff from 07d7e0ee544 still apply, and fleet proof gate passes on d746c355e26.

Follow-ups, outside this PR: the asset URL rewrite command writes content without the hash, the lock or the workflow sync (the sync gap predates this PR); the global loader toast repeats every failed template save next to the page's own message (it predates this PR, and the fix is one entry in frontend/src/initKea.ts).

Resolved: the release-then-reupload race is #508.

The final QA swarm split this PR into three parts by layer (fork PRs #547, #548, #549) and reviewed each one.

  • Round 1 fixed an upload that dropped the stored sender and reply-to, an empty attr for a body that is not an object, a refused draft save that left the page editable, a double Quill Duplicate, a banner Duplicate that dropped refused edits, and a leave without asking.
  • Round 1 rejected one: PATCH {"managed_by": "code"} answers 200 and changes nothing, because DRF ignores a read-only field.
  • Round 2 fixed the refused upload's sender message and four refused-edits gaps. Final read 1 found one more: refused edits across a release. Final read 2 found nothing.

The rebase onto master got its own read by GPT-6.1 Sol, then a final read of the fixes. It found 3 medium items, all from the meeting of master's drafts, sensitive flag and Quill actions with the lock. The final read found the draft fix incomplete: the workflow access check still used the read before the lock. All 4 are fixed (see "Rebased onto master"). Two more final reads followed the second rebase (master's template tags) and a one-line import-order fix. Neither found anything.

Release status

  • This change is behind a feature flag and is not available to users

Docs update

The beta docs page /docs/workflows/transactional-email-api ships separately on posthog.com (Silthus/posthog#475). Every new error's docs_url points at its anchor.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, Claude Opus 5.5 (claude-opus-5-5) wrote the code and the fixes. Reviews by GPT-6.1 Sol and Claude Opus 5.5.

  • Built ticket by ticket on the integration branch spec/code-managed-templates of the fork, each with /tdd. Skills: /improving-drf-endpoints, /implementing-mcp-tools, /adding-api-scopes, /writing-tests, /writing-ui-components, /writing-kea-logics, /writing-user-facing-copy, /writing-code-comments, /writing-pr-descriptions, qa-swarm.
  • No duplicate: the related open PRs are feat(messaging): add key, owner, source and hash columns to templates #118769 (the migration this stacks on) and feat(workflows): draft and publish edits to transactional email templates #118664 (drafts). Neither adds these routes.
  • Public artifact: fixtures and screenshots use invented data on example.com.
  • CodeRabbit is retired in this repository, so no CodeRabbit pass ran. CI on this fork PR waits for a maintainer to approve the workflow runs.

Decisions

Question Answer Who Where
What does code send email through? The upstream transactional email API, not our parallel /emails route 👤 Human map #461
What does this feature add? Upload a template file from code; the library shows it read-only as code-managed 👤 Human map #461
Which route sends a template? One POST …/messaging/send/email/ that names the template in the body, instead of the recommended …/by_key/{key}/send/ 👤 Human spec answers
How does a send stay in step with the file? The SDK sends a content hash, so no upload or CI step is needed, instead of the recommended separate upload step 👤 Human spec answers
How is prod shape proven? Recorded as a gap until production read access exists (standing instruction) 👤 Human #498
Which fields mark a code-managed template? key, managed_by and source_* on the message template 👍 Accepted default map #461
How does code upload a template? An idempotent upsert by key over REST and an MCP tool, no CLI 👍 Accepted default map #461
What does an upload publish? The content at once; the first upload makes the template transactional 👍 Accepted default map #461
When does the lock land? After the first slice, together with the read-only view 👍 Accepted default map #461
What does the lock cover? The template only; the linked workflow stays editable, a known gap 👍 Accepted default map #461
How is an existing template adopted? Set its key once through PATCH 👍 Accepted default spec answers
What happens to a deleted template's key? It is released 👍 Accepted default spec answers
What does a send with a superseded hash deliver? The current content, with a template_superseded warning 👍 Accepted default spec handoff
What happens to unsaved edits when an upload claims a released template back? The page reloads read-only and offers "Duplicate with my changes"; closing the offer discards them 👍 Accepted default map #461
Which auth, flag and throttles apply? The existing hog_flow:write, flag, throttles and send error codes 🤖 Agent spec #485
When is an upload "unchanged"? On an exact match of the stored fields; an upload never reactivates a workflow or changes the sender 🤖 Agent spec #485
How is a template released? A PATCH with only managed_by: "gui"; the next upload claims it back, and the lock refuses soft delete 🤖 Agent spec #485
Does the SDK retry a send? Only after template_outdated; it has no idempotency option 🤖 Agent spec #485
May a send name both hash and id? No, 400; accepting it later is additive 🤖 Agent #488
Can an automatic upload republish a superseded hash? No, if_not_superseded: a new ticket after the posthog-js review found the race 🤖 Agent #499
Where is a stale stored hash repaired? At upload, not by hashing live on every send 🤖 Agent #498
Can agents unlock a template? No; the release is in the PATCH schema but not in the MCP update tool 🤖 Agent #498
Where do the shared transactional email API pieces live? In their own module, moved in this PR 🤖 Agent #498
Does the asset URL rewrite command go through the shared save path? Not in this PR; recording only its hash would describe content the workflow does not send 🤖 Agent #498
Do existing send errors gain why/fix/docs_url? No, they keep their bodies, as the spec says 🤖 Agent #498
When does a refused save lock the page? At once, before the reload, so a failed reload can't leave it editable 🤖 Agent #508
How does the rebase meet master's drafts? An upload clears a draft and the lock refuses a draft save, so a code-managed template never holds one 🤖 Agent #535 comment
How is the stack unblocked from master? Rebase all four PRs onto master, recommended over a manual rebase or a fifth layer 👍 Accepted default #535
Does the lock refuse the sensitive setting? No; a PATCH of only sensitive passes, because the setting is not part of the file 🤖 Agent #535 comment
Does the lock refuse tags? No; a PATCH of only tags passes, because tags organize the library and are not part of the file 🤖 Agent #535 comment
How risky is the merge? High; the review's risk lens raised the first medium grade, so a shadow diff ran 🤖 Agent #498
Is a cross-version proof owed? No 🤖 Agent #498

| While refused edits wait, does the code-managed info banner stay? | Yes, without its Duplicate action, under the refused-edits banner. Its text stays true. The alternative is to hide it while edits wait (UI walk UW-1) | 🤖 Agent | pending approval |
| Should the API tab of a keyed template show template.id? | No, rejected: it shows template.key, which is the same in every project, and a GUI template shows its id (UI walk UW-2) | 🤖 Agent | pending approval |

Open: the rewrite_email_asset_url follow-up (➡️ route it through the shared save path). See the map status.

Open: should managed_by default to gui instead of null for a template made in PostHog? It changes stored data and the documented "gui or null" read contract (➡️ keep null; a later backfill to gui is additive).

Open: should the read-only preview show the sender? The editor shows From and Reply-to, the preview does not (➡️ yes, in this PR: a read-only sender row from EmailSenderSummary, because the sender is set only in PostHog).

Involvement: 3 grilling rounds answered (the re-chart and two spec rounds); 1 prototype iterated over 4 rounds; QA swarm before the split with 2 review rounds (GPT-6.1 Sol ×5 lenses, Claude Opus 5.5 ×2 lenses each) and 3 final reads (GPT-6.1 Sol); a rebase read and its final read (GPT-6.1 Sol). Final QA swarm over the three parts: design pass (GPT-6.1 Sol high); round 1 (review and team lanes GPT-6.1 Sol high, fresh eyes and UI Claude Opus 5.5 high); round 2 on parts 2 and 3 (review lane GPT-6.1 Sol high, fresh eyes and UI Claude Opus 5.5 high, team lane on part 3); final reads 1 and 2 (GPT-6.1 Sol high); fixers Claude Opus 5.5 high; UI walk (Claude Opus 5.5); live run at the stack top. CodeRabbit is retired in this repo.

Closes Silthus#485
Closes Silthus#487
Closes Silthus#488
Closes Silthus#489
Closes Silthus#490
Closes Silthus#491
Closes Silthus#492
Closes Silthus#499
Closes Silthus#508

🤖 Generated with Claude Code

@trunk-io

trunk-io Bot commented Oct 17, 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

This was referenced Oct 17, 2026
@Silthus
Silthus force-pushed the spec/code-managed-templates branch from 89f57b2 to f04e5eb Compare October 23, 2026 21:24
Silthus and others added 11 commits October 23, 2026 21:37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The template library marks a code-managed template with its source and
key, shows it read-only, and offers a release back to PostHog.

Part 3 of 3 of PostHog#118924.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Silthus
Silthus force-pushed the spec/code-managed-templates branch from f04e5eb to 4b0ca47 Compare October 24, 2026 17:28
@Silthus Silthus closed this Oct 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment