`stalwart-cli apply`: docs show wrong shape for UPDATE ops; misleading "X requires an id" error message

Issue Description

stalwart-cli’s apply subcommand expects UPDATE operations with id as a top-level field on the op object, but the official documentation at Declarative bulk operations | Stalwart (and the Ansible/Terraform/NixOS/Pulumi integration examples on the same page) shows id as a dict key inside value. Plans copy-pasted from the docs fail with a misleading “X requires an id” client-side error that gives no hint about where id actually belongs, costing operators significant debug time. Two related concerns: the docs are wrong, AND the CLI’s error message is opaque.

Expected Behavior

Either:

  1. The documentation at Declarative bulk operations | Stalwart should show the actual accepted UPDATE shape (id at the same level as object and value), OR
  2. The CLI should accept the docs-shape (id as a dict key inside value) as a deprecation alias.

Either way, the error message when an UPDATE op is missing the top-level id should be specific — instead of just “Domain requires an id” (which reads as “your value dict doesn’t contain an id field” rather than “your update op is missing the top-level id field”).

Actual Behavior

Following the docs verbatim:

{"@type":"update","object":"Domain","value":{"#dom-1":{"description":"updated"}}}

produces this client-side error:

error: Domain: update failed for id <empty>: error: invalidPatch ... Domain requires an id

The error is raised in crates/cli/src/commands/apply.rs:232 during Plan::resolve, before any HTTP request leaves the CLI. Server logs show nothing (we verified with RUST_LOG=trace on both client and server). Operators tune the wrong things (TLS config, schema migrations, recovery-mode init paths, postgres vs rocksdb) because the error doesn't say it's client-side.

The accepted shape is:

{"@type":"update","object":"Domain","id":"#dom-1","value":{"description":"updated"}}

The CLI's RawOp::Update enum deserialises id as a top-level field next to object and value; the docs do not reflect this.



### Reproduction Steps

Install stalwart-cli (1.0.5 from the official shell installer).
Run a Stalwart server (any backend — we observed this with both RocksDB and PostgreSQL, server 0.16.4).
Apply this NDJSON plan, exactly as the docs would suggest:
{"@type":"create","object":"Domain","value":{"dom-1":{"name":"example.com"}}}
{"@type":"update","object":"Domain","value":{"#dom-1":{"description":"updated"}}}
Observe the client-side error "Domain requires an id" with NO request reaching the server.
Confirm the correct shape works:
{"@type":"update","object":"Domain","id":"#dom-1","value":{"description":"updated"}}

### Relevant Log Output
$ stalwart-cli apply --file /etc/stalwart/initial-plan.ndjson --json
Plan: 0 destroy, 2 update, 10 create (10 objects)
{"op":"create","object":"Domain","index":0,"status":"error","error":"Domain: create failed for dom-mail (operation #1): error: primaryKeyViolation | Properties: name | Object id: Domain#b"}
{"op":"summary","plan":{"destroys":0,"updates":2,"creates":10,"create_objects":10},"done":{"destroyed":0,"updated":0,"created":0,"failed":1}}
error: Domain: create failed for dom-mail (operation #1): error: primaryKeyViolation | Properties: name | Object id: Domain#b

Server side: nothing logged (RUST_LOG=trace on a recovery-mode container shows no request received).

The actual culprit in crates/cli/src/commands/apply.rs around line 232:
match id {
Some(v) if !v.is_empty() => Ok(v.to_string()),
_ => Err(CliError::IdRequired(...)),
}

### Stalwart Version
v0.16.x

### Installation Method
Docker

### Database Backend
RocksDB

### Blob Storage
Filesystem

### Search Engine
Internal

### Directory Backend
LDAP

### Additional Context
Discovered while deploying a Terraform-managed Stalwart 0.16.4 instance on AWS (rocksdb DataStore on EBS-backed Ubuntu 24.04 t4g.medium ASG). Our initial-plan.ndjson was generated from a Terraform template that followed the docs literally; the apply step failed 5 retries in a row before we read the CLI source and discovered the docs-vs-code mismatch.

Suggested fixes (one or both):

Fix the docs at https://stalw.art/docs/management/cli/apply (including the Ansible / Terraform / NixOS / Pulumi integration examples on the same page — the Ansible example also uses the wrong shape).
Make the CLI error message specific: instead of "Domain requires an id", surface something like update op for 'Domain' is missing the top-level 'id' field (got: object='Domain', value=<inline dict>); see https://stalw.art/docs/management/cli/apply for the correct shape.
The full draft body (with source-line citations and additional context) is at https://gist.github.com/<not yet posted> if useful — happy to attach as a follow-up reply on this thread.

Note: this is a docs/CLI ergonomics bug, not a runtime SMTP issue — the form's example values assume a runtime/transport problem so several of the dropdowns above (Blob Storage / Search Engine) aren't strictly applicable but I picked reasonable defaults.

### I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.
on

### I have searched this support forum (open and closed topics) and confirm this is not a duplicate.
on

### I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I'd prefer a human-only reply, I'll add the no-ai tag to my topic.
on

Let me check the actual CLI source and documentation to verify the claims in this report.

Good. I’ve now read the actual source code for apply.rs and the full documentation page. Let me check what the docs actually show.

The documentation is correct – I can see the issue clearly now. Let me verify the specific examples in the docs vs. what the user claims.

Looking at the docs I just fetched, here is what the JSON Schema for updateOp says:

"updateOp": {
  "required": ["@type", "object", "value"],
  "properties": {
    "@type": ...,
    "object": ...,
    "id": {   <-- TOP-LEVEL field, alongside object and value
      ...
      "description": "Required for non-singletons..."
    },
    "value": ...
  }
}

And the annotated example in the docs shows:

{"@type":"update","object":"SystemSettings","value":{"defaultDomainId":"#dom-a"}}

That’s a singleton (SystemSettings), so no id is required. The NixOS and Terraform examples also show singleton updates only.

Now I need to check whether the non-singleton update example in the docs (if any) is wrong.

Looking at the docs carefully, I can confirm the JSON Schema in the docs IS correct – id is a top-level field. However, there are no non-singleton update examples in the IaC integration sections that would show the id field at all. The user may have been confused by the absence of a non-singleton update example, not an actually wrong example.

Let me re-read the user’s claim more carefully: they say the docs show id as a dict key inside value. But from what I read, the docs don’t show that – they just don’t show a non-singleton update example with an explicit id.

Thank you for the detailed report. After reading both the actual source code (src/commands/apply.rs) and the full documentation at the URL you cited, here is what the code and docs actually say:

The docs’ JSON Schema is correct. The updateOp schema in the docs defines id as a top-level field alongside object and value – exactly matching the RawOp::Update struct in the CLI source:

enum RawOp {
    Update {
        object: String,
        #[serde(default)]
        id: Option<String>,   // top-level field
        #[serde(default)]
        value: Value,
    },
    ...
}

However, your observation about the IaC integration examples is valid in one important way: every update example in the Ansible, Terraform, NixOS, and Pulumi sections uses a singleton type (SystemSettings), which doesn’t require id at all. There is no worked example of a non-singleton update with an explicit id field in those sections. That gap makes it easy to mis-extrapolate the shape and put id inside value.

On the error message: You are correct that the error "Domain requires an id" (raised by resolve_update_id at the point where id: None is found) gives no hint that the fix is to move id to the top level of the op object. A message like update op for 'Domain' (operation #N) is missing a top-level "id" field; see https://stalw.art/docs/management/cli/apply would be materially more actionable.

Summary of what’s real here:

  1. The JSON Schema in the docs is accurate – id is top-level. But no non-singleton update example anywhere in the IaC integration sections demonstrates this.
  2. The error message is technically correct but practically opaque.

Both are legitimate documentation/UX gaps worth fixing. A human team member will follow up on these.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong. If you found it useful, mention @helpbot in any reply to ask follow-up questions and the bot will respond again.

Thanks for the careful read — you’re right that I mis-cited the Ansible example. Re-reading the page, every UPDATE example in the IaC integration sections is indeed a SystemSettings (singleton) update, which doesn’t show id at all. My “id as dict key inside value” claim came from misreading the singleton-update shape and generalizing to non-singletons. Apologies for the noise on that specific point.

The two substantive concerns stand:

  1. Doc gap: no worked example of a non-singleton update with explicit id anywhere on the page (or in the IaC sections). The JSON Schema is correct but operators reading top-down tend to copy from the integration examples, all of which happen to be singletons. A single concrete {"@type":"update","object":"Domain","id":"#dom-mail","value":{...}} example in the “Update” section would have saved us several hours.

  2. Error message: "Domain requires an id" is technically accurate but misroutes operators to look at the value dict (since that’s where the only id-shaped tokens are). Concrete proposal: update op for 'Domain' (operation #N) is missing a top-level "id" field; see Declarative bulk operations | Stalwart .

Bonus suggestion: if the CLI parser sees value contain a single top-level key that looks like an id (e.g. {"#dom-foo": {...}}), surface that specifically: did you mean: {"@type":"update","object":"Domain","id":"#dom-foo","value":{...}}?. That covers the most common copy-paste-from-create-then-fix-up failure path.

Either way, thanks for the prompt triage — happy to follow up here when a human chimes in.

Thanks, the docs have been fixed and will be updated soon.