Skip to content

Breaking: output_format override returning context.payload now renders the command context instead of declining, silently corrupting all CLI output — shipped under Fixed with no migration note, and ServiceOverrideDecision is not publicly exported #776

Description

@unbraind

Summary

2026.7.27 changed what an output_format service override's return value means. Returning
the inbound context.payload used to be the idiomatic "I decline this payload, render it
normally" signal. It now means "render exactly this", so an override that passes the payload
through makes every command in the workspace print the whole command context instead of its
own result.

This shipped under Fixed as "First-party service collision: builtin-calendar and
builtin-guide-shell both override the global output_format service (pm-ixoa)" — with no
BREAKING marker and no migration note for third-party override authors. The new contract
({ handled: false }) is also not discoverable from the public SDK surface (see below).

I found it because three of my own extensions silently corrupted all CLI output after the
upgrade.

Reproduction

An extension whose output_format override is a pure pass-through:

api.registerService("output_format", (ctx: ServiceOverrideContext) => ctx?.payload);
$ pm init probe --json >/dev/null && pm create --title "Real item" --type Task --json >/dev/null

$ pm list --limit 2              # BEFORE installing the extension
items:
  - id: "probe-yuf8"
    status: "open"
    type: "Task"
    title: "Real item"
count: 1

$ pm install ./pm-starter --project --json | jq .ok
true

$ pm list --limit 2              # AFTER — the item list is gone
global:
  quiet: false
  noChangedFields: false
  idOnly: false
  noExtensions: false
  noPager: false
  profile: false
  defaultOutputFormat: "toon"
format: "toon"
options:
  quiet: false
  noChangedFields: false
...

Every command is affected, not just the extension's own. Changing the decline path to the
documented decision object restores correct output:

api.registerService("output_format", (_ctx: ServiceOverrideContext) => ({ handled: false }));
$ pm list --limit 2
items:
  - id: "probe-yqx3"
    ...
count: 1

Why this was easy to get wrong

  1. The old pattern was the safe-looking one. Returning the payload unchanged reads as an
    inert pass-through, and it was one. Two of my extensions carried an explicit comment saying
    "return the incoming payload UNCHANGED (returning a fabricated value here would corrupt every
    command's output)" — advice that inverted meaning in 2026.7.27.
  2. It fails silently and globally. No error, no warning, exit code 0. pm health and
    pm validate both stay happy. The only symptom is that unrelated commands render the wrong
    thing, which looks like a host bug rather than an extension bug.
  3. ServiceOverrideDecision is not publicly exported. declineServiceOverride(),
    handleServiceOverride() and defineServiceOverride() are all in ./sdk and
    ./sdk/authoring per sdk/public-surface.json, but the type they return is not. So an
    author who cannot take a runtime dependency on the SDK — which is the documented situation for
    a standalone-installed extension that loads only its own dist/ — has no way to type the
    return and must hand-write { handled: false } against an unexported contract.

Suggested fix

  • Export ServiceOverrideDecision from ./sdk and ./sdk/authoring. Runtime helpers are
    exported but the type is not, which is backwards for the one case (standalone extension) that
    can only use the type.
  • Add a migration note for the semantics change, and ideally flag it as breaking for
    extension authors even though the underlying motivation was a first-party collision fix.
  • Consider warning on the ambiguous case: if an override returns an object that is
    reference-identical to context.payload, that is almost certainly a stale pass-through rather
    than a deliberate "render the context". A one-time diagnostic naming the extension would have
    turned three silent breakages into an immediate fix.

The third point is what would have saved the most time — the failure gave no indication which
extension was responsible.

Environment

  • pm-cli 2026.7.27 (@unbrained/pm-cli), Node 26.5.0, Linux
  • Affected extensions (now fixed): pm-graph, pm-starter, pm-slack-standup — all had a
    payload-echo decline path
  • pm-starter is a starter template, so the broken pattern was being handed to every new
    extension author as the reference implementation

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