Skip to content

fix(feature-flags): gate trash and restore on a standalone flag - #108143

Open
yasen-posthog wants to merge 4 commits into
masterfrom
yasen/fix/trash-restore-flag-gate
Open

yasen-posthog wants to merge 4 commits into
masterfrom
yasen/fix/trash-restore-flag-gate

Conversation

@yasen-posthog

@yasen-posthog yasen-posthog commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Problem

  • An editor can disable a flag an approval policy protects by moving it to trash in the file tree, and enable it again with undo. Neither step creates a change request.
  • The same flip through the flags API answers 409, so the file tree is a way around the policy.
  • 22 organizations gate feature_flag.enable and 5 gate feature_flag.disable, so the gap is reachable wherever those policies exist.
  • The behavior predates chore(file-system): flip trashed flags through the flag facade #107819. That PR moved the write into the facade and recorded the gap as two TODOs, which this PR closes.

Changes

Warning

Trashing or restoring a standalone flag can now answer 409 approval_required where it previously succeeded. A product-owned flag is unaffected.

  • Trash and restore honour an enable or disable policy on a standalone flag. A gated trash answers 409 with a change request, and the flag stays in the tree until an approver acts.
  • A product-owned flag keeps the raw write and trash stays one update. Ownership picks the path, the way resource-scoped policies do everywhere else: feature_flag.* governs standalone flags, and an experiment-owned or survey-owned flag matches that family under no endpoint, so gating it here would put an approval prompt in front of starting a survey or launching an experiment.
  • The gate runs on its own, without a serializer write. Trash never ran the serializer's dependents check or filter validation, and routing it through them would start rejecting a flag that other flags depend on, and log a second activity entry beside the file system's own.
  • gate_scheduled_change becomes gate_flag_change. It already served the copy-between-projects path, where nothing is scheduled, and now serves trash and restore too. Its docstring states the payload shape, which stays scheduled-change shaped for every caller.
  • FileSystemViewSet takes ApprovalHandlingMixin, so ApprovalRequired renders as 409 rather than escaping as a 500.
  • destroy and undo_delete take gated_atomic() instead of transaction.atomic(). The gate writes its change request inside the caller's transaction, so a plain block rolled it back and the 409 carried an id with no row behind it.
  • Restore still turns a flag on even when it was off before trash, because trash records no prior state. The gate only decides whether that flip needs approval.

How did you test this code?

  • Two new tests name the regression: a standalone flag under a feature_flag.disable policy is refused with a 409, stays untrashed, and leaves a change request an approver can act on; a survey-owned flag under the same policy trashes with no change request.
  • test_undo_delete_restores_feature_flag loses its disable_and_enable_policies case, which asserted that a policy does not block trash. That assertion is what this PR reverses.
  • Both review findings were verified by reverting them. With the serializer path, test_undo_delete_restores_feature_flag_1_active_dependent_flag and _2_depends_on_inactive_flag fail. With plain transaction.atomic(), the change-request assertion fails.
  • Ran locally: posthog/api/test/test_file_system.py (47), products/approvals/backend/tests/ (285), products/feature_flags/backend/api/test/test_scheduled_change.py (52), the gated-write invariant (32), hogli lint:tach and hogli product:lint --all.
  • Not run locally: repo-wide mypy. CI runs it.

Release status

  • No feature flag controls this change
  • This change is behind a feature flag and is not available to users
  • This change makes a previously flagged feature available to everyone

Automatic notifications

  • Publish to changelog?

Docs update

None.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, Opus 5 (claude-opus-5)

  • Skills invoked: /writing-pr-descriptions, /writing-tests.
  • CodeRabbit CLI pass skipped: the CLI is signed out and the person chose to skip.
  • The first version of this PR routed trash through set_flag_active, which applies the whole serializer. CodeRabbit caught that it would reject a flag with dependents, and that the change request rolled back. Both held up, and the gate-only approach replaced it.
  • The rename came out of review: reading the function's contract showed its payload is scheduled-change shaped, which the first attempt did not pass, so the gate silently declined to fire.
  • The gap was raised in review on chore(file-system): flip trashed flags through the flag facade #107819 and recorded there as two TODOs rather than fixed. This PR removes both.

Trash flips active on any flag in the file tree, so an editor could disable a flag
an approval policy protects by trashing it, and enable it again with undo. The same
flip through the flags API answers 409.

Ownership picks the path, the way resource-scoped policies do everywhere else. A
standalone flag is what feature_flag.* governs, so trash and restore now honour an
enable or disable policy on it. A product-owned flag matches no feature_flag.*
policy and keeps the raw write.
@yasen-posthog yasen-posthog self-assigned this Sep 29, 2026
@trunk-io

trunk-io Bot commented Sep 29, 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

@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Trunk lane — backend Python lane

This PR is assigned to the backend Python lane. It runs backend Python tests and may merge in parallel with PRs in other lanes.

✅ Complexity (TypeScript) — clean

Cyclomatic complexity above the limit in changed typescript files (10 for production files, 15 for test files). Warn only: worth simplifying when you next touch these functions.

✅ Duplication (Python) — clean

New Python code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

✅ Duplication (TypeScript) — clean

New TypeScript code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

⚠️ Comment density — 4% of added code lines are comments (4 of 100)

This section warns when comments are more than 3% of the code lines a PR adds, and alerts above 6%. Before agent-assisted PRs, the typical share was about 2%. Only full-line comments count. Docstrings, generated files, snapshots, migrations, and workflow files are left out.

Comments that restate the code, record how the change came about, or narrate the next line add noise for the next reader. Keep the comments that explain a reason the code cannot show, and remove the rest. See .agents/skills/writing-code-comments/SKILL.md for the house rules.

Files with the most added comment lines:

File Comment lines Added lines
products/feature_flags/backend/facade/api.py 3 36
products/approvals/backend/tests/test_scheduled_change_gating.py 1 3

This check does not block merging. It updates on every push and clears when the share drops.

⚠️ Bundle size — 🔺 +35.2 KiB (+0.0%)

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

Total: 68.92 MiB · 🔺 +35.2 KiB (+0.0%)

File Size Δ vs base
posthog-app/_parent/products/business_knowledge/frontend/scenes/BusinessKnowledgePlaygroundScene.js removed 🟢 -26.2 KiB (-100.0%)
posthog-app/_parent/products/business_knowledge/frontend/scenes/playground/BusinessKnowledgePlaygroundScene.js 26.1 KiB 🔺 +26.1 KiB (new)
posthog-app/_parent/products/business_knowledge/frontend/scenes/BusinessKnowledgeScene.js removed 🟢 -18.7 KiB (-100.0%)
posthog-app/_parent/products/business_knowledge/frontend/scenes/sources/BusinessKnowledgeScene.js 18.6 KiB 🔺 +18.6 KiB (new)
posthog-app/_parent/products/business_knowledge/frontend/scenes/KnowledgeSourceScene.js removed 🟢 -18.0 KiB (-100.0%)
posthog-app/_parent/products/business_knowledge/frontend/scenes/source/KnowledgeSourceScene.js 18.0 KiB 🔺 +18.0 KiB (new)
posthog-app/_parent/products/business_knowledge/frontend/scenes/BusinessKnowledgeSettingsScene.js removed 🟢 -16.8 KiB (-100.0%)
posthog-app/_parent/products/business_knowledge/frontend/scenes/settings/BusinessKnowledgeSettingsScene.js 16.8 KiB 🔺 +16.8 KiB (new)
posthog-app/_parent/products/signals/frontend/inbox/InboxScene.js 488.4 KiB 🔺 +13.6 KiB (+2.9%)
render-query/src/render-query/render-query.js 20.16 MiB 🔺 +7.8 KiB (+0.0%)
posthog-app/_parent/products/ai_observability/frontend/evaluations/AIObservabilityEvaluation.js 87.1 KiB 🔺 +6.0 KiB (+7.4%)
posthog-app/_parent/products/workflows/frontend/Broadcasts/BroadcastScene.js 67.3 KiB 🔺 +3.7 KiB (+5.9%)
posthog-app/_parent/products/stamphog/frontend/scenes/StamphogDigestsScene/StamphogDigestsScene.js 9.5 KiB 🔺 +1.6 KiB (+20.7%)
posthog-app/_parent/products/stamphog/frontend/scenes/StamphogScene/StamphogScene.js 22.1 KiB 🔺 +1.1 KiB (+5.4%)

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.58 MiB · 22 files 🔺 +188 B (+0.0%) █████████░ 85.8% of 1.84 MiB
logged-out boot: index + App + bootApp (preloaded by every page, including /login)
src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
3.52 MiB · 629 files 🔺 +1.8 KiB (+0.0%) █████████░ 87.3% of 4.03 MiB
authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
7.35 MiB · 2,339 files 🔺 +4.2 KiB (+0.1%) █████████░ 88.1% of 8.34 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/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/layout/navigation-3000/navigationLogic.tsx stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/scenes/dashboard/dashboardLogic.tsx stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/lemon-ui/LemonMarkdown/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/components/RichContentEditor/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/components/CodeSnippet/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/taxonomy/core-filter-definitions-by-group.json stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 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
🟢 src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts 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
🟢 [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/index.tsx
1.3 KiB src/RootErrorBoundary.tsx
912 B ../node_modules/.pnpm/ieee754@1.2.1/node_modules/ieee754/index.js
854 B src/scenes/ChunkLoadErrorBoundary.tsx
Largest files eagerly shipped from src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
Size File
301.8 KiB ../node_modules/.pnpm/posthog-js@1.434.14_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
216.9 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
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
100.5 KiB src/lib/api.ts
88.5 KiB src/products.tsx
69.4 KiB src/lib/lemon-ui/icons/icons.tsx
40.1 KiB src/lib/utils/eventUsageLogic.ts
38.7 KiB ../node_modules/.pnpm/@dnd-kit+core@6.0.8_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@dnd-kit/core/dist/core.esm.js
33.9 KiB ../node_modules/.pnpm/kea@4.0.0-pre.6_patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5_react@18.3.1/node_modules/kea/lib/index.cjs.js
28.4 KiB src/scenes/scenes.ts
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
Size File
301.8 KiB ../node_modules/.pnpm/posthog-js@1.434.14_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
272.2 KiB src/taxonomy/core-filter-definitions-by-group.json
216.9 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
153.7 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
100.5 KiB src/lib/api.ts
98.8 KiB ../packages/quill/packages/quill/dist/index.js
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
90.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
88.5 KiB src/products.tsx

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

✅ Toolbar bundle — eager 2.16 MiB within budget

What the toolbar ships to customer pages, measured from the esbuild output (minified, post-tree-shake). The eager set is the entry plus everything statically imported from it — fetched before any feature runs; deferred chunks load lazily. The eager guardrail is 5.72 MiB. Each output file must also stay below 10 MB, where CloudFront stops compressing it. The module boundary is enforced separately by check-toolbar-graph.

Metric Size Δ vs base Budget
Eager (shipped)
entry + static imports
2.16 MiB · 19 files 🔺 +336 B (+0.0%) ████░░░░░░ 37.8% of 5.72 MiB
Deferred (lazy) 2.10 MiB · 44 files 🔺 +528 B (+0.0%) n/a — loads on demand
Loader dist/toolbar.js 1.2 KiB no change █░░░░░░░░░ 6.0% of 19.5 KiB
Largest eagerly-shipped chunks
Size File
805.4 KiB dist/toolbar/toolbar-app-5UT2PX3W.css
651.7 KiB dist/toolbar/chunk-chunk-SEZBMT4M.js
259.4 KiB dist/toolbar/chunk-chunk-RK3L52NC.js
138.3 KiB dist/toolbar/chunk-chunk-YMECCIGG.js
131.8 KiB dist/toolbar/chunk-chunk-FDH2IBXT.js
75.2 KiB dist/toolbar/toolbar-app-SN7ERW5L.js
69.0 KiB dist/toolbar/chunk-chunk-TSAL54PB.js
35.6 KiB dist/toolbar/chunk-chunk-A6ER72L5.js
21.0 KiB dist/toolbar/chunk-chunk-E6HGNIFN.js
6.8 KiB dist/toolbar/chunk-chunk-DV7IWQNF.js

Posted automatically by check-toolbar-size · sizes are toolbar output bytes (shipped, post-tree-shake) from the esbuild metafile

✅ Dist folder size — 🔺 +804.6 KiB (+0.1%)

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

Total: 947.31 MiB · 🔺 +804.6 KiB (+0.1%)

✅ Playwright — all passed

All tests passed.

View test results →

@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

📝 Walkthrough

Walkthrough

The approval gate is renamed to gate_flag_change and its scheduled-change callers are updated. The file system viewset adds approval handling for trash and restore operations. Hooks pass the acting user to facade functions, which gate active-state changes for standalone flags and use raw updates for product-owned flags. Tests cover standalone and survey-owned flags under a disable policy.

Priority: ⬇️ Low

Merge Risk: 🔵 Low · up to 103b7

Trash and restore can misrepresent a system write’s actor, and an approval-required response omits approver details. These are bounded issues to address or explicitly accept before merging.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to 103b7

Standalone flags gain an approval gate for trash and restore, but approving a request does not complete the corresponding file-tree operation. This can leave the approval result and the visible file-tree state out of sync.

Retained concerns

  • Medium · reliability · inferred: Approval applies the flag state change but does not finish the gated trash or restore transition, allowing the approved request and file-tree state to diverge.
Security review details

Security Blast Radius

  • inferred — The reachable new gate concerns editors operating on flags in their team through file-system trash or restore; approval-policy enforcement applies to standalone flags, not product-owned flags.

Trust Boundaries and Controls

  • observed — The facade scopes its lookup by flag and team, uses ownership to select the policy path, and raises before its active-state write when the gate returns a change request.

Resilience and Maintainability Implications

  • inferred — Flag-action application has a row lock, version precondition, and target-state idempotency check, but those controls do not reconcile the separate file-tree transition after approval.

Hardening Proposals

  • proposed — Represent the approved trash or restore as a lifecycle action whose application completes both the flag-state and file-tree transitions, with preconditions and retry behavior for the combined operation.
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The description is complete and follows the required template. It explains the problem, user-visible changes, testing, release status, documentation status, and agent context. It also documents the ap…
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
posthog/api/test/test_file_system.py (1)

726-726: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add standalone restore approval coverage.

Standalone restore now uses feature_flag.enable approval. The remaining restore cases do not install an enable policy, and the removed case did not assert the gated response. Add a test that expects HTTP 409, a pending ChangeRequest, and the flag to remain trashed.

Suggested restore approval test
+    def _create_enable_policy(self) -> None:
+        ApprovalPolicy.objects.create(
+            organization=self.organization,
+            team=self.team,
+            action_key="feature_flag.enable",
+            conditions={},
+            approver_config={"quorum": 1, "users": [self.user.id]},
+            created_by=self.user,
+        )
+
+    @patch("products.approvals.backend.decorators._is_approvals_enabled", return_value=True)
+    def test_restoring_a_standalone_flag_under_an_enable_policy_needs_approval(
+        self, _mock_approvals_enabled
+    ) -> None:
+        flag = FeatureFlag.objects.create(team=self.team, key="gated-restore", created_by=self.user)
+        self._create_enable_policy()
+        file_entry = FileSystem.objects.get(team=self.team, type="feature_flag", ref=str(flag.id))
+
+        assert self.client.delete(f"/api/environments/{self.team.id}/file_system/{file_entry.id}/").status_code == 200
+
+        response = self.client.post(
+            f"/api/environments/{self.team.id}/file_system/undo_delete/",
+            {"items": [{"type": "feature_flag", "ref": str(flag.id)}]},
+        )
+
+        assert response.status_code == status.HTTP_409_CONFLICT, response.json()
+        flag.refresh_from_db()
+        assert flag.deleted is True
+        assert flag.active is False
+        assert not FileSystem.objects.filter(team=self.team, type="feature_flag", ref=str(flag.id)).exists()
+        assert ChangeRequest.objects.filter(team=self.team, action_key="feature_flag.enable").exists()

The dependency-case comment is a separate serializer-validation regression and should be updated with that fix, not combined with this approval-coverage test.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: PostHog/posthog/.coderabbit.yaml

Review profile: QUIET

Plan: Enterprise

Run ID: 768e683a-632a-4973-b97b-0c76a89b97d5

📥 Commits

Reviewing files that changed from the base of the PR and between 0d4cf12 and aeaabc6.

📒 Files selected for processing (4)
  • posthog/api/file_system/file_system.py
  • posthog/api/file_system/registrations.py
  • posthog/api/test/test_file_system.py
  • products/feature_flags/backend/facade/api.py

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 9 remain after this review.

Comment thread products/feature_flags/backend/facade/api.py Outdated
Comment thread products/feature_flags/backend/facade/api.py Outdated
@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Retrigger

[Critical risk] Changes how trash and restore interact with feature flag policies.

The PR is not safe to merge until approval requests persist and approved actions complete the requested operation without breaking existing trash and restore behavior.

Reviews (1) · Last reviewed commit: "fix(feature-flags): gate trash and resto..."

Comment thread products/feature_flags/backend/facade/api.py Outdated
Comment thread products/feature_flags/backend/facade/api.py Outdated
Comment thread products/feature_flags/backend/facade/api.py Outdated
@yasen-posthog
yasen-posthog marked this pull request as ready for review September 29, 2026 08:09
@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested a review from a team September 29, 2026 08:10
@trunk-io

trunk-io Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Static Badge   Static Badge   Static Badge

View Full Report ↗︎ ⋅ Docs

yasen-posthog and others added 2 commits September 29, 2026 11:29
The facade shape check rejects an Any-typed user parameter, because it can carry a
Django object across the boundary. Take the id and read the row inside.
@github-actions

github-actions Bot commented Sep 29, 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 103b7ee · box box-d985e9b366b9 · ready in 687s (push → usable) · build log · rebuilds on every push, torn down on close

…write

Routing trash through set_flag_active brought the serializer's other checks with
it, so trashing a flag that other flags depend on started failing, and a second
activity entry landed beside the file system's own.

Use the approvals gate directly, which answers the approval question and creates
the change request without a serializer write. Rename gate_scheduled_change to
gate_flag_change: it already served the copy path, and now trash and restore.

destroy and undo_delete take gated_atomic, so a rejected trash reports a change
request that still exists.

@coderabbitai coderabbitai Bot left a comment

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.

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (2)
products/feature_flags/backend/facade/api.py-220-222 (1)

220-222: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not evaluate system writes as the flag creator.

When user_id is None, the gate substitutes flag.created_by as the actor. A creator with a configured bypass role can then receive an ALLOW decision, so trash or restore proceeds without an approval request.

The module docstring already defines user=None as a system write. Skip the gate only when user_id is None. Reject an invalid non-null user_id instead of falling back to the creator.

Proposed fix
-    if flag_owner_kind(flag) is None:
+    acting_user = User.objects.filter(pk=user_id).first() if user_id is not None else None
+    if flag_owner_kind(flag) is None and user_id is not None:
+        if acting_user is None:
+            raise ValueError(f"Unknown acting user: {user_id}")
         # The gate alone, not a serializer write. Trash never ran the dependents check or filter
         # validation, and routing it through the serializer would start rejecting a flag other
         # flags depend on, and log a second activity entry beside the file system's own.
         change_request = gate_flag_change(
-            flag, {"operation": "update_status", "value": active}, User.objects.filter(pk=user_id).first()
+            flag, {"operation": "update_status", "value": active}, acting_user
         )
products/feature_flags/backend/facade/api.py-223-228 (1)

223-228: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Preserve approver details in the file-system 409 response.

When a standalone flag trash or restore matches an approval policy, gate_flag_change returns a change request and _flip_trashed_flag raises ApprovalRequired. The policy snapshot stores quorum, users, and roles at the top level, so approver_config is absent. The 409 response therefore returns an empty required_approvers field.

Suggested fix
         if change_request is not None:
+            policy_snapshot = change_request.policy_snapshot or {}
             raise ApprovalRequired(
                 change_request=change_request,
                 message="Approval required",
-                required_approvers=(change_request.policy_snapshot or {}).get("approver_config") or {},
+                required_approvers={
+                    key: policy_snapshot[key]
+                    for key in ("quorum", "users", "roles")
+                    if key in policy_snapshot
+                },
             )

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: PostHog/posthog/.coderabbit.yaml

Review profile: QUIET

Plan: Enterprise

Run ID: 16596422-cfea-4e54-887a-9879aec75257

📥 Commits

Reviewing files that changed from the base of the PR and between 5c8cf56 and 103b7ee.

📒 Files selected for processing (7)
  • posthog/api/file_system/file_system.py
  • products/approvals/backend/scheduled_changes.py
  • products/approvals/backend/tests/test_feature_flag_bypass_matrix.py
  • products/approvals/backend/tests/test_scheduled_change_gating.py
  • products/feature_flags/backend/api/organization_feature_flag.py
  • products/feature_flags/backend/api/scheduled_change.py
  • products/feature_flags/backend/facade/api.py

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 8 remain after this review.

@yasen-posthog yasen-posthog added the stamphog Request AI approval (no full review) label Sep 29, 2026

@stamphog stamphog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved.

Approval-policy territory, but the author has STRONG familiarity with the code, there's a current-head reviewer comment with only minor nits, and the diff matches the description. It closes a real policy bypass with regression tests. The stated behavior change is disclosed: trashing a standalone flag under a policy now answers 409.

  • Author wrote 93% of the modified lines and has 55 merged PRs in these paths (familiarity STRONG).
  • coderabbitai[bot] reviewed the current head.
  • Minor (CodeRabbit, non-blocking): the 409 for a gated trash or restore may report empty required_approvers, because the policy snapshot doesn't nest the details under approver_config.
  • Minor (CodeRabbit, non-blocking): if the acting user is missing, the gate evaluates as the flag creator. The file-system caller always passes the request user, so this isn't reachable today.
  • Restore has no test for the gated case where an enable policy makes it answer 409.
Gate mechanics and policy version
Gate Result
prerequisites ✓ all clear
deny-list ✓ no deny categories matched
size ✓ 94L, 6F substantive, 300L/11F incl. docs/generated/snapshots — within ceiling
tier ✓ T1-agent / T1d-complex (300L, 11F, cross-cutting, fix)
stamphog 2.3.0 .stamphog/policy.yml @ 103b7ee · reviewed head 103b7ee

This branch was successfully deployed

1 active deployment
preview-pr-108143 — 103b7ee2 Deployed Sep 29, 2026 by github-actions[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature/feature-flags Feature Tag: Feature flags stamphog Request AI approval (no full review) team/feature-flags

Projects

Status: Approved

Development

Successfully merging this pull request may close these issues.

1 participant