Skip to content

JSON envelope shape differs between mutations and queries with no documented contract, and it silently cost us a production bug #888

Closed

Description

@unbraind

Observed

Probed against pm-cli 2026.8.3 in a throwaway workspace:

$ pm create --title "envelope probe" --type Task --json
{"id":"pm-gqpv","status":"open","changed_field_count":10}

$ pm update pm-ty5w --priority 1 --json
{"id":"pm-ty5w","status":"open","changed_field_count":1}

$ pm close pm-ty5w --reason "probe done" --json
{"id":"pm-ty5w","status":"closed","changed_field_count":4,"close_reason":"probe done"}

$ pm read pm-ty5w --json
{"item":{"id":"pm-ty5w","title":"envelope probe", ...}}

$ pm list --json
{"items":[{"id":"pm-ty5w", ...}]}

So mutations return a flat receipt ({id, status, changed_field_count}) while queries return a wrapped entity ({item: …} / {items: […]}).

That split is defensible as a design — a mutation receipt genuinely is not an entity. The problem is that it is undiscoverable: nothing in --help states it, and the two shapes are similar enough that an author who has seen pm read --json will reasonably assume pm create --json wraps too.

Why I am filing it

This is not hypothetical. It shipped a silent production bug in one of our extensions.

An importer needed the id of a freshly created item in order to run the follow-up pm close --reason (required since 2026.8.3 — see #887). It parsed the create output as:

const id = parsed?.item?.id;   // undefined, always

Because the parser returns undefined rather than throwing, nothing failed: pm create exited 0, the id was empty, the follow-up close was skipped, and every imported issue that was closed upstream landed open while the import reported success. The same undefined also silently disabled comment syncing. It went unnoticed through review and a full test suite, because the unit test asserted the same fabricated {item:{id}} shape the parser expected — test and parser agreed with each other rather than with the CLI.

The class of bug matters more than our specific instance: any consumer that guesses this shape wrong gets silence, not an error.

Requests (in preference order)

  1. Document the contract explicitly. State in --json help (and the docs) that mutation commands emit a flat receipt and query commands emit a wrapped entity, and that id is top-level on mutations. A one-line statement would have prevented this entirely.

  2. Ship a typed SDK helper so extensions never hand-parse a receipt:

    import { parseMutationReceipt } from "@unbrained/pm-cli/sdk";
    const { id, status, changedFieldCount } = parseMutationReceipt(stdout);

    This is the highest-value fix for agent authors: today every extension that needs a created id reimplements the same fragile parse. The SDK already owns the shape, so it should own the parser.

  3. Consider a machine-readable discriminant on --json output — e.g. a kind: "receipt" | "item" | "items" field — so a consumer can assert what it received instead of guessing. This would let a wrong assumption fail loudly at the boundary rather than silently downstream.

I am not asking you to unify the two shapes; changing them now would break every existing consumer. Documenting them and giving the SDK a parser gets the safety without the breakage.

Context

Found while upgrading a 20-package extension fleet to 2026.8.3. Related: #887 (no single-command way to create an item in a terminal state, which is why the importer needed to parse the created id in the first place — fixing that would remove this hazard for the whole importer class).

Activity

  1. coderabbitai commented on Aug 3, 2026

    @coderabbitai
    🔗 Related PRs

    #549 - feat(sdk): ship history time travel, rich reads, and runtime integrity [merged]
    #593 - SDK-first agent UX graph, ownership, and recovery contracts [merged]
    #617 - feat(sdk): harden multi-agent identity, merge fences, and typed mutations [merged]
    #624 - SDK atomic structured mutations, lean output, and agent-first envelopes [merged]
    #673 - feat(sdk): harden workspace mutation and migration primitives [merged]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    You can configure auto-planning by selecting labels in the issue_enrichment configuration.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  2. unbraind commented on Aug 4, 2026

    @unbraind
    OwnerAuthor

    Tracked in the existing uniform output-contract owner pm-srns.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions