siyuan-note/siyuan · error

template document tree plan requires reversible parent opera

Error message

template document tree plan requires reversible parent operations

What it means

A document-tree plan transaction must carry both DoOperations and UndoOperations so the generated documents can be rolled back; an empty list on either side triggers this error. The plan mechanism compensates by undoing the parent operation if any part of the tree creation fails, so reversibility is mandatory.

Source

Thrown at kernel/model/template_doc_tree.go:467

func AttachTemplateDocTreePlans(transactions []*Transaction) (attached bool, err error) {
	var target *Transaction
	for _, transaction := range transactions {
		if nil == transaction || "" == transaction.TemplateDocTreePlanID {
			continue
		}
		if nil != target || 1 != len(transactions) {
			return false, errors.New("a document tree plan must be applied in a single transaction")
		}
		target = transaction
	}
	if nil == target {
		return false, nil
	}
	if target.isReplay {
		return false, errors.New("template document tree plans cannot be attached to replay transactions")
	}
	if 0 == len(target.DoOperations) || 0 == len(target.UndoOperations) {
		return false, errors.New("template document tree plan requires reversible parent operations")
	}
	if err = validateTemplateDocTreeParentOperations(target); nil != err {
		return false, err
	}
	for _, operation := range append(append([]*Operation{}, target.DoOperations...), target.UndoOperations...) {
		if nil != operation && ("restoreCreatedDoc" == operation.Action || "removeCreatedDoc" == operation.Action) {
			return false, errors.New("template document tree transaction contains a reserved operation")
		}
	}

	planID := target.TemplateDocTreePlanID
	target.TemplateDocTreePlanID = ""
	value, loaded := templateDocTreePlans.LoadAndDelete(planID)
	if !loaded {
		return false, errors.New("template document tree plan is missing or has expired")
	}
	plan, ok := value.(*templateDocTreePlan)
	if !ok || plan.id != planID || time.Now().After(plan.expiresAt) {

View on GitHub (pinned to 8641553a1f)

Solutions

  1. Populate UndoOperations with the inverse of every DoOperation when constructing the plan transaction
  2. Build the transaction through the official template rendering flow (renderTemplateSource) instead of hand-assembling operations
  3. Do not strip or clear UndoOperations before submission
  4. Verify both operation arrays are non-empty before calling the transactions API

Example fix

// before: missing undo side
tx := &Transaction{TemplateDocTreePlanID: planID, DoOperations: doOps}
// after
tx := &Transaction{TemplateDocTreePlanID: planID, DoOperations: doOps, UndoOperations: undoOps}
Defensive patterns

Strategy: validation

Validate before calling

// Go: verify both operation sides before submitting a plan transaction
reversible := len(tx.DoOperations) > 0 && len(tx.UndoOperations) > 0
if !reversible {
    return errors.New("plan transaction needs non-empty Do and Undo operations")
}

Try / catch

if _, err := AttachTemplateDocTreePlans([]*Transaction{tx}); err != nil && strings.Contains(err.Error(), "reversible") {
    return rebuildPlanTransactionWithUndo(tx)
}

Prevention

When it happens

Trigger: Submitting a transaction with TemplateDocTreePlanID set but with no undo operations (or no do operations) in performTransactions — typically a hand-constructed transaction missing the compensating operation list.

Common situations: Plugin or script authors building the plan transaction manually and forgetting UndoOperations; serialization dropping empty operation arrays; a code path that clears undo ops after partial failure and re-submits the transaction.

Understand the failure class

Background: "must not be empty", "cannot be empty" — required-field validation errors across open-source libraries — this error's family across 41 libraries.

Related errors


AI-assisted analysis of siyuan-note/siyuan@8641553a1f (2026-09-11). Data as JSON: /api/errors/43a964ddc5837c2f. Report an issue: GitHub.