Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions TEMPLATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4415,6 +4415,72 @@ ALTER TABLE `users` DROP INDEX `idx_email`;
```

</details>

</details>

<details>
<summary><a name="rollback-plan-directexecution-change"></a><strong>Rollback Plan (Direct-execution Change)</strong></summary>


## Schema Rollback Plan — Staging

**Database**: `testapp` | **Type**: `MySQL`

*Requested by @jackjackbits at 2026-01-01 00:00:00 UTC*

```sql
ALTER TABLE `users`
DROP PRIMARY KEY,
ADD PRIMARY KEY(`id`);
```

⚙️ **Direct execution**: **1** change will run as native MySQL DDL
- `users`: dropping primary key is not supported; runs as native MySQL DDL on a table with ~1,240 rows

These statements run synchronously outside the schema-change engine: writes to each table are blocked while its statement runs, the change is **not revertible**, and `--defer-cutover` does not apply to it. Confirming the rollback consents to this.

> **Warning**: Rollback may include destructive changes (e.g., DROP INDEX, DROP COLUMN). These will be applied automatically.

📋 **Plan**: **1** table to alter

---

To confirm this rollback, comment:
```
schemabot rollback-confirm -e staging
```

To cancel, comment:
```
schemabot unlock
```

</details>

<details>
<summary><a name="rollback-rejected-engineblocked-changes"></a><strong>Rollback Rejected (Engine-blocked Changes)</strong></summary>


## Schema Rollback Plan — Staging

**Database**: `testapp` | **Type**: `MySQL`

*Requested by @jackjackbits at 2026-01-01 00:00:00 UTC*

```sql
ALTER TABLE `users`
DROP PRIMARY KEY,
ADD PRIMARY KEY(`id`);
```

📋 **Plan**: **1** table to alter

---

**⛔ Rollback rejected**: **1** planned change not supported by the schema-change engine
- `users`: dropping primary key is not supported; direct execution is enabled but the table has ~2,400,000 rows, above the configured limit of 1,000,000

Reconcile the target schema with a follow-up schema change PR instead, or contact your SchemaBot operators for help.
</details>

### CLI Output
Expand Down
9 changes: 9 additions & 0 deletions docs/direct-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,15 @@ containing direct-execution changes never does:
statements only, and the disclosure says so. A rejection at confirm time
preserves the pending confirmation, so re-running `apply-confirm` without
the flag executes the confirmed plan.
- `schemabot rollback` follows the same consent model. A rollback plan whose
reverse DDL the policy routes to direct execution carries the ⚙️ disclosure
on the rollback plan comment, and `schemabot rollback-confirm` is the
consent against it — including the all-direct `--defer-cutover` rejection,
which preserves the pending rollback. A reverse plan that resolves to
blocked (e.g. the table grew past the size bound since the apply) is
rejected before anything is pinned for confirmation: a blocked change
guarantees the rollback would fail, so the target must be reconciled with a
follow-up schema change instead.

## Observability

Expand Down
2 changes: 2 additions & 0 deletions pkg/cmd/commands/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ func (cmd *PreviewCmd) Run(g *Globals) error {
case templates.PreviewCommentPlan, templates.PreviewCommentPlanBlocked,
templates.PreviewCommentPlanDirect,
templates.PreviewCommentApplyBlockedRejected,
templates.PreviewCommentRollbackPlanDirect,
templates.PreviewCommentRollbackBlockedRejected,
templates.PreviewCommentPlanTenant,
templates.PreviewCommentPlanEmpty,
templates.PreviewCommentNoManagedSchema,
Expand Down
2 changes: 2 additions & 0 deletions pkg/cmd/internal/templates/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ const (
PreviewCommentPlanBlocked PreviewType = "comment_plan_blocked" // Plan with a statement the engine refuses (blocked verdict)
PreviewCommentPlanDirect PreviewType = "comment_plan_direct" // Locked plan with a statement routed to direct execution (direct verdict)
PreviewCommentApplyBlockedRejected PreviewType = "comment_apply_blocked_rejected" // Apply rejected: plan contains engine-blocked statements
PreviewCommentRollbackPlanDirect PreviewType = "comment_rollback_plan_direct" // Rollback plan with a reverse statement routed to direct execution
PreviewCommentRollbackBlockedRejected PreviewType = "comment_rollback_blocked_rejected" // Rollback rejected: reverse plan contains engine-blocked statements
PreviewCommentPlanTenant PreviewType = "comment_plan_tenant" // Tenant-targeted plan comment
PreviewCommentPlanEmpty PreviewType = "comment_plan_empty" // Plan comment with no changes
PreviewCommentNoManagedSchema PreviewType = "comment_no_managed_schema" // No managed schema changes in current PR
Expand Down
4 changes: 4 additions & 0 deletions pkg/cmd/internal/templates/preview_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ func previewCommentAllOutput() {
{"SUMMARY: MULTI-NAMESPACE COMPLETED", func() { fmt.Print(webhooktemplates.PreviewCommentSummaryMultiNamespaceCompleted()) }},
{"ROLLBACK STATUS: RUNNING", func() { fmt.Print(webhooktemplates.PreviewCommentRollbackStatus()) }},
{"SUMMARY: ROLLBACK COMPLETE", func() { fmt.Print(webhooktemplates.PreviewCommentRollbackSummaryCompleted()) }},
{"ROLLBACK PLAN (DIRECT-EXECUTION CHANGE)", func() { fmt.Print(webhooktemplates.PreviewCommentRollbackPlanDirect()) }},
{"ROLLBACK REJECTED (ENGINE-BLOCKED CHANGES)", func() { fmt.Print(webhooktemplates.PreviewCommentRollbackBlockedRejected()) }},
}

for i, s := range sections {
Expand Down Expand Up @@ -282,6 +284,8 @@ func previewCommentApplyFlowAllOutput() {
{"SUMMARY: MULTI-NAMESPACE COMPLETED", func() { fmt.Print(webhooktemplates.PreviewCommentSummaryMultiNamespaceCompleted()) }},
{"ROLLBACK STATUS: RUNNING", func() { fmt.Print(webhooktemplates.PreviewCommentRollbackStatus()) }},
{"SUMMARY: ROLLBACK COMPLETE", func() { fmt.Print(webhooktemplates.PreviewCommentRollbackSummaryCompleted()) }},
{"ROLLBACK PLAN (DIRECT-EXECUTION CHANGE)", func() { fmt.Print(webhooktemplates.PreviewCommentRollbackPlanDirect()) }},
{"ROLLBACK REJECTED (ENGINE-BLOCKED CHANGES)", func() { fmt.Print(webhooktemplates.PreviewCommentRollbackBlockedRejected()) }},
}
printSections(sections)
}
Expand Down
4 changes: 4 additions & 0 deletions pkg/cmd/internal/templates/preview_dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ func PreviewCLIOutput(previewType PreviewType) {
fmt.Print(webhooktemplates.PreviewCommentPlanDirect())
case PreviewCommentApplyBlockedRejected:
fmt.Print(webhooktemplates.PreviewCommentApplyBlockedRejected())
case PreviewCommentRollbackPlanDirect:
fmt.Print(webhooktemplates.PreviewCommentRollbackPlanDirect())
case PreviewCommentRollbackBlockedRejected:
fmt.Print(webhooktemplates.PreviewCommentRollbackBlockedRejected())
case PreviewCommentPlanTenant:
fmt.Print(webhooktemplates.PreviewCommentPlanTenant())
case PreviewCommentPlanEmpty:
Expand Down
21 changes: 21 additions & 0 deletions pkg/storage/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,27 @@ func (tc TableChange) UnsafeOptInReason() string {
return "unsafe schema change requires explicit opt-in"
}

// Execution-mode verdict values a stored plan carries per table change. The
// canonical vocabulary lives with the engines; storage keeps its own copies so
// this package stays dependency-free.
const (
executionModeBlocked = "blocked"
executionModeDirect = "direct"
)

// EngineBlocked reports whether the planner recorded that the engine
// deterministically refuses this change. A blocked change guarantees the
// apply fails, so gates on stored plans reject them instead of executing.
func (tc TableChange) EngineBlocked() bool {
return strings.EqualFold(tc.ExecutionMode, executionModeBlocked)
}

// DirectExecution reports whether the planner routed this change to direct
// execution as native DDL on the target.
func (tc TableChange) DirectExecution() bool {
return strings.EqualFold(tc.ExecutionMode, executionModeDirect)
}

// NamespacePlanData contains plan data for a single namespace. OriginalFiles is
// captured once for the namespace and applies to every table/artifact change in
// Tables and Artifacts.
Expand Down
2 changes: 1 addition & 1 deletion pkg/webhook/apply_execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ func (h *Handler) executeApply(
h.logger.Info("apply rejected: --defer-cutover on an all-direct plan; the pending confirmation is preserved",
"repo", repo, "pr", pr, "database", database, "environment", environment, "action", actionName)
h.postCommandError(repo, pr, installationID, actionName, environment, requestedBy,
fmt.Sprintf(msgDeferCutoverAllDirectConfirm, environment))
templates.RenderDeferCutoverAllDirectConfirm(environment, h.deploymentTenant()))
return
}

Expand Down
102 changes: 53 additions & 49 deletions pkg/webhook/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -658,13 +658,6 @@ func shardedUnsafeChanges(shards []*apitypes.ShardPlanResponse) []templates.Unsa
// cutover to defer, so the flag is refused instead of silently ignored.
const msgDeferCutoverAllDirect = "`--defer-cutover` has no effect on this plan: every change runs directly as native DDL, which has no cutover to defer. Re-run without the flag."

// msgDeferCutoverAllDirectConfirm rejects --defer-cutover at confirm time on
// an all-direct plan. The rejection preserves the pending confirmation — the
// lock still pins the plan the operator confirmed against — so the recovery
// is re-running apply-confirm without the flag, not restarting from apply.
// The format verb takes the environment for the coached command.
const msgDeferCutoverAllDirectConfirm = "`--defer-cutover` has no effect on this plan: every change runs directly as native DDL, which has no cutover to defer. The pending confirmation is preserved — re-run `schemabot apply-confirm -e %s` without the flag."

// shardedDirectChanges collects direct-execution per-shard changes, grouped by
// (table, reason) so a change present on several shards lists them together
// rather than repeating. Returns nil when the plan carries no per-shard
Expand Down Expand Up @@ -737,6 +730,57 @@ func shardedBlockedChanges(shards []*apitypes.ShardPlanResponse) []templates.Blo
return out
}

// blockedChangesData collects a plan's engine-blocked changes for rendering —
// the apply commands will reject these. Like the unsafe view, a sharded plan
// derives them per shard so a blocked change confined to one shard names the
// shard it applies to; otherwise the namespace-level view is used.
func blockedChangesData(planResp *apitypes.PlanResponse) []templates.BlockedChangeData {
if blocked := shardedBlockedChanges(planResp.Shards); len(blocked) > 0 {
return blocked
}
var out []templates.BlockedChangeData
for _, sc := range planResp.Changes {
if sc == nil {
continue
}
for _, t := range sc.TableChanges {
if !t.EngineBlocked() {
continue
}
out = append(out, templates.BlockedChangeData{
Table: t.TableName,
Reason: t.ModeReason,
})
}
}
return out
}

// directChangesData collects a plan's direct-execution changes for rendering —
// the policy routes these to native MySQL DDL. Derived the same way as the
// blocked view.
func directChangesData(planResp *apitypes.PlanResponse) []templates.DirectChangeData {
if direct := shardedDirectChanges(planResp.Shards); len(direct) > 0 {
return direct
}
var out []templates.DirectChangeData
for _, sc := range planResp.Changes {
if sc == nil {
continue
}
for _, t := range sc.TableChanges {
if !t.DirectExecution() {
continue
}
out = append(out, templates.DirectChangeData{
Table: t.TableName,
Reason: t.ModeReason,
})
}
}
return out
}

// buildPlanCommentData converts plan results into template data.
func buildPlanCommentData(schema *ghclient.SchemaRequestResult, planResp *apitypes.PlanResponse, environment, tenant, requestedBy string) templates.PlanCommentData {
data := templates.PlanCommentData{
Expand Down Expand Up @@ -820,48 +864,8 @@ func buildPlanCommentData(schema *ghclient.SchemaRequestResult, planResp *apityp
}
}

// Blocked changes — the apply commands will reject these. Like the
// unsafe view, a sharded plan derives them per shard so a blocked change
// confined to one shard names the shard it applies to.
if blocked := shardedBlockedChanges(planResp.Shards); len(blocked) > 0 {
data.BlockedChanges = blocked
} else {
for _, sc := range planResp.Changes {
if sc == nil {
continue
}
for _, t := range sc.TableChanges {
if !t.EngineBlocked() {
continue
}
data.BlockedChanges = append(data.BlockedChanges, templates.BlockedChangeData{
Table: t.TableName,
Reason: t.ModeReason,
})
}
}
}

// Direct-execution changes — the policy routes these to native MySQL DDL,
// derived the same way as the blocked view.
if direct := shardedDirectChanges(planResp.Shards); len(direct) > 0 {
data.DirectChanges = direct
} else {
for _, sc := range planResp.Changes {
if sc == nil {
continue
}
for _, t := range sc.TableChanges {
if !t.DirectExecution() {
continue
}
data.DirectChanges = append(data.DirectChanges, templates.DirectChangeData{
Table: t.TableName,
Reason: t.ModeReason,
})
}
}
}
data.BlockedChanges = blockedChangesData(planResp)
data.DirectChanges = directChangesData(planResp)

// Add lint violations (error-severity results are shown via UnsafeChanges instead)
for _, w := range planResp.LintNonErrors() {
Expand Down
Loading
Loading