siyuan-note/siyuan · error

block operation context

Error message

block operation context [%s] must be text

What it means

A block operation's optional Context map must contain only string values. During contract conversion, contextFields values are dereferenced as *string; a nil value means a non-text (null/typed) value sneaked into the context, so blockOperationContract fails with the offending key named in the message. This protects the public contract, which only allows text context entries.

Solutions

  1. Ensure every Context entry is a non-nil string before building the operation; drop keys with null values
  2. Coerce non-text values to strings client-side (String(value)) or remove them
  3. Sanitize deserialized context maps: iterate keys and delete entries whose value is nil before calling the API
  4. If a non-text value must be stored, encode it as a string (e.g. JSON-encode the value) instead of a typed value

Example fix

// before
ctx := map[string]*string{"note": nil, "ref": strPtr("2024")}
op := &model.Operation{Action: "insert", Context: ctx}
// after
for k, v := range ctx {
  if v == nil {
    delete(ctx, k) // or substitute strPtr("")
  }
}
op := &model.Operation{Action: "insert", Context: ctx}
Defensive patterns

Strategy: validation

Validate before calling

const sanitizeContext = (ctx) => ctx && Object.fromEntries(Object.entries(ctx).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]));

Type guard

const hasTextOnlyContext = (op) => !op.Context || Object.values(op.Context).every(v => typeof v === 'string');

Try / catch

try {
  return convertOperationContract(op);
} catch (e) {
  if (String(e?.message ?? e?.msg).includes('must be text')) {
    op.Context = sanitizeContext(rawContext);
    return convertOperationContract(op);
  }
  throw e;
}

Prevention

When it happens

Trigger: Constructing a model.Operation whose Context map contains a nil pointer or non-string-typed value and passing it through blockOperationContract — e.g. plugin/kernel code copying a generic map[string]any into Context, or deserialized JSON with null values for some keys.

Common situations: JSON payloads like {"context":{"note":null}} deserialized to nil *string; generic map conversion helpers assigning any-typed values; old clients sending numeric/boolean context values that no longer fit the text-only contract.

Understand the failure class

Background: Schema validation failed / invalid input schema: payload rejected because its shape doesn't match the expected schema — this error's family across 28 libraries.

Related errors


AI-assisted analysis of siyuan-note/siyuan@9f775e8a12 (2026-09-19). Data as JSON: /api/errors/0cab70bc0f0ee897. Report an issue: GitHub.

Appendix: source

Thrown at kernel/api/contract_block_transaction.go:73

	if err != nil {
		return nil, err
	}
	if err = json.Unmarshal(data, &ret.RetData); err != nil {
		return nil, err
	}
	data, err = json.Marshal(operation.Context)
	if err != nil {
		return nil, err
	}
	var contextFields map[string]*string
	if err = json.Unmarshal(data, &contextFields); err != nil {
		return nil, err
	}
	if contextFields != nil {
		ret.Context = make(map[string]string, len(contextFields))
		for key, value := range contextFields {
			if value == nil {
				return nil, fmt.Errorf("block operation context [%s] must be text", key)
			}
			ret.Context[key] = *value
		}
	}
	return ret, nil
}

func blockOperationsContract(operations []*model.Operation) ([]*apicontract.BlockOperation, error) {
	if operations == nil {
		return nil, nil
	}
	ret := make([]*apicontract.BlockOperation, len(operations))
	for i, operation := range operations {
		converted, err := blockOperationContract(operation)
		if err != nil {
			return nil, err
		}
		ret[i] = converted

View on GitHub (pinned to 9f775e8a12)