Skip to content

feat(tasks): link a Channel to the desktop-fs folder that renders it#72463

Closed
adboio wants to merge 2 commits into
masterfrom
posthog-code/channel-folder-fk
Closed

feat(tasks): link a Channel to the desktop-fs folder that renders it#72463
adboio wants to merge 2 commits into
masterfrom
posthog-code/channel-folder-fk

Conversation

@adboio

@adboio adboio commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Problem

A channel exists as two unrelated rows: the Channel model (feed, membership, Task.channel FK) and a desktop-surface FileSystem folder — the tree the desktop app actually renders, under whose path canvases and task filings nest. The only join between them is display-name matching, with the normalization function mirrored client- and server-side (normalize_channel_namenormalizeChannelName). Anything that needs to walk from a task to its channel's folder — e.g. agent-side canvas placement in PostHog/code#3594 — is stranded one id hop short, and ends up scraping filing-row path conventions or relaying channel names through the model.

Changes

  • Channel.folder FK → FileSystem (nullable, SET_NULL, db_constraint=False like the model's other FKs, one live channel per folder via a partial unique constraint).
  • Resolve-or-create (POST /task_channels/) accepts an optional folder_id; PATCH /task_channels/:id/ accepts folder_id too (personal channels included — rename stays public-only), so clients that own folder creation can link at creation and backfill lazily.
  • Claim semantics: first claim wins; a folder claimed by another live channel can't be stolen (the constraint rejects it, the request still succeeds unlinked); re-linking is allowed once the old folder row is gone; foreign-team / non-desktop / non-folder rows are ignored.
  • ChannelSerializer now returns folder_id, so any consumer resolves task → channel → folder purely by ids.

Follow-up in posthog/code: pass folder_id from the app's existing resolveTaskChannel/useBackendChannel seam, and switch the canvas tool's placement to this join (replacing the filing-row lookup).

How did you test this code?

  • pytest products/tasks/backend/tests/test_channels_api.py — 42 passed (8 new: link-on-resolve, first-claim-wins, exclusivity across channels, personal-channel PATCH link, foreign/non-desktop/non-folder rejection, relink after folder deletion, rename preserves link, empty PATCH 400).
  • pytest posthog/api/test/test_api_docs.py — schema generation passes with the new field.
  • Migration applied locally (additive nullable column + partial unique index on the small posthog_task_channel table).

Created with PostHog Code

A channel exists as two unrelated rows today: the Channel model (feed,
membership, Task.channel FK) and a desktop-surface FileSystem folder (the
tree the app renders — canvases and task filings nest under its path). The
only join between them is display-name matching, with the normalization
function mirrored client- and server-side. Anything that needs to go from a
task to the channel's folder (e.g. agent-side canvas placement) is stranded
one hop short, or has to scrape filing-row conventions.

Give Channel a folder FK (nullable, SET_NULL, db_constraint=False like the
model's other FKs, one live channel per folder via a partial unique
constraint). Clients that own folder creation pass folder_id on the
resolve-or-create POST, or PATCH it onto an existing channel (personal
included) to backfill lazily. Claim semantics: first claim wins, a claimed
folder can't be stolen, re-linking is allowed once the old folder row is
gone, and non-desktop / non-folder / foreign-team rows are ignored.

The task_channels serializer now returns folder_id, so any consumer can
resolve task -> channel -> folder purely by ids.

Generated-By: PostHog Code
Task-Id: 09832237-2f58-4ab7-adf6-231aeb82e1fe
@greptile-apps

greptile-apps Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Security Review

The new folder-link mutation checks team membership but not ownership of personal channels. A team member can change another member's personal-channel folder association if they know its UUID.

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
products/tasks/backend/presentation/views/channels_api.py:112
**Personal Channel Ownership Bypass**

Any team member who knows another member's personal-channel UUID can change its folder association. This call passes only `team_id`, and `link_channel_folder` does not check the channel's `created_by_id`, so the new personal-channel mutation bypasses the owner-specific boundary used when reading personal channels.

### Issue 2 of 3
products/tasks/backend/facade/api.py:5004-5009
**Failed Repair Returns Wrong Link**

When a channel contains a dangling folder ID and the requested replacement is already claimed, the save rolls back but this handler changes only the in-memory object to `None`. The response reports `folder_id: null` while the database still contains the old UUID, so a later read returns different state.

```suggestion
    channel.folder_id = folder_id
    try:
        with transaction.atomic():
            channel.save(update_fields=["folder", "updated_at"])
    except IntegrityError:
        channel.refresh_from_db(fields=["folder"])
```

### Issue 3 of 3
products/tasks/backend/presentation/serializers.py:1349-1351
**Create Schema Marks Name Optional**

This serializer is used for both POST and PATCH, so making `name` optional also marks it optional in the generated create schema. Generated clients can submit a schema-valid create request without a name, but `create()` always rejects that request with 400; separate create and update serializers are needed to keep the API contract accurate.

Reviews (1): Last reviewed commit: "feat(tasks): link a Channel to the deskt..." | Re-trigger Greptile

)
if folder_id is None:
return Response(ChannelSerializer(result).data)
linked = tasks_facade.link_channel_folder(pk, self.team_id, folder_id=folder_id)

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.

P1 security Personal Channel Ownership Bypass

Any team member who knows another member's personal-channel UUID can change its folder association. This call passes only team_id, and link_channel_folder does not check the channel's created_by_id, so the new personal-channel mutation bypasses the owner-specific boundary used when reading personal channels.

Rule Used: When implementing new features, ensure that owners... (source)

Learned From
PostHog/posthog#31236

Prompt To Fix With AI
This is a comment left during a code review.
Path: products/tasks/backend/presentation/views/channels_api.py
Line: 112

Comment:
**Personal Channel Ownership Bypass**

Any team member who knows another member's personal-channel UUID can change its folder association. This call passes only `team_id`, and `link_channel_folder` does not check the channel's `created_by_id`, so the new personal-channel mutation bypasses the owner-specific boundary used when reading personal channels.

**Rule Used:** When implementing new features, ensure that owners... ([source](https://app.greptile.com/posthog-org-19734/-/custom-context?memory=9655b466-451a-401a-9ba0-5bf3e7b7f9f8))

**Learned From**
[PostHog/posthog#31236](https://github.com/PostHog/posthog/pull/31236)

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +5004 to +5009
channel.folder_id = folder_id
try:
with transaction.atomic():
channel.save(update_fields=["folder", "updated_at"])
except IntegrityError:
channel.folder_id = None

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.

P1 Failed Repair Returns Wrong Link

When a channel contains a dangling folder ID and the requested replacement is already claimed, the save rolls back but this handler changes only the in-memory object to None. The response reports folder_id: null while the database still contains the old UUID, so a later read returns different state.

Suggested change
channel.folder_id = folder_id
try:
with transaction.atomic():
channel.save(update_fields=["folder", "updated_at"])
except IntegrityError:
channel.folder_id = None
channel.folder_id = folder_id
try:
with transaction.atomic():
channel.save(update_fields=["folder", "updated_at"])
except IntegrityError:
channel.refresh_from_db(fields=["folder"])
Prompt To Fix With AI
This is a comment left during a code review.
Path: products/tasks/backend/facade/api.py
Line: 5004-5009

Comment:
**Failed Repair Returns Wrong Link**

When a channel contains a dangling folder ID and the requested replacement is already claimed, the save rolls back but this handler changes only the in-memory object to `None`. The response reports `folder_id: null` while the database still contains the old UUID, so a later read returns different state.

```suggestion
    channel.folder_id = folder_id
    try:
        with transaction.atomic():
            channel.save(update_fields=["folder", "updated_at"])
    except IntegrityError:
        channel.refresh_from_db(fields=["folder"])
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 1349 to +1351
name = serializers.CharField(
max_length=128, help_text="Channel name, rendered as #<name>. Normalized to lowercase-dashed."
max_length=128,
required=False,

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.

P2 Create Schema Marks Name Optional

This serializer is used for both POST and PATCH, so making name optional also marks it optional in the generated create schema. Generated clients can submit a schema-valid create request without a name, but create() always rejects that request with 400; separate create and update serializers are needed to keep the API contract accurate.

Context Used: docs/published/handbook/engineering/type-system.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: products/tasks/backend/presentation/serializers.py
Line: 1349-1351

Comment:
**Create Schema Marks Name Optional**

This serializer is used for both POST and PATCH, so making `name` optional also marks it optional in the generated create schema. Generated clients can submit a schema-valid create request without a name, but `create()` always rejects that request with 400; separate create and update serializers are needed to keep the API contract accurate.

**Context Used:** docs/published/handbook/engineering/type-system.md ([source](https://app.greptile.com/posthog-org-19734/github/PostHog/posthog/-/custom-context?memory=2d5b82d8-8608-4823-8983-4faaa9415b96))

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +5008 to +5009
except IntegrityError:
channel.folder_id = None

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.

After an IntegrityError, the in-memory channel object has stale data. The transaction was rolled back, so the database still has the original folder_id value, but the in-memory object now has folder_id = None. When the caller link_channel_folder (line 5019) returns _channel_to_dto(channel), it will return incorrect data showing folder_id as None when the database actually has a different value.

Fix by refreshing from the database:

except IntegrityError:
    channel.refresh_from_db()

This same bug affects line 5103 where resolve_channel calls _link_channel_folder and then returns the DTO without refreshing.

Suggested change
except IntegrityError:
channel.folder_id = None
except IntegrityError:
channel.refresh_from_db()

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

🦔 Hogbox preview · ✅ ready

▶ Open the preview

🔑 Login test@posthog.com / 12345678 (demo data)
🧩 Running this PR's backend and frontend, on the PostHog :master base
🔗 Link stable across rebuilds — a re-push swaps the box underneath, the URL stays
🔒 Access tailnet only (PostHog VPN)
🛠️ Admin inspect & debug state in hogland
💤 Idle sleeps after ~30 min idle (snapshot to S3, zero node cost) and wakes on your next visit in ~30s, behind a brief "waking up" screen

commit 2be8198 · box box-387213d5d8d8 · ready in 745s (push → usable) · build log · rebuilds on every push, torn down on close

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

🤖 CI report

Bundle size — no change

Uncompressed size of every built .js bundle, compared against the base branch.

Total: 64.19 MiB · no change

No file changed by more than 1000 B.

Posted automatically by build-bundle-size-report · uncompressed bytes from dist-report

Eager graph — within budget

How much code each root ships on the eager path — downloaded and parsed before the surface is interactive. Measured from the esbuild output chunks (post-tree-shake, static imports only); lazy import() / React.lazy chunks are not counted.

Root Eager (shipped) Δ vs base Budget
entry (logged-out pages, app bootstrap)
src/index.tsx
1.23 MiB · 22 files no change ███░░░░░░░ 28.6% of 4.29 MiB
authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
8.18 MiB · 2,996 files no change █████████░ 88.4% of 9.25 MiB

🟢 node_modules/monaco-editor/ stays out of src/index.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 node_modules/monaco-editor/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx

Largest files eagerly shipped from src/index.tsx
Size File
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
24.6 KiB ../node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.js
6.3 KiB ../node_modules/.pnpm/react@18.3.1/node_modules/react/cjs/react.production.min.js
4.5 KiB ../node_modules/.pnpm/@jspm+core@2.1.0/node_modules/@jspm/core/nodelibs/browser/process.js
3.9 KiB ../node_modules/.pnpm/scheduler@0.23.2/node_modules/scheduler/cjs/scheduler.production.min.js
1.4 KiB ../node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.js
1.3 KiB src/RootErrorBoundary.tsx
912 B ../node_modules/.pnpm/ieee754@1.2.1/node_modules/ieee754/index.js
789 B src/scenes/ChunkLoadErrorBoundary.tsx
762 B src/index.tsx
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
Size File
281.3 KiB ../node_modules/.pnpm/posthog-js@1.404.1/node_modules/posthog-js/dist/rrweb.js
267.7 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
235.8 KiB src/taxonomy/core-filter-definitions-by-group.json
223.4 KiB ../node_modules/.pnpm/posthog-js@1.404.1/node_modules/posthog-js/dist/module.js
167.1 KiB src/queries/validators.js
154.3 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
105.9 KiB src/lib/api.ts
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
93.2 KiB ../packages/quill/packages/quill/dist/index.js

Posted automatically by check-eager-graph · sizes are eager output bytes (shipped, post-tree-shake) from the esbuild metafile · part of #32479

Dist folder size — 🔺 +1.5 KiB (+0.0%)

Total size of the built frontend/dist folder (all assets), compared against the base branch.

Total: 1349.19 MiB · 🔺 +1.5 KiB (+0.0%)

⚠️ Backend coverage — 94.0% of changed backend lines covered — 6 uncovered

🧪 Backend test coverage

Patch coverage — changed backend lines (products + core): ███████████████████░ 94.0% (104 / 110)

File Patch Uncovered changed lines
products/tasks/backend/presentation/views/channels_api.py 82.6% 71, 101, 105, 107
products/tasks/backend/facade/api.py 92.0% 4996, 5017

🤖 Agents: add a test covering the lines above, or note why under "How did you test this code?". Machine-readable gap list: the patch-coverage artifact on this run (gh run download 29789077554 -n patch-coverage), or the coverage-data block at the end of this comment.

Per-product line coverage (touched products)
Product Coverage Lines
demo ███████████░░░░░░░░░ 56.2% 1,497 / 2,663
tasks █████████████░░░░░░░ 66.3% 26,426 / 39,851
signals ████████████████░░░░ 79.7% 19,751 / 24,782
data_modeling ████████████████░░░░ 80.0% 4,834 / 6,045
cdp ████████████████░░░░ 80.7% 3,118 / 3,864
notebooks █████████████████░░░ 85.0% 7,101 / 8,355
cohorts █████████████████░░░ 86.2% 4,063 / 4,715
agent_platform █████████████████░░░ 86.4% 3,807 / 4,405
actions █████████████████░░░ 86.6% 717 / 828
product_tours █████████████████░░░ 87.5% 1,266 / 1,447
exports ██████████████████░░ 88.4% 6,943 / 7,853
conversations ██████████████████░░ 89.1% 16,387 / 18,386
mcp_analytics ██████████████████░░ 89.2% 2,514 / 2,819
dashboards ██████████████████░░ 89.3% 5,928 / 6,640
engineering_analytics ██████████████████░░ 89.5% 5,507 / 6,154
alerts ██████████████████░░ 89.9% 4,054 / 4,508
error_tracking ██████████████████░░ 90.1% 9,757 / 10,828
early_access_features ██████████████████░░ 90.1% 1,031 / 1,144
streamlit_apps ██████████████████░░ 90.4% 2,501 / 2,767
slack_app ██████████████████░░ 90.6% 8,989 / 9,926
marketing_analytics ██████████████████░░ 90.8% 11,514 / 12,684
stamphog ██████████████████░░ 91.0% 3,993 / 4,387
product_analytics ██████████████████░░ 91.3% 5,786 / 6,337
data_warehouse ██████████████████░░ 92.5% 18,819 / 20,348
ai_observability ███████████████████░ 92.7% 14,921 / 16,093
workflows ███████████████████░ 92.8% 5,520 / 5,949
web_analytics ███████████████████░ 92.9% 13,853 / 14,913
surveys ███████████████████░ 93.0% 5,724 / 6,157
posthog_ai ███████████████████░ 93.2% 1,325 / 1,421
approvals ███████████████████░ 93.3% 3,395 / 3,640
reminders ███████████████████░ 93.4% 468 / 501
endpoints ███████████████████░ 94.1% 8,606 / 9,143
revenue_analytics ███████████████████░ 94.5% 3,598 / 3,809
skills ███████████████████░ 94.5% 2,893 / 3,061
review_hog ███████████████████░ 94.6% 6,806 / 7,191
logs ███████████████████░ 95.4% 9,935 / 10,416
experiments ███████████████████░ 95.7% 24,561 / 25,671
replay_vision ███████████████████░ 95.8% 13,850 / 14,459
annotations ███████████████████░ 96.2% 732 / 761
feature_flags ███████████████████░ 96.3% 16,265 / 16,891
warehouse_sources ███████████████████░ 96.5% 289,438 / 300,067
user_interviews ███████████████████░ 96.5% 2,639 / 2,735
customer_analytics ███████████████████░ 97.3% 7,780 / 7,999
data_catalog ███████████████████░ 97.5% 2,346 / 2,407
pulse ████████████████████ 98.4% 2,017 / 2,049

Report-only. Patch coverage = changed backend lines covered vs origin/master. Sorted lowest first.
Known gaps: lines covered only by Temporal tests show as uncovered; core line numbers may drift if master changed the same file.

⚠️ Django migration SQL — 1 new migration to review

We've detected new migrations on this PR. Review the SQL output for each migration:

products/tasks/backend/migrations/0063_channel_folder_channel_task_channel_folder_unique.py

BEGIN;
--
-- Add field folder to channel
--
ALTER TABLE "posthog_task_channel" ADD COLUMN "folder_id" uuid NULL;
--
-- Create constraint task_channel_folder_unique on model channel
--
CREATE UNIQUE INDEX "task_channel_folder_unique" ON "posthog_task_channel" ("folder_id") WHERE (NOT "deleted" AND "folder_id" IS NOT NULL);
CREATE INDEX "posthog_task_channel_folder_id_fb10b098" ON "posthog_task_channel" ("folder_id");
COMMIT;

Last updated: 2026-07-21 00:05 UTC (2be8198)

Django migration risk — migration analysis complete

We've analyzed your migrations for potential risks.

Summary: 0 Safe | 1 Needs Review | 0 Blocked

⚠️ Needs Review

May have performance impact

tasks.0063_channel_folder_channel_task_channel_folder_unique
  └─ #1 ✅ AddField
     Adding nullable field requires brief lock
     model: channel, field: folder
  └─ #2 ⚠️ AddConstraint
     Adding constraint may lock table (use NOT VALID pattern)
     model: channel

📚 How to Deploy These Changes Safely

AddConstraint:

Add constraints in 2 phases without locking, using the PostHog helpers:

  1. AddConstraintNotValid (instant, validates new rows only, no table scan)

  2. ValidateConstraint in a separate migration (scans table with non-blocking lock)

    from posthog.migration_helpers import AddConstraintNotValid, ValidateConstraint

See the migration safety guide

AddField:

This operation acquires a brief lock but doesn't rewrite the table.

Deployment uses lock timeouts with automatic retries, so lock contention will cause retries rather than connection pile-up.

Last updated: 2026-07-21 00:05 UTC (2be8198)

def link_channel_folder(channel_id: str | UUID, team_id: int, *, folder_id: UUID) -> contracts.ChannelDTO | str:
"""Attach the desktop-fs folder that renders a channel (personal channels
included). Returns the DTO — unchanged if the claim was ignored — or ``not_found``."""
channel = Channel.objects.select_related("created_by").filter(id=channel_id, team_id=team_id, deleted=False).first()

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.

Medium: Personal channel ownership bypass

This query allows any team member who obtains another user's personal channel ID to attach an arbitrary team folder to it and receive its DTO. Pass the requesting user ID into this function and reject personal channels whose created_by_id does not match, consistent with _visible_channel and task channel validation.

@veria-ai

veria-ai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

PR overview

This PR adds backend support for linking a task Channel to the desktop-fs folder that renders it. The touched API path appears to create or return the channel-folder association DTO used by the desktop filesystem integration.

There is one open security issue: the current channel-folder linking path does not enforce ownership for personal channels, so a team member who knows another user’s personal channel ID could attach a team folder to it and receive the resulting DTO. No issues have been fixed or addressed yet, so this ownership check remains the main blocker before the PR is in a good security state.

Open issues (1)

Fixed/addressed: 0 · PR risk: 6/10

@adboio
adboio marked this pull request as draft July 21, 2026 12:26
@adboio adboio closed this Jul 21, 2026
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.

1 participant