Skip to content
Merged
6 changes: 6 additions & 0 deletions .semgrep/rules/security/idor-team-scoped-models.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,10 @@ rules:
|WebAnalyticsFilterPreset
|WebAnalyticsInteraction
|WebAnalyticsVisit
|WizardRun
|WizardRunArtifact
|WizardSession
|WizardWorker
)$
- focus-metavariable: $SINK
pattern-sanitizers:
Expand Down Expand Up @@ -770,7 +773,10 @@ rules:
|WebAnalyticsFilterPreset
|WebAnalyticsInteraction
|WebAnalyticsVisit
|WizardRun
|WizardRunArtifact
|WizardSession
|WizardWorker
)$
# Exclude when team filter is present
- pattern-not: $M.objects. ... .$METHOD2(..., team__project_id=$T, ...)
Expand Down
75 changes: 75 additions & 0 deletions docs/internal/wizard-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Wizard registry

Status: design document for the Wizard run stack.
This layer ships only the data models and the registry parsing described under "Remote configuration".
The "API" routes and the "Cloud execution" workers describe planned behavior and do not exist yet; they land in later stack layers.

The Wizard Registry lists the programs a person can run in the Wizard.
Programs are personalized by the signed-in user's distinct ID and the organization that owns the selected project.

## API

The registry endpoint returns a paginated list:

```text
GET /api/projects/{project_id}/wizard/registry/?limit=100&offset=0
```

Each program contains an ID, display metadata, a pinned Wizard version, Wizard command arguments, tags, prerequisite program IDs, and supported run environments.

Creating a run requires the selected `program_id`:

```json
{
"program_id": "posthog-integration",
"environment": "local",
"workspace": {
"type": "local_folder",
"project_name": "example-project"
}
}
```

The backend resolves the ID from the personalized registry, checks that the program supports the requested environment, and stores the complete program definition on the run.
Clients cannot supply or change program metadata.
Run responses return the stored `program` snapshot.
Comment thread
posthog[bot] marked this conversation as resolved.
Resolved

## Remote configuration

The registry is stored in the `wizard-program-registry` feature flag payload:

```json
{
"version": 1,
"programs": [
{
"id": "web-analytics-audit",
"name": "Web analytics audit",
"description": "Audit a project's web analytics setup",
"wizard_version": "2.60.0",
"command": ["audit", "web-analytics"],
"tags": ["audit", "web-analytics"],
"required_programs": ["posthog-integration"],
"supported_environments": ["local", "cloud"]
}
]
}
```

The entire payload is invalid when its version or shape is unsupported, program IDs are duplicated, an environment is unknown, or any program field is invalid.
Each program must include a `wizard_version` pinned to an exact release, such as `2.60.0`.
The registry payload rejects `latest`, so a program that omits `wizard_version` or sets it to `latest` makes the whole payload invalid.
A valid empty program list remains empty.

When the payload cannot be fetched or is invalid, the registry contains one `posthog-integration` program with local and cloud support.
Its command is empty, which keeps the Wizard package's default command.

## Cloud execution

Cloud workers execute the program snapshot stored when the run was created.
They do not evaluate the registry again.
They run the pinned `wizard_version` from that snapshot, never a mutable tag such as `latest`.

Program command tokens are positional command names.
They appear after `@posthog/wizard@{program.wizard_version}` and before worker-owned flags.
Option flags and shell syntax are rejected, and every accepted token is shell-quoted before execution.
12 changes: 10 additions & 2 deletions products/wizard/backend/facade/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@
from typing import Any

from products.wizard.backend import metrics
from products.wizard.backend.facade.contracts import UpsertWizardSessionInput, WizardSessionDTO
from products.wizard.backend.logic import pubsub, sessions
from products.wizard.backend.facade.contracts import UpsertWizardSessionInput, WizardProgram, WizardSessionDTO
from products.wizard.backend.logic import (
pubsub,
registry as registry_service,
sessions,
)


def upsert(params: UpsertWizardSessionInput) -> tuple[WizardSessionDTO, bool]:
Expand Down Expand Up @@ -65,3 +69,7 @@ def record_latest_session_poll(raw_source: str | None, result: str) -> None:
metrics.WIZARD_LATEST_SESSION_REQUESTS_TOTAL.labels(
source=metrics.poll_source_label(raw_source), result=result
).inc()


def get_registry(*, distinct_id: str, organization_id: str) -> tuple[WizardProgram, ...]:
return registry_service.get_registry(distinct_id=distinct_id, organization_id=organization_id)
9 changes: 9 additions & 0 deletions products/wizard/backend/facade/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from datetime import timedelta

DEFAULT_WIZARD_VERSION = "2.67.0"
LATEST_WIZARD_VERSION = "latest"
Comment thread
fercgomes marked this conversation as resolved.
Resolved
WIZARD_REGISTRY_VERSION = 1
WIZARD_RUN_DEADLINE = timedelta(minutes=85)
WIZARD_ERROR_CODE_MAX_LENGTH = 50
WIZARD_ERROR_CODE_PATTERN = r"PHW_[A-Z][A-Z0-9_]*"
WIZARD_GIT_DIFF_CONTENT_TYPE = "text/x-diff; charset=utf-8"
153 changes: 143 additions & 10 deletions products/wizard/backend/facade/contracts.py

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You know, you hate AI comments, but some heredocs here would have been quite useful!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yeah my bad

Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,19 @@

from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
from typing import Any, Literal
from uuid import UUID

from .enums import RunPhase, TaskStatus
from posthog.dataclasses import frozen

from .enums import (
WizardRunArtifactType,
WizardRunEnvironment,
WizardRunStage,
WizardRunStatus,
WizardSessionRunPhase,
WizardSessionTaskStatus,
)

STALE_AFTER = timedelta(minutes=10)

Expand All @@ -18,7 +28,7 @@
class WizardTaskDTO:
id: str
title: str
status: TaskStatus
status: WizardSessionTaskStatus


@dataclass(frozen=True)
Expand All @@ -35,7 +45,7 @@ class WizardSessionDTO:
workflow_id: str
skill_id: str
started_at: datetime
run_phase: RunPhase
run_phase: WizardSessionRunPhase
tasks: tuple[WizardTaskDTO, ...]
event_plan: dict[str, Any] | None
error: dict[str, Any] | None
Expand All @@ -47,10 +57,6 @@ class WizardSessionDTO:
is_stale: bool


class WizardSessionOwnershipError(Exception):
"""Raised when an upsert would overwrite a session owned by a different user."""


@dataclass(frozen=True)
class UpsertWizardSessionRequest:
"""What the wizard CLI POSTs. team_id is derived from the URL, not the body."""
Expand All @@ -59,7 +65,7 @@ class UpsertWizardSessionRequest:
workflow_id: str
skill_id: str
started_at: datetime
run_phase: RunPhase
run_phase: WizardSessionRunPhase
tasks: tuple[WizardTaskDTO, ...]
event_plan: dict[str, Any] | None = None
error: dict[str, Any] | None = None
Expand All @@ -74,11 +80,138 @@ class UpsertWizardSessionInput:
workflow_id: str
skill_id: str
started_at: datetime
run_phase: RunPhase
run_phase: WizardSessionRunPhase
tasks: tuple[WizardTaskDTO, ...]
event_plan: dict[str, Any] | None
error: dict[str, Any] | None
pending_input: dict[str, Any] | None
handoff_text: str | None = None
# Set on create only, never overwritten on later pushes for the same run.
created_by_id: int | None = None


class WizardSessionOwnershipError(Exception):
pass


@frozen
class LocalFolderWorkspace:
project_name: str
type: Literal["local_folder"] = "local_folder"


@frozen
class GitRepositoryWorkspace:
repository: str
type: Literal["git_repository"] = "git_repository"


type WizardWorkspace = LocalFolderWorkspace | GitRepositoryWorkspace
Comment on lines +97 to +109

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Don't know what's more common in Python: having a base class you inherit from or this. Also, Github doesn't seem to handle the type keyword all that well, so maybe this means this is not the best way to do it?

@fercgomes fercgomes Sep 3, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Don't know what's more common in Python: having a base class you inherit from or this.

i don't think it matters honestly

Github doesn't seem to handle the type keyword all that well, so maybe this means this is not the best way to do it?

can you elaborate?



@frozen
class WizardProgram:
id: str
name: str
description: str
wizard_version: str
Comment thread
posthog[bot] marked this conversation as resolved.
Resolved
command: tuple[str, ...]
tags: tuple[str, ...]
required_programs: tuple[str, ...]
supported_environments: tuple[WizardRunEnvironment, ...]


@frozen
class WizardRegistry:
programs: tuple[WizardProgram, ...]
version: Literal[1] = 1


@frozen
class CreateWizardRunInput:
team_id: int
created_by_id: int
environment: WizardRunEnvironment
workspace: WizardWorkspace
program_id: str
wizard_version: str | None = None
idempotency_key: str | None = None


@frozen
class WizardRunCreationResult:
run: "WizardRunDTO"
created: bool


@frozen
class WizardRunDTO:
id: UUID
team_id: int
created_by_id: int | None
environment: WizardRunEnvironment
workspace: WizardWorkspace
program: WizardProgram
status: WizardRunStatus
error_code: str | None
error_message: str | None
stage: WizardRunStage | None
created_at: datetime
updated_at: datetime | None
started_at: datetime | None
finished_at: datetime | None
deadline_at: datetime | None


@frozen
class ListWizardRunsInput:
team_id: int
offset: int
limit: int


@frozen
class WizardRunPage:
results: tuple[WizardRunDTO, ...]
count: int


@frozen
class CreatePullRequestArtifactInput:
team_id: int
run_id: UUID
url: str
number: int
repository: str
head_branch: str
base_branch: str


@frozen
class WizardRunGitDiffArtifactDTO:
id: UUID
team_id: int
run_id: UUID
artifact_type: Literal[WizardRunArtifactType.GIT_DIFF]
size_bytes: int
content_hash: str
additions: int | None
removals: int | None
created_at: datetime


@frozen
class WizardRunPullRequestArtifactDTO:
id: UUID
team_id: int
run_id: UUID
artifact_type: Literal[WizardRunArtifactType.PULL_REQUEST]
url: str
number: int
repository: str
head_branch: str
base_branch: str
created_at: datetime


type WizardRunArtifactDTO = WizardRunGitDiffArtifactDTO | WizardRunPullRequestArtifactDTO
Loading
Loading