Skip to content

release: 0.6.0 — typed get unwraps the API envelope (BREAKING), plus the release cut - #106

Merged
leet-c1 merged 6 commits into
mainfrom
release/v0.6.0
Aug 27, 2026
Merged

release: 0.6.0 — typed get unwraps the API envelope (BREAKING), plus the release cut#106
leet-c1 merged 6 commits into
mainfrom
release/v0.6.0

Conversation

@leet-c1

@leet-c1 leet-c1 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Everything needed to tag v0.6.0. The version is VCS-stamped from the tag, so there is no constant to bump.

The breaking change

All 12 typed get commands now print the resource, not the API's envelope.

apps get <id>   before: {"app":{"id":…}}   →   jq -r .id  →  null
                after:  {"id":…}           →   jq -r .id  →  2fsgTvx…

Every list command already emitted a flat row where .id worked. get was the outlier at the same tier, and --fields was no workaround — apps get <id> --fields id returned {"app":{"id":…}}, so jq -r .id was null there too.

Migrating: .app.id.id, .userView.user.id.id, --fields function.id--fields id. A --fields spec naming a wrapper key now exits 2 rather than returning less, so a script written against 0.5.x's documented --fields function.id fails loudly rather than silently.

Nothing is dropped. Every key the envelope carried beside the resource — expanded included — is now a top-level sibling. The family-B wrappers are not single-key (taskView carries 16 others), so hoisting the payload alone would have been silent data loss.

Deliberately unchanged: mutation output (apps create still returns {"app":…}, never projected) and c1i api, a raw passthrough. One asymmetry: mcp servers get has no id field at all — its identity is connectorId, documented in its --help.

Implementation reuses matchesPathAnyDepth, factored out of cmd/fields.go's existing depth-insensitive search, rather than adding a second search with its own rules. It refuses a same-depth tie and a sibling-name collision, returning the response untouched rather than partially unwrapped.

Also in 0.6.0

Two exit codes moved, both narrower failures that previously reported success or a generic error: auth whoami on a 200 carrying no usable identity now exits 6 instead of 0, and a non-object body moves from 1 to 6.

The ### Upgrading from 0.5.x section leads the release notes with all of the above.

Doc fixes found by auditing five surfaces against source

docs guide claimed the owner write takes 96-129s while apps owners --help said 45-150s — both real, measured at different scopes, and the binary contradicted itself on how long to wait. Two wrong exit codes in shipped guides (a 400 and a 409 both called exit 1; both are 2). CLAUDE.md attributed retries to internal/client, which contains none. README described get as passing the response through as-is.

Guards added

  • TestGuideExitCodeClaimsMatchTheTaxonomy — checks every exit-code claim in the embedded guides that sits beside an HTTP status against exitCode(), across all three phrasings the guides use
  • TestTypedGetExposesTopLevelID — asserts the exact top-level key set and every value for all 12 commands, so a leak or a loss fails rather than just a missing id
  • TestEveryTypedGetIsCovered — walks the tree so a 13th get cannot reintroduce the envelope

Verification

Full CI reproduced locally with the pinned tools (golangci-lint v2.11, gosec strict, gitleaks, govulncheck × 3 GOOS, -race, -shuffle=on). Live-validated against a test tenant. Reviewed across multiple adversarial rounds; the final review verified the merge lost no hunk from either side and the CHANGELOG cut is byte-exact.

Ledger: C147 (re-ranked MEDIUM → HIGH during this work), C59, C158, C167, C168, C170, C171.

🤖 Generated with Claude Code

leet-c1 and others added 6 commits August 27, 2026 18:35
Everything needed to tag v0.6.0, plus the doc defects that would otherwise
ship inside the binary. The version itself is VCS-stamped from the tag, so
there is no version constant to bump.

CHANGELOG: [Unreleased] becomes [0.6.0], a fresh empty [Unreleased] opens
above it, and the link refs move to compare/v0.6.0...HEAD.

Added an "Upgrading from 0.5.x" lead, because the one behavior change in this
release was filed under Fixed where nobody scanning for upgrade impact would
look: "auth whoami" on a 200 carrying no usable identity moves exit 0 -> 6,
and a non-object body moves exit 1 -> 6. It also states what did NOT change,
since "c1i api" now writes to stderr and a caller doing 2>&1 will see it.

Four doc fixes, all found by auditing the five doc surfaces against source:

  - cmd/docs_guide.go said the owner write takes "96-129s" while
    "apps owners --help", "apps add-owner", "apps remove-owner", README and
    cmd/agents.md all said "45-150s". Both measurements were real -- 96-129s
    was set-owners alone, 45-150s the union across all four owner-write paths
    -- but only agents.md stated its scope, so the binary contradicted itself
    on how long to wait. The guide now says 45-150s. The narrower figure
    survives only in apps_set_owners.go's comment, which names its own scope.

  - cmd/docs_guide.go said the unresolvable-policy-step error on
    "tasks approve" is exit 1. cmd/tasks.go:187 wraps it in &usageError{},
    which is exit 2. Verified against a built binary, not "go run": go run
    does not propagate the child's exit status and reports 1 for every
    non-zero exit, which is a plausible way the wrong number got written.

  - the adjacent bullet asserted exit 1 for "action not permitted", which
    depends on the API's status for that refusal and is recorded nowhere. It
    now says the code follows the API's status instead of naming one.

  - CLAUDE.md put the retry logic in internal/client/client.go. That file
    contains none of it -- sendWithRetry, isRetryableStatus and nextBackoff
    are all in internal/transport/transport.go; internal/client only forwards
    the options.

README also gains the login carve-out: PollForToken appends
WithMaxRetries(0) after the caller's opts, so --max-retries does not reach the
device-flow poll leg -- RFC 8628's interval is that call's retry strategy.

Not in this release, and awaiting an owner decision: C147 (typed "get" returns
the raw envelope, so jq -r .id yields null -- breaking to fix) and C76 (a
negative --limit is silently unlimited). Both are cheapest to land in a minor
bump; deferring them means 0.7.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All 12 single-resource `get` commands printed the response verbatim, which
wraps the resource under the endpoint's own key, so `jq -r .id` returned null
at exit 0 while every `list` row already exposed a flat `.id`. `--fields id`
did not work around it: depth-insensitive matching found the value and then
rebuilt the wrapper, emitting `{"app":{"id":"…"}}`.

Measured live on the test tenant, the envelopes are two families:

  depth 1  {"app":{"id":…}}                 apps, policies, automations,
                                            functions, mcp tools ("tool"),
                                            mcp toolsets ("profile"),
                                            mcp servers catalog
                                            ("catalogEntry"),
                                            mcp servers ("mcpServer")
  depth 2  {"userView":{"user":{"id":…},…},  users, requests ("taskView.task"),
            "expanded":[…]}                 entitlements
                                            ("appEntitlementView.
                                            appEntitlement")

Two findings that shaped the fix. `mcp servers get` has no `id` field at all —
its identity is `connectorId`, which is why its list rows emit connector_id —
so writeResource takes the id key as a parameter rather than assuming "id".
And the depth-2 wrappers are NOT single-key: userView carries 6 more keys
(userId, objectPermissions, four *Path strings), taskView 16, appEntitlementView
4. Hoisting the payload alone would have dropped them, so every envelope key
the unwrap passes over is kept as a top-level sibling — `expanded` included,
which is what the design constraint asked for and now falls out of the general
rule instead of being special-cased.

Locating the payload reuses cmd/fields.go's depth-insensitive search, factored
into matchesPathAnyDepth (behavior-preserving for --fields, which still takes
the first match). The unwrap is the stricter consumer: it refuses a same-depth
tie, refuses when a sibling name collides with a payload key (a payload with
its own `expanded` is that case), and returns the bytes untouched on any shape
it cannot unwrap losslessly. The payload is re-extracted from the original
bytes rather than re-marshalled, so key order, number precision, and string
escaping are byte-identical to what the API sent.

`--fields` composes after the unwrap: `--fields id` now yields `{"id":"…"}`,
and the zero-match exit-2 rule is unchanged. Mutation confirmations still go
through writeRawObject unprojected — `apps create` still answers under an
`app` key.

Tests: TestTypedGetExposesTopLevelID and TestTypedGetFieldsProjectsUnwrapped
drive all 12 commands end-to-end through httptest with their real envelopes;
both fail on all 12 before the wiring. TestEveryTypedGetIsCovered walks the
command tree so a 13th `get` cannot ship uncovered. TestUnwrapEnvelope* pin
the hoist, the expanded-exclusion, the collision refusals, and raw-byte
fidelity; verified red by stubbing unwrapEnvelope to a passthrough and, for
the collision cases, by disabling the collision check.

cmd/docs_guide.go's approve/deny walkthrough documented `requests get` output
as `taskView.task.state` etc.; those paths are now top-level. The `c1i api`
examples in that guide still show wrapper paths, correctly — `api` is raw
passthrough and is unchanged.

Live-validated against the test tenant: all 12 commands exit 0 with a
top-level id, the three *View responses keep `expanded` and their sibling
keys (key sets byte-compared against the pre-change responses: identical),
`--fields id` returns one key, and a bogus `--fields` still exits 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…uard the class

Review of 138a3f0 came back REQUEST_CHANGES. Three of the four findings are
mine; one is a regression that commit introduced.

The regression: 138a3f0 replaced "96-129s" with "45-150s" in the set-owners
guide. That sentence names "GET .../ownerids", which is exactly the scope
96-129s was measured at -- four set-owners writes read through ownerids.
45-150s is the "apps owners" (= GET .../owners) figure across four different
write paths. So the edit put the wrong endpoint's number on the sentence and
left behind the "(four measured writes; a fifth had not landed at 108s)"
evidence, which belongs to 96-129s.

138a3f0's own message diagnosed the original defect as MISSING SCOPE, not a
wrong number, then applied the opposite remedy. This restores 96-129s and adds
the scope clause the message had already written for the CHANGELOG, plus the
wider spread and what it is measured on, so neither figure floats free.

Two more wrong exit codes in the same file, both pre-existing and both missed
by the five-surface audit that caught the third:

  - "apps set-owners exits 1 with a 400 naming a regex pattern" -- every 4xx
    outside the 401/403/404/408/429/499 carve-out maps to exitUsage, so 2.
  - "409 duplicate ticket found ... (exit 1)" -- cmd/errors.go names 409
    explicitly as an exitUsage example, so 2.

README: my carve-out sentence stole an antecedent. "It does not apply to the
docs subcommands" originally referred to all four things the shared transport
provides; three inserted sentences later its nearest referent had become
"--max-retries still governs the rest of the login handshake", so the paragraph
contradicted its own scope two lines down. The docs carve-out is back adjacent
to the claim it qualifies with an explicit subject, and the login note is its
own paragraph.

And the guard, because this fact has now drifted and been hand-corrected twice:
TestGuideExitCodeClaimsMatchTheTaxonomy extracts every exit-code claim from the
embedded guides that sits beside an HTTP status and checks the pair against
exitCode(). It reads both phrasings the guides use -- "(exit N)" and "exits N
with a 4xx" -- because the second is not decoration: it is one of the three
pairs and it carried a wrong code. Claims with no status on the line are left
alone; there is nothing to check them against.

Proven red-then-green: reverting the 409 fix fails the test naming the guide,
the claimed code, the status and the correct code. Its anti-vacuity floor is
measured at the three pairs that exist, not guessed -- a first attempt guessed
the floor and tripped its own tripwire, which is the class the test exists to
catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-false claims

The get-unwrap merge landed its own entry under Changed, but the Upgrading
section still named only the two whoami exit codes -- the same burial this
release notes section was added to fix, one commit later. It now leads with the
breaking change: what the shape was, what it is, which paths lose a hop, that
nothing is dropped, that mutation output and "c1i api" are unchanged, and that
"mcp servers get" has no id at all.

Two claims went false the moment get unwrapped, both verified against live
output before rewriting:

  - README described get as passing the API response through as-is and told
    readers "--fields function.id still works too". There is no function key
    left to resolve. The depth-insensitive search it describes is still real,
    so the bullet now scopes it to where the envelope survives: "c1i api" and
    mutation output.

  - cmd/agents.md offered "userView" as its example of camelCase in
    single-object reads. "users get" no longer emits it -- the keys are now
    createdAt, delegatedUserId, ... -- so the example is replaced while the
    casing point it illustrates stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two of these are mine, from the integration commit whose purpose was retiring
false claims.

The Upgrading section promised "Nothing else changes an exit code." The unwrap
changed a third one, and this release's own Changed bullet says so: a --fields
spec naming a wrapper key now exits 2 rather than returning less. 0.5.x
documented `--fields function.id` as supported, so a script written against
that doc hard-fails on upgrade -- and the reassurance would have excused it
from the audit. Now stated as its own migration.

My README rewrite attributed the depth-insensitive search to mutation output,
where --fields never runs at all: mutation confirmations go through
writeRawObject, which never touches projection. A reader following it would
expect `apps create --fields displayName` to project and get the full envelope
with no error. The search is now scoped to `c1i api`, and mutation output is
described as unprojected -- which is what README:502 and cmd/agents.md:156
already said correctly.

Survivor sweep miss: cmd/fields.go:109 and :159 still described single-object
reads as passing the envelope through -- in the file C147 edited, immediately
above the function it factored out. Both now scope the rationale to `c1i api`
and say typed `get` unwraps before projecting.

Also: moved an orphaned paragraph that rendered as a continuation of the
`.claude/worktrees/` gitignore bullet while describing appOwners measurements,
onto the appOwners bullet, trimmed of counts that bullet already gives -- the
cut is the last cheap chance to fix where it lands in published notes. Dropped
a seam comment in cmd/client.go that newListClient's own comment already made.
Widened the exit-claim guard's floor message, which blamed the extraction when
a legitimate doc edit dropping a pair is equally likely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@leet-c1
leet-c1 merged commit 54fcfbc into main Aug 27, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant