# `Lightning.Projects.Sandboxes`
[🔗](https://github.com/OpenFn/lightning/blob/main/lib/lightning/projects/sandboxes.ex#L1)

Manage **sandbox projects** - isolated copies of existing projects for safe experimentation.

Sandboxes allow developers to test changes, experiment with workflows, and collaborate
without affecting production projects. They share credentials with their parent but
maintain separate workflow execution environments.

## What gets copied to a sandbox

* **Project settings**: retention policies, concurrency limits, MFA requirements
* **Workflow structure**: jobs, triggers (disabled), edges, and node positions
* **Credentials**: references to parent credentials (no secrets duplicated)
* **Keychain metadata**: cloned for jobs that use them
* **Version history**: latest workflow version per workflow
* **Optional dataclips**: named clips of specific types can be selectively copied

## Operations

* `provision/3` - Create a new sandbox from a parent project
* `merge/4` - Merge a sandbox into its target (workflows + collections)
* `update_sandbox/3` - Update sandbox name, color, or environment
* `schedule_sandbox_deletion/2` - Soft-delete a sandbox and its descendants;
  they remain in the database for a grace period before being purged
* `cancel_scheduled_sandbox_deletion/2` - Restore a scheduled sandbox subtree
  while it is still within its grace period (admin recovery path)
* `delete_sandbox/2` - Hard-delete a sandbox and all its descendants
  immediately (used by the Oban purge worker after the grace period elapses)

## Authorization

* **Provisioning**: Requires `:editor`, `:admin`, or `:owner` role on the parent project
* **Merge**: Requires `:editor`, `:admin`, or `:owner` role on the target project
* **Updates/Deletion**: Requires `:owner` or `:admin` role on the sandbox itself,
                        or `:owner` or `:admin` on the root project

## Transaction safety

All operations are performed within database transactions to ensure consistency.
Failed operations leave no partial state behind.

# `merge_error`

```elixir
@type merge_error() :: :merge_failed | Lightning.Extensions.UsageLimiting.message()
```

# `provision_attrs`

```elixir
@type provision_attrs() :: %{
  :name =&gt; String.t(),
  optional(:color) =&gt; String.t() | nil,
  optional(:env) =&gt; String.t() | nil,
  optional(:dataclip_ids) =&gt; [Ecto.UUID.t()],
  optional(:starting_dataclip) =&gt; %{
    :body =&gt; String.t(),
    optional(:name) =&gt; String.t() | nil
  }
}
```

Attributes for creating a new sandbox via `provision/3`.

## Required
* `:name` - Sandbox name (must be unique within the parent project)

## Optional
* `:color` - UI color hex string (e.g. `"#336699"`)
* `:env` - Environment identifier (e.g. `"staging"`, `"dev"`)
* `:dataclip_ids` - UUIDs of dataclips to copy (only copies named dataclips
  of types `:global`, `:saved_input`, or `:http_request`)
* `:starting_dataclip` - a `%{body: json_string, name: string | nil}` map. The
  body is created in the sandbox as a `:saved_input` dataclip rather than
  copied from the parent, so what the caller reviewed is what lands.

The sandbox's `project_users` are derived from the parent project: every
parent user is copied across with their role preserved, except the parent
owner who is demoted to `:admin`. The `actor` is then set as the sandbox
owner (replacing any other role they may have had on the parent). To add
a user to the sandbox who is not on the parent, call
`Lightning.Projects.add_project_users/4` after provision returns — that
path goes through the seat-limit check.

# `cancel_scheduled_sandbox_deletion`

```elixir
@spec cancel_scheduled_sandbox_deletion(
  Lightning.Projects.Project.t() | Ecto.UUID.t(),
  Lightning.Accounts.User.t()
) ::
  {:ok, Lightning.Projects.Project.t()}
  | {:error, :unauthorized | :not_found | term()}
  | Lightning.Extensions.UsageLimiting.error()
```

Clears the scheduled deletion on a sandbox subtree, restoring it to active use.

Walks every descendant of `sandbox` and clears `scheduled_deletion` on any
row that has it set. Triggers are not automatically re-enabled: this is an
admin recovery path and the operator decides whether the subtree should
resume firing triggers.

## Cascade semantics

The cancel clears `scheduled_deletion` on every descendant that has it set,
regardless of whether the schedule originated from this subtree's parent
or from a separate scheduling action on the descendant itself. Any row
whose `scheduled_deletion` is already nil is left alone.

## Limit

Restoring a sandbox moves it back into the active count, so the same
usage-limit action that gates new sandbox creation also gates restore.
When the active-sandbox count is already at the limit, restore is
refused with `{:error, :too_many_sandboxes, message}`; the operator
needs to delete an active sandbox first.

## Parameters
* `sandbox` - Sandbox project to restore (or sandbox ID as string)
* `actor` - User performing the action (needs `:cancel_scheduled_deletion`
  permission — the same owner/admin rule as `:delete_sandbox`, read directly
  rather than through Scope, since the subject is by definition a project
  scheduled for deletion)

## Returns
* `{:ok, restored_sandbox}` - Sandbox subtree restored
* `{:error, :unauthorized}` - Actor lacks permission on the sandbox
* `{:error, :not_found}` - Sandbox ID not found (when using a string ID)
* `Lightning.Extensions.UsageLimiting.error()` - Limit reached

# `copyable_dataclips?`

```elixir
@spec copyable_dataclips?(Lightning.Projects.Project.t(), [Ecto.UUID.t()]) ::
  boolean()
```

Whether every id names a dataclip this parent can copy into a sandbox.

`provision/3` drops ineligible ids silently, which suits a bulk selection but
not a caller offering one deliberate choice: that caller wants to say why
nothing arrived.

# `delete_sandbox`

```elixir
@spec delete_sandbox(
  Lightning.Projects.Project.t() | Ecto.UUID.t(),
  Lightning.Accounts.User.t()
) ::
  {:ok, Lightning.Projects.Project.t()}
  | {:error, :unauthorized | :not_found | term()}
```

Deletes a sandbox and all its descendant projects.

## Parameters
* `sandbox` - Sandbox project to delete (or sandbox ID as string)
* `actor` - User performing the deletion (needs `:owner` or `:admin` role on sandbox)

## Returns
* `{:ok, deleted_sandbox}` - Successfully deleted sandbox and descendants
* `{:error, :unauthorized}` - Actor lacks permission on sandbox
* `{:error, :not_found}` - Sandbox ID not found (when using string ID)
* `{:error, reason}` - Database or other deletion error

## Example
    {:ok, deleted} = Sandboxes.delete_sandbox(sandbox, user)

# `merge`

```elixir
@spec merge(
  Lightning.Projects.Project.t(),
  Lightning.Projects.Project.t(),
  Lightning.Accounts.User.t(),
  map()
) :: {:ok, Lightning.Projects.Project.t()} | {:error, merge_error()}
```

Merges a sandbox into its target project, in one transaction.

Imports the sandbox's workflows into the target through the provisioner and
synchronises collection names; collection data is never copied. Each merged
workflow also gains the target's resulting head in its version history, so a
later merge can tell the target has moved on.

Callers must authorise the merge first (`:merge_sandbox`).

## Options
* `:selected_workflow_ids`, `:deleted_target_workflow_ids` - scope the merge
* `:selected_credential_ids` - sandbox-only credentials to attach to the
  target first, so the remap matches them instead of dropping them. Keychains
  follow the same rule, and are skipped rather than failing the merge when the
  actor cannot create one in the target.
* `:skip_collections` - names to leave out. A merge never deletes collections.
* `:record_release`

Returns `{:ok, updated_target}` or `{:error, merge_error}`, typed so callers
can render copy without reading changesets.

# `parent_admin?`

```elixir
@spec parent_admin?(Lightning.Projects.Project.t(), Lightning.Accounts.User.t()) ::
  boolean()
```

Returns `true` when `user` has an `:admin` or `:owner` role on any ancestor
of `project`, walking the parent chain.

Used to enforce the parent-admin floor rule: a user who is admin/owner on
any ancestor project cannot be removed from, or downgraded within, a
sandbox descended from that project.

# `preview_collections`

```elixir
@spec preview_collections(
  Lightning.Projects.Project.t(),
  Lightning.Projects.Project.t()
) :: %{
  to_create: [String.t()]
}
```

Computes the collections a merge from `source` into `target` would create,
without applying anything.

Returns a map with:

* `:to_create` - sorted names that exist only in the source and would be
  created empty in the target

Used by the merge screen to show what a merge would change before the
user commits to it. Callers pass the names the user explicitly unchecked
back as `:skip_collections`; the merge recomputes the missing names at
merge time, so a collection added to the source after the preview is
still created. A merge never deletes collections, so there is nothing
else to preview.

# `provision`

```elixir
@spec provision(
  Lightning.Projects.Project.t(),
  Lightning.Accounts.User.t(),
  provision_attrs()
) ::
  {:ok, Lightning.Projects.Project.t()}
  | {:error, :unauthorized | :nesting_too_deep | Ecto.Changeset.t() | term()}
```

Creates a new sandbox project by cloning from a parent project.

The creator becomes the sandbox owner, and all workflow triggers are disabled.
Credentials are shared (not duplicated) between parent and sandbox.

## Parameters
* `parent` - Project to clone from
* `actor` - User creating the sandbox (needs `:editor`, `:admin`, or `:owner` role on parent)
* `attrs` - Creation attributes (see `t:provision_attrs/0`)

## Returns
* `{:ok, sandbox_project}` - Successfully created sandbox
* `{:error, :unauthorized}` - Actor lacks permission on parent
* `{:error, :nesting_too_deep}` - Parent is already at `Lightning.Config.max_sandbox_nesting_depth/0`
* `{:error, changeset}` - Validation or database error

## Example
    {:ok, sandbox} = Sandboxes.provision(parent_project, user, %{
      name: "test-environment",
      color: "#336699"
    })

## Concurrency note

The nesting-depth check runs inside the same `Repo.transaction` as the
sandbox insert, but PostgreSQL's default READ COMMITTED isolation does
not lock the parent's ancestry. A concurrent committed reparent of
`parent` or any of its ancestors between the depth read and the insert
could place the new sandbox one level above the cap. Lightning has no
reparenting code path today, so this is theoretical; if a re-homing
feature ships, this check should be tightened with `SELECT FOR UPDATE`
on the ancestor chain.

# `schedule_sandbox_deletion`

```elixir
@spec schedule_sandbox_deletion(
  Lightning.Projects.Project.t() | Ecto.UUID.t(),
  Lightning.Accounts.User.t()
) ::
  {:ok, Lightning.Projects.Project.t()}
  | {:error, :unauthorized | :not_found | term()}
```

Schedules a sandbox and its entire descendant subtree for deletion.

The sandbox stays in the database for a grace period (controlled by
`PURGE_DELETED_AFTER_DAYS`) before the Oban purge worker permanently
deletes it. During the grace period the sandbox is hidden from the
parent's sandbox listing but remains recoverable via
`cancel_scheduled_sandbox_deletion/2`.

All triggers in the subtree are disabled so that scheduled work stops
immediately. The scheduled timestamp is applied to the target and every
descendant in a single transaction so the entire subtree shares a grace
period and gets purged together.

## Cascade semantics

Scheduling cascades through every descendant unconditionally. If a child
sandbox was already scheduled separately (with an earlier timestamp), that
earlier timestamp is overwritten with the new one. The intent is that
scheduling a parent always synchronises the whole subtree's grace window;
if you need a child to be purged on its original earlier timestamp, do not
schedule the parent.

## Parameters
* `sandbox` - Sandbox project to schedule (or sandbox ID as string)
* `actor` - User performing the action (needs `:delete_sandbox` permission)

## Returns
* `{:ok, scheduled_sandbox}` - Sandbox subtree scheduled for deletion
* `{:error, :unauthorized}` - Actor lacks permission on the sandbox
* `{:error, :not_found}` - Sandbox ID not found (when using a string ID)
* `{:error, reason}` - Database or other failure

# `sync_collections`

```elixir
@spec sync_collections(
  Lightning.Projects.Project.t(),
  Lightning.Projects.Project.t(),
  keyword()
) ::
  {:ok, %{created: non_neg_integer()}}
```

Creates the source's collections in its merge target.

Names only in the source are created empty in the target; collection data
is never copied. Collections that exist only in the target are never
touched - a merge cannot delete a collection.

## Options

  * `:skip_names` - collection names not to create even though the target
    lacks them; everything else missing is created. Defaults to `[]`.
    The merge passes the names the user explicitly unchecked, so any
    collection added to the source since the preview is still created.

# `update_sandbox`

```elixir
@spec update_sandbox(
  Lightning.Projects.Project.t() | Ecto.UUID.t(),
  Lightning.Accounts.User.t(),
  map()
) ::
  {:ok, Lightning.Projects.Project.t()}
  | {:error, :unauthorized | :not_found | Ecto.Changeset.t()}
```

Updates a sandbox project's basic attributes.

## Parameters
* `sandbox` - Sandbox project to update (or sandbox ID as string)
* `actor` - User performing the update (needs `:owner` or `:admin` role on sandbox)
* `attrs` - Map with `:name`, `:color`, and/or `:env` keys

## Returns
* `{:ok, updated_sandbox}` - Successfully updated sandbox
* `{:error, :unauthorized}` - Actor lacks permission on sandbox
* `{:error, :not_found}` - Sandbox ID not found (when using string ID)
* `{:error, changeset}` - Validation error

## Example

```elixir
{:ok, updated} = Sandboxes.update_sandbox(sandbox, user, %{
  name: "new-name",
  color: "#ff6b35"
})
```

---

*Consult [api-reference.md](api-reference.md) for complete listing*
