Skip to content

Shared Go SDK for the CLI, MCP server and Webex Admin Studio - #20

Merged
edumansky merged 36 commits into
mainfrom
shared-webexapi-and-auth-json
Nov 14, 2026
Merged

edumansky merged 36 commits into
mainfrom
shared-webexapi-and-auth-json

Conversation

@edumansky

@edumansky edumansky commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR moves the core Webex API logic into a Go SDK in pkg/. The CLI, its MCP server and Webex Admin Studio all use it.

Go SDK

  • Transport (pkg/webexapi):
    • hosts for every data region and Contact Center data center (--cc-region, WEBEX_CC_REGION, webex config set cc-region);
    • org ID conversion;
    • a Contact Center pacer for SDK callers;
    • a list pager that fails instead of ending a list short.
  • Operation table (pkg/webexops): codegen writes one exported *Operation per API call instead of cobra code. internal/apicmd builds the command tree from it at startup.
  • Object model (pkg/resource, pkg/catalog, pkg/webex, pkg/guard, pkg/fakeapi): moved from Webex Admin Studio.
    • Each type names the operations it uses, so a collection refresh that drops one fails the build.
    • The SDK holds API facts only. Presentation and Terraform naming stay in Studio.
    • The write guard stops further writes after any failure, including writes already reading before they send.
  • Change set file format (pkg/changeset): the format Studio imports and exports, with its validation and JSON Schema.
  • AI Agent Studio: token lookup and redaction live in pkg/aiagent, for Studio to use too. The CLI's requests are unchanged.
  • Golden files (testdata/golden/) record every command's flags and help, and the exact request each API command sends. CI runs the Go and codegen tests, and the weekly collection refresh runs make golden.

Other CLI changes

  • Contact Center lists: seven older list-v1/list-v2 routes, which the catalog lists through. --paginate reads past full bare-array pages, which those routes return.
  • Auth: auth list and auth token --output json, and auth token --min-ttl, which Studio uses to get a token.
  • MCP:
    • webex_help refuses flags, because a trailing value flag could swallow --help and run the command;
    • the webex_write description warns that a Contact Center update replaces the whole object.

Removed during review

  • Change set recording (--changeset, the webex changeset commands, and the webex_propose/webex_changeset MCP tools) is taken out, to be redesigned in its own PR. Intercepting arbitrary write commands kept producing new review findings. The file format package stays, since Studio uses it.
  • webex resource.
  • Terraform naming helpers: moved to Webex Admin Studio's Rust repo (its commit 43866e6, not pushed).
  • Smaller items: a duplicate pacer, auth status --output json, and pacing of ordinary CLI requests.

Commit note: the files deleted when recording was removed landed in 64235b9, the guard commit, instead of b140441. The branch's contents are correct.

Compatibility notes

  • Swift Studio: its Go tool still calls the moved Terraform helpers, so it no longer builds against this branch. The Rust port builds and passes its tests.
  • Contact Center data centers: only us1 was exercised live; the others follow Cisco's host naming.

Validation

  • gofmt, go vet ./..., go test ./... (one package at a time), the golden test, the codegen unit tests and the site build all pass.
  • Webex Admin Studio's Rust repo passes its Go tests against this branch, and so do cargo xtask tools and cargo test -p studio-engine --test apply (41 passed, with real tofu).
  • Reviews: six Codex rounds and three Fable rounds on the branch, plus Codex and Fable reviews of the trim plan, with their findings posted on this PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

edumansky and others added 15 commits November 4, 2026 22:47
…rograms

- pkg/webexapi: the CLI's HTTP transport as a public package with no
  process state (401 refresh, 429 retry, structured errors, fail-closed
  paging). internal/client now sends through it.
- pkg/changeset and --changeset: record write commands as a portable
  change set file instead of sending them, with `webex changeset`
  new/show/validate/intent/schema and the webex_propose and
  webex_changeset MCP tools.
- auth token --min-ttl and JSON output for auth token, status and list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
auth status now resolves the requested user once, for both text and JSON
output. Successful MCP calls return stderr as a separate text item for
every tool, which also covers change set warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Every command's name, aliases, help and flags, and the exact request each
API command sends for a fixed set of flags (all flags, required flags only,
--body, --paginate), answered by a recording transport with no network.
Rebuilding how commands are made must leave these files unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Hosts gives the base URL of every Webex API for a Contact Center data
center and a data region. The pacer, from Webex Admin Studio, spaces
Contact Center requests below the rate Webex refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
codegen now writes pkg/webexops: one exported *Operation per API call,
with its route, parameters, body fields, paging and CLI names, and a
Call that sends it through webexapi. internal/apicmd builds the cobra
commands from that table at startup, replacing about 82,000 lines of
generated command code with about 31,000 lines of data. Every command,
flag, help text and request matches the golden files unchanged.

make test runs the Go and codegen tests, and make golden rewrites the
golden files; the weekly collection refresh runs it so its PR shows
command changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
--cc-region (or WEBEX_CC_REGION, or `webex config set cc-region`) picks
the Contact Center and AI Agent Studio hosts from webexapi.Hosts, which
were fixed to us1. Every request in a process now shares a pacer that
keeps Contact Center below the rate it refuses. Base URLs and org ID
conversion come from the SDK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Each operation carries its Go name as ID. Seven Contact Center list
routes that Webex Admin Studio reads, and that Cisco's collection
replaced with newer versions, come from a supplemental collection as
`list-v1` (`list-v2` for queues). The golden files gain those seven
commands and nothing else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
pkg/resource defines object types as data, pkg/catalog lists the types,
pkg/webex reads and writes them, pkg/guard is the write protocol (conflict
check, read-back, stop after a failure), and pkg/fakeapi is the fake Webex
the tests run against. They come from Webex Admin Studio unchanged in
behavior, with two differences:

- Each type names the pkg/webexops operations it uses for list, create,
  read, update and delete, instead of URL templates. Catalog tests check
  each operation's method and host and that every path parameter is
  bound. Route.Template gives the old template form for change set files.
- Presentation (sidebar folder, icon, columns, form sections) is left to
  Studio. Definitions hold API facts only.

The catalog was compared field by field with Studio's before the move,
and Studio's catalog output for its app is byte-for-byte the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
webex resource list-types, describe, list, get, set, create and delete
work from the SDK catalog. set reads the object and sends only the named
fields' changes, as the whole object where the route replaces it (every
Contact Center PUT), so fields the command does not name survive. Writes
go through the write guard: a conflict check, a read-back of the result,
and no retry after a failure that may have committed. Field values are
checked against the catalog (known field, kind, options, immutability).

--changeset records set, create and delete like other writes, and
--dry-run prints them. MCP tool descriptions point agents at resource
set for changing objects. internal/client.SDK gives SDK packages a
transport set up from the CLI's flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
pkg/aiagent looks up an agent's runtime token from the v2 agent list,
redacts Studio credentials from responses, and Call sends a private
Studio operation with both done. The CLI's internal/aiagent keeps only
the one-time private-API notice and calls the SDK with this run's login,
org and hosts. Requests are unchanged (golden files pass).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
README and the docs site describe the Go SDK packages and the resource
commands, with new Resource Commands and Go SDK pages. The skill tells
agents to change objects with resource set rather than a raw update that
erases what it leaves out. --cc-region joins the global flag lists, and
AGENTS.md lists the SDK packages and their boundary with the CLI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
webexapi.List reads Contact Center lists with any page size parameter
and offset lists with any parameter names, SCIM's 1-based startIndex and
totalResults, or Link headers only, finding the items array when a route
does not name it. A list that cannot be read to its end still fails.
webexops.List pages an operation the way its table entry says, and
aiagent.BotToken now uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
The golden files only protect the published commands if they are
checked on every pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

@edumansky edumansky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex review (gpt-6-astra, reasoning effort: high)

The existing Go and codegen tests pass, but targeted checks reproduce wrong-organization recording, lost edits, invalid dependencies, and broken resource capture workflows. Live validation also reports success when verification cannot complete.

Comment thread internal/client/capture.go Outdated
Comment on lines +66 to +69
err = changeset.Edit(path, newChangeSet, func(cs *changeset.ChangeSet) error {
if org := config.OrgID(); org != "" && cs.Scope.OrgID != org {
return fmt.Errorf("%s is for org %s, but this command is for org %s", path, cs.Scope.OrgID, org)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Validate the request's actual organization before recording

When a command supplies --orgid or --org-id different from the configured organization, those flags change the request but not config.OrgID(). This check therefore accepts the wrong scope, while captureTarget removes the request's organization. For example, a skill creation targeting organization B is recorded as a creation in organization A. Derive and validate the effective request organization before stripping it, or reject conflicting overrides.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in 1f99f93: captureTarget reads the request's own org (the Contact Center {orgid} path parameter, or the orgId query decoded to a UUID) and refuses the request when it differs from the --organization org that the change set records for. Regression test: TestCaptureRefusesARequestForAnotherOrg (a CC create and a Calling update for another org are both refused, and nothing is recorded).

Comment thread internal/client/capture.go Outdated
Comment on lines +103 to +110
changes := changeset.Leaves(fields)
if current != nil {
changes = changeset.Diff(current, fields)
}
if len(changes) == 0 {
return map[string]any{"recorded": nil, "unchanged": true}, nil
}
id := cs.AddUpdate(target, changes)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Honor edits that restore a pending field's live value

If the live name is Desk, recording Front and then recording Desk reports the second command as unchanged but leaves Front in the change set. Comparing only against live state discards the second edit before it can replace the pending value, violating the latest-value-wins behavior. Reconcile explicitly supplied fields with existing changes before dropping unchanged values, removing pending changes that have been reverted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, with one limit. Fixed in 1f99f93: an update now withdraws the pending change at any path it explicitly sets back to the live value (ChangeSet.Withdraw), and drops the operation if nothing is left. "Explicitly" means every field of a Webex Calling body, and for Contact Center the paths the caller names with Request.Sets, which resource set does for its fields. A raw Contact Center PUT carries the whole object as read, so a field it repeats from Webex is not taken as a revert: otherwise recording capacity=4 from a fresh read would silently undo an earlier name change. Regression tests: TestCaptureSettingAFieldBackWithdrawsItsPendingChange, TestCaptureWholeObjectPutKeepsEarlierChanges, TestResourceSetBackToTheLiveValueWithdrawsOnlyThatChange, TestWithdrawRemovesChangesAndEmptiedUpdates.

Comment thread pkg/changeset/record.go Outdated
Comment on lines +80 to +84
func (cs *ChangeSet) AddUpdate(target Target, changes []Change) string {
if op := cs.find(target.Key()); op != nil && op.Action == Update {
for _, c := range changes {
mergeChange(op, c)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve dependency order when merging repeated updates

If an existing object is updated as op-1, a new object is created as op-2, and another update references that new object's handle, merging into op-1 creates a forward reference. All recording commands succeed, but changeset.Validate then rejects the file because references must name earlier creates. Reorder merged operations after their dependencies, or otherwise preserve valid dependency ordering.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in 1f99f93: after a merge, AddUpdate moves the update to just after the last create its new values refer to. UpdateCreate had the same problem when a pending create came to refer to a later create, so it does the same, and refuses (changing nothing) when an operation it would move past refers to the create being moved. Regression tests: TestAnUpdateThatComesToReferALaterCreateMovesAfterIt, TestACreateThatComesToReferALaterCreateMovesAfterIt (both check Validate).

Comment thread internal/client/livecheck.go Outdated
Unreadable []string `json:"unreadable,omitempty"`
}

func (r LiveReport) OK() bool { return len(r.Drift) == 0 && len(r.Missing) == 0 }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Fail live validation when objects cannot be read

When live reads fail with authentication errors, HTTP 500s, or network failures, CheckLive populates Unreadable, but this predicate still returns true. Consequently, changeset validate --live emits "valid": true and exits successfully even when none of its preconditions were checked. Include unreadable targets in the failure condition so callers can distinguish successful verification from an unavailable check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in 1f99f93: LiveReport.OK also requires Unreadable to be empty, so changeset validate --live reports "valid": false when it could not check an object. Regression test: TestLiveCheckIsNotOKWhenAnObjectCannotBeRead. The Change Sets page says so.

Comment thread internal/client/capture.go Outdated
Comment on lines +145 to +149
t.Route = req.path
if t.API == changeset.APIContactCenter {
if m := ccOrgPrefix.FindStringSubmatch(t.Route); m != nil {
t.Route = t.Route[len(m[0]):]
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reject or represent Contact Center routes outside the org prefix

Commands such as cc flow patch-draft use /{orgid}/project/{projectId}/v2/flows/{flowId}, so this branch retains the entire path while labeling it as an organization-relative cc target. readTarget subsequently prepends /organization/{orgid}, producing a nonexistent URL and reporting an existing flow as missing. Either support host-relative routes throughout the format and reader or reject these requests instead of recording an unusable target.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in 1f99f93 by rejecting: the format defines Contact Center routes as relative to /organization/{orgId} (pkg/changeset/types.go), so captureTarget now refuses a CC route without that prefix, such as cc flow patch-draft, instead of recording a target that reads back wrong. Regression test: TestCaptureRefusesContactCenterRoutesOutsideTheOrgPath. README, skill and site list it as not recordable.

Comment thread cmd/resource.go Outdated
Comment on lines +152 to +158
key, err := objectKey(ctx, c, t, args[1])
if err != nil {
return err
}
fresh, err := c.Read(ctx, t, key)
if err != nil {
return readError(t, key, err)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Handle pending-create IDs before reading resource state

After resource create cc_skill ... --changeset file returns a placeholder such as new:review, resource set cc_skill new:review name=Revised --changeset file attempts to read that placeholder from Webex and fails with not found. It never reaches the capture logic that folds updates into pending creates. Detect placeholder IDs in capture mode before the live read and merge the supplied fields into the recorded create.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in 1f99f93: with --changeset, resource set on a new:<handle> ID skips the live read and sends the named fields as a patch, which capture folds into the recorded create. Regression test: TestResourceSetOfARecordedCreateFoldsIntoIt.

Comment thread cmd/resource.go Outdated
Comment on lines +216 to +218
if config.ChangesetPath() != "" || config.DryRun() {
parent, _ := desired[t.ParentField].(string)
return sendRaw(t.Routes.Create, resource.Key{Parent: parent}, t.CreateBody(desired))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Include copied create fields in captured resource requests

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in 1f99f93: the SDK now has webex.Client.CreateBody, which Create itself uses and which applies CopyOnCreate. The capture and dry-run path of resource create calls it, so the recorded body is the one the SDK would send. Regression test: TestResourceCreateRecordsTheBodyTheSDKWouldSend (a desktop layout records the global layout's jsonFileContent).

- Refuse a request whose org differs from the change set's, instead of
  recording it under the wrong org.
- Refuse Contact Center routes outside /organization/{orgid}, which the
  change set format cannot name.
- Setting a field back to its live value withdraws its pending change, for
  Webex Calling bodies and the fields named to resource set. Fields a
  whole-object Contact Center PUT repeats leave earlier changes in place.
- Move an update or create after a later create it comes to refer to, so
  merged edits keep references pointing at earlier creates.
- validate --live is not valid when an object could not be read.
- resource set on a new:<handle> folds into the recorded create, and
  resource create records the body the SDK would send, including the copied
  desktop layout file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
@edumansky

Copy link
Copy Markdown
Contributor Author

Codex review: outcome

Codex (gpt-6-astra, high effort) reviewed 3b3c2d7 and raised 7 findings. All 7 were valid, and all are fixed in 1f99f93 with a regression test each.

Finding Outcome Regression test
[P1] Validate the request's actual organization before recording Fixed: requests for an org other than --organization are refused TestCaptureRefusesARequestForAnotherOrg
[P2] Honor edits that restore a pending field's live value Fixed for explicitly set fields (Calling bodies, resource set). A raw Contact Center whole-object PUT still doesn't undo earlier changes, by design TestCaptureSettingAFieldBackWithdrawsItsPendingChange, TestCaptureWholeObjectPutKeepsEarlierChanges, TestResourceSetBackToTheLiveValueWithdrawsOnlyThatChange
[P2] Preserve dependency order when merging repeated updates Fixed: merged updates (and creates) move after the create they now refer to TestAnUpdateThatComesToReferALaterCreateMovesAfterIt, TestACreateThatComesToReferALaterCreateMovesAfterIt
[P2] Fail live validation when objects cannot be read Fixed: unreadable objects make validate --live invalid TestLiveCheckIsNotOKWhenAnObjectCannotBeRead
[P2] Reject or represent Contact Center routes outside the org prefix Fixed: rejected TestCaptureRefusesContactCenterRoutesOutsideTheOrgPath
[P2] Handle pending-create IDs before reading resource state Fixed TestResourceSetOfARecordedCreateFoldsIntoIt
[P2] Include copied create fields in captured resource requests Fixed: new webex.Client.CreateBody is used by both paths TestResourceCreateRecordsTheBodyTheSDKWouldSend

README, the skill and the Change Sets page describe the new recording rules.

Checks

  • go build, go vet ./..., gofmt -l: clean
  • go test ./...: pass, golden files unchanged
  • Codegen unit tests: pass
  • npm run build in site/: pass
  • Webex Admin Studio's Go build and tests against this branch: pass

🤖 Generated with Claude Code

https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

- webex_propose refuses "--", after which the server's --changeset flag
  would be an argument and the write would be sent, and accepts only
  write commands of the API areas and resource.
- The SDK pagers fail instead of looping when a page of any size repeats,
  and the object client pages each list as the operation table says.
- --paginate reads past a full bare-array Contact Center page.
- A DELETE that names no object is recorded as an invoke with its body.
- Change sets check org IDs in Webex paths, refuse an update to an object
  they delete, and merge updates past invokes on the same object.
- resource set records against the object it read, for types whose item
  route cannot be read.
- Parallel 401s refresh the token once, without a data race.
- --dry-run records nothing in a change set.
- A null field no longer counts as an array when finding a list's items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

@edumansky edumansky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fable review (fable, reasoning effort: high)

Review of PR #20 at 1f99f93 (git diff origin/main...1f99f93). All reproductions were throwaway tests against pkg/fakeapi or httptest stubs; no live Webex calls.

P0: none found.

[P2] --paginate on the new cc … list-v1 commands stops at 100 items — internal/client/pagination.go:170 (outside this PR's diff); commands added by codegen/naming_overrides.py:28-36 and codegen/supplemental/Contact Center lists.postman_collection.json

  • The CLI pager treats a bare array as the only page. The v1 site, user, team, skill, entry-point and business-hours lists return bare arrays, per pkg/fakeapi and the comment at pkg/webexapi/pager.go:55.
  • Reproduced with 151 skills in the fake: cc skill list-v1 --paginate returned 100 with no error, while resource list cc_skill returned 151.
  • I did not confirm the bare-array shape on a live tenant.

Checked, no finding

  • Request building (internal/apicmd, webexops): I generated the golden files from origin/main's generated commands using the PR's test harness. All six requests-*.json files and the API part of commands.json match the PR's, apart from the seven new list-v1/list-v2 commands. The goldens do not exercise --last, --body-file, or pages after the first; I checked those by reading only.
  • Python codegen: re-running extract_api_spec.py, generate_cli.py and generate_skills.py on a copy of 1f99f93 reproduces the committed pkg/webexops/zz_*.go and skill files exactly. The codegen unit tests pass once api_spec.json exists; make test on a fresh clone fails until it does.
  • Contact Center host selection, token redaction in debug and dry-run output, read-only and dry-run gating of resource set/create/delete, guard full-PUT bodies: no defect found.

Comment thread cmd/mcp/dispatcher.go Outdated
if errText != "" {
return mcplib.NewToolResultText(errText), nil
}
args = append(args, "--changeset="+path)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] webex_propose sends the write live when the command contains a -- token

(args built at :192)

  • The handler splits command on whitespace and then appends --changeset=<path> and --output=json.
  • With command cc team delete --id=team1 --, cobra treats everything after -- as positional arguments. Leaf commands accept extra positionals, so capture mode is never turned on.
  • Result: the DELETE goes to Webex from a tool described as "Nothing is sent to Webex". Clients may auto-approve that tool.
  • A read-only server is not affected, because the child process still refuses writes.
  • Reproduced at the CLI level, not through an MCP client: running that exact argument list against the fake committed DELETE /organization/org1/team/team1 and created no change set file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in 87b5064: the dispatcher refuses a -- token in any tool, so the flags it appends can never become arguments. Regression test: TestProposeRefusesWhatItCannotRecord (cc team delete --id=team1 --).

Comment thread internal/client/capture.go Outdated
id, err := cs.RemoveCreate(handle)
return map[string]any{"recorded": id, "action": "removed create"}, err

case method == http.MethodDelete:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Bulk DELETEs are recorded without their body, merged together, and leave an invalid file

Also pkg/changeset/record.go:221.

  • Every non-new: DELETE becomes AddDelete(target), which drops the body.
  • 21 DELETE operations have routes that do not end in an ID, for example calling numbers delete-phone-location and calling call-queue delete-bulk-supervisors.
  • Reproduced: two delete-phone-location --phone-numbers ... --changeset f runs with different numbers both printed {"action":"delete","recorded":"op-1"}.
  • The file held one delete on /telephony/config/locations/{locationId}/numbers with no numbers.
  • changeset validate then failed with "the target route of a delete must end with the object's ID".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in 87b5064: a DELETE to a route that names no object (target.ID() empty) is now recorded as an invoke with its body, so two runs make two operations and the file validates. Regression test: TestCaptureBulkDeleteIsAnInvokeWithItsBody.

Comment thread pkg/webexapi/pager.go Outdated
Comment on lines +78 to +92
if len(items) == ccPageSize && bytes.Equal(items[0], previousFirst) {
return fmt.Errorf("%w: %s repeated page %d", ErrIncompleteList, listURL, page)
}
if len(items) > 0 {
if err := visit(items); err != nil {
return err
}
previousFirst = items[0]
}
switch {
case totalPages > 0 && page+1 >= totalPages:
return nil
case totalPages == 0 && len(items) < ccPageSize:
return nil
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The SDK pager loops forever when a page holds more than 100 items, and the catalog pages no-paging endpoints with start/max

Also :164-181; callers pkg/webex/webex.go:206, :352.

  • The repeated-page check only fires when len(items) == 100. A response of 150 items is neither short nor a repeat, so the loop never ends.
  • Reproduced with a stub that always returns 150 items: ListOffset and ListCC each made 21 requests and visited 3150 items before the test stopped them.
  • pkg/webex calls ListOffset with fixed start/max and ignores the operation's own Paging. The table marks dectNetworks, cxEssentials/wrapup/reasons, announcements/playlists, announcementLanguages and lineKeyTemplates as having no paging parameters, and /people and /locations as Link-only.
  • Consequence if those endpoints ignore start/max (not checked live): webex resource list dect_network hangs with growing memory once an org has more than 100.
  • Related, reproduced: an endpoint that ignores start, holds exactly 100 items and sends no Link header fails with incomplete list … repeated a page.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in 87b5064, in two parts. Both pagers now treat any page that starts with the previous page's first item as a repeat, whatever its size, so an endpoint that ignores the paging parameters gives ErrIncompleteList instead of a loop. And pkg/webex lists each type with the paging the operation table records: the five lists with no paging parameters are read in one request (plus Link headers), and /people and /locations page by Link alone. Every other catalog list still uses start/max. Regression tests: TestPagersFailRatherThanLoopOnARepeatedOversizedPage, TestAListWithoutPagingParametersIsReadInOneRequest.

Comment thread internal/client/capture.go Outdated
Comment on lines +58 to +61
if !isNew && req.method != http.MethodPost {
if current, err = fetchCurrent(req); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not read %s (%v); recording without preconditions\n", target.Path(), err)
} else {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] resource set cc_work_type … --changeset records the whole object as changes, with no preconditions

Also :106; reached from cmd/resource.go:187-192.

  • resource set sends the full-object body through capture, which re-reads the item route to diff against.
  • Work type item reads return 406 (pkg/catalog/center.go:391), so the diff falls back to Leaves(body).
  • Reproduced with a 406 stub: setting description=zz-new recorded active, description, id, lastUpdatedTime, name and workTypeCode, all without before.
  • The same happens for any full-PUT type whenever that re-read fails; the only signal is a stderr warning.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in 87b5064: client.Request.Current gives capture the object the caller already read, and resource set passes the object it read through the SDK, so capture no longer re-reads the item route. Regression tests: TestCaptureDiffsAgainstTheObjectTheCallerRead, TestResourceSetRecordsOnlyItsChangesWhenTheItemRouteCannotBeRead (the second read fails, as for work types). A raw command whose re-read fails still records without preconditions, with the existing stderr warning.

Comment thread internal/client/capture.go Outdated
Comment on lines +163 to +183
var org string
t.Route = req.path
if t.API == changeset.APIContactCenter {
m := ccOrgPrefix.FindStringSubmatch(t.Route)
if m == nil {
return t, fmt.Errorf("%s is not under /organization/{orgid}, so it cannot be recorded in a change set", req.path)
}
org = req.pathParams[m[1]]
t.Route = t.Route[len(m[0]):]
if t.Route == "" {
t.Route = "/"
}
}
for k, v := range req.queryParams {
if strings.EqualFold(k, "orgId") {
org = config.DecodeOrgID(v)
}
}
if org != "" && org != config.OrgID() {
return t, fmt.Errorf("this request is for org %s, but change sets record changes for the --organization org (%s)", org, config.OrgID())
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The change-set org check misses org IDs in Webex paths

  • The check covers the Contact Center path org and an orgId query, but not {orgId}/{orgid} in webexapis.com paths. 31 write operations have one, such as /identity/organizations/{orgId}/… and /contacts/organizations/{orgid}/contacts/{contactId}.
  • Reproduced: admin org-contacts update --orgid 9999… --organization 1111… --changeset f recorded an update with params.orgid = 9999… in a file whose scope.orgId is 1111….

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in 87b5064: every path or query parameter named orgId (any case) must decode to the --organization org. Regression test: TestCaptureRefusesAnotherOrgInAWebexPath.

Comment thread pkg/changeset/record.go Outdated
// already in the change set merge into its operation: the latest value wins and
// the first precondition is kept. It returns the operation's ID.
func (cs *ChangeSet) AddUpdate(target Target, changes []Change) string {
if i := cs.index(target.Key()); i >= 0 && cs.Operations[i].Action == Update {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] An update recorded after a delete of the same object appends duplicates

(index() at :284)

  • index() returns the first non-create operation on the target. When that is a delete or an invoke, AddUpdate appends a new update every time and never merges.
  • Reproduced: resource delete cc_team team1, then two resource set runs, gave op-1 delete, op-2 update and op-3 update, each reported as recorded. changeset validate then failed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in 87b5064: AddUpdate now returns an error for an object the change set deletes, and index finds only the update or delete of an object, so updates merge past an invoke on the same route. Regression tests: TestAnUpdateToADeletedObjectIsRefused, TestAnUpdateMergesPastAnInvokeOnTheSameObject.

Comment thread internal/client/sdk.go Outdated
if err != nil {
return err
}
config.SetToken(newToken)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Data race on the token, and one refresh per goroutine, when parallel reads get a 401

(also client.go:106), from pkg/webex/webex.go:299

  • resolve runs 10 reads at once; each 401 calls Refresh, which calls config.SetToken unsynchronized while others read config.Token().
  • Reproduced under go test -race: resource list hunt_group with three injected 401s reported a data race in config.SetToken and ran the refresher 3 times.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in 87b5064: config.Token/SetToken are guarded by a lock, and both transports share one refreshToken. It serializes refreshes, and a 401 within 30 seconds of a refresh retries with the new token instead of refreshing again. Regression test: TestConcurrent401sRefreshTheTokenOnce (5 parallel 401s, one refresh, clean under -race).

Comment thread internal/client/client.go Outdated
return body, status, headers, err
// Capture runs before read-only checks: it never writes to Webex, so a
// read-only login can propose changes.
if path := config.ChangesetPath(); path != "" && !readOnlyAllows(req.method, req.path) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] --dry-run is ignored when --changeset is set

  • Capture runs before the dry-run check. Reproduced: resource set cc_team team1 name=zz-dry --changeset f --dry-run created the file with op-1.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in 87b5064: capture is skipped in a dry run, so the request is printed and nothing is recorded. Regression test: TestDryRunWithAChangeSetRecordsNothing. The Change Sets page says so.

Comment thread cmd/mcp/dispatcher.go Outdated
Comment on lines +64 to +85
// Proposing never writes to Webex, so it is offered in read-only mode too.
s.AddTool(
mcplib.NewTool("webex_propose",
mcplib.WithDescription(
"Record a Webex CLI write command (create, update, delete) in a change set file instead of running it. "+
"Nothing is sent to Webex. The CLI reads the object, records only the fields the command changes, "+
"and keeps the values it saw as preconditions. A create returns a placeholder ID like \"new:sales-team\" "+
"that later proposals can use in flags or bodies. Start the file with webex_changeset action \"new\", "+
"then give the file to the person, who can review and apply it in Webex Admin Studio.",
),
mcplib.WithString("changeset",
mcplib.Required(),
mcplib.Description("Path of the change set file, ending in .json (e.g. /path/to/boulder.changeset.json)"),
),
mcplib.WithString("command",
mcplib.Required(),
mcplib.Description("Write CLI command without 'webex' prefix (e.g. 'calling hunt-group update', 'cc team create')"),
),
mcplib.WithString("flags",
mcplib.Description(`Optional flags as a JSON object string (e.g. {"location-id": "xxx", "hunt-group-id": "yyy", "body": "{\"name\":\"Front Desk\"}"}). Keys are flag names without '--'.`),
),
),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] webex_propose runs any non-blocked command, including on read-only servers

Also :218-227.

  • Propose mode has no action check. A read-only server previously exposed only read actions; it now reaches auth token (prints the access token, as JSON because --output=json is appended) and auth set-folder-default / clear-folder-default.
  • Not executed; from reading the code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in 87b5064: webex_propose accepts only commands under admin, calling, cc, device, meetings, messaging or resource that resolve to a write action. auth token, auth set-folder-default, reads and unresolved commands are refused. Regression test: TestProposeRefusesWhatItCannotRecord.

Comment thread pkg/webexapi/pager.go
switch {
case totalPages > 0 && page+1 >= totalPages:
return nil
case totalPages == 0 && len(items) < ccPageSize:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Places the SDK list can end short or fail on a valid response

  • pkg/webexapi/pager.go:90: a Contact Center bare array, or {data} without totalPages, shorter than 100 ends the list. Reproduced: a stub capping pages at 50 with 100 items returned 50 and no error.
  • pkg/webex/webex.go:170: a 400, 403 or 404 on one location's list silently drops that location from a per-location type. From reading the code.
  • pkg/webexapi/pager.go:212: null counts as an array when probing for the only array. Reproduced: {"objects":[…],"next":null} fails with "response has several arrays". This path serves the limit/offset lists, including BotToken.

@edumansky edumansky Nov 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly valid. The null case is fixed in 87b5064: a null field no longer counts as an array (TestOffsetPagerIgnoresNullFieldsWhenFindingTheArray). I left the other two. (1) A short Contact Center page ends the list only when the response has no totalPages, and only if the server silently returns fewer items than the pageSize asked for. Nothing in the collections or our live testing shows a Contact Center list doing that, but our live tenants have not had more than 100 objects of a type either, so this is unproven in both directions; I would rather not guess a smaller page size. (2) A 400, 403 or 404 on one location's list means that location cannot have the feature (for example, it is not a Calling location). Skipping it is how per-location types list the org; a 5xx or network failure still fails the list.

@edumansky

Copy link
Copy Markdown
Contributor Author

Fable review: outcome

Fable (high effort) reviewed 1f99f93 and raised 11 findings, with no P0. I fixed 10 of them in full and the 11th in part, all in 87b5064, with a regression test for each fix.

Finding Outcome Regression test
[P1] webex_propose sends the write live with a -- token Fixed: -- is refused TestProposeRefusesWhatItCannotRecord
[P2] Bulk DELETEs recorded without their body Fixed: recorded as invoke with the body TestCaptureBulkDeleteIsAnInvokeWithItsBody
[P2] --paginate on list-v1 stops at 100 (review body) Fixed: a full bare-array page leads to the next page; a repeated one ends the list TestDoPaginatedPagesFullBareArrays, TestDoPaginatedStopsWhenABareArrayRepeats
[P2] SDK pager loops on oversized pages; catalog ignores the table's paging Fixed: any repeated page is an error; lists use the table's paging TestPagersFailRatherThanLoopOnARepeatedOversizedPage, TestAListWithoutPagingParametersIsReadInOneRequest
[P2] resource set cc_work_type --changeset records the whole object Fixed: capture diffs against the object resource set read TestCaptureDiffsAgainstTheObjectTheCallerRead, TestResourceSetRecordsOnlyItsChangesWhenTheItemRouteCannotBeRead
[P2] Org check misses org IDs in Webex paths Fixed TestCaptureRefusesAnotherOrgInAWebexPath
[P3] Update after delete appends duplicates Fixed: refused; updates merge past invokes TestAnUpdateToADeletedObjectIsRefused, TestAnUpdateMergesPastAnInvokeOnTheSameObject
[P3] Token data race on parallel 401s Fixed: locked token, one shared refresh TestConcurrent401sRefreshTheTokenOnce (-race)
[P3] --dry-run ignored with --changeset Fixed: dry run records nothing TestDryRunWithAChangeSetRecordsNothing
[P3] webex_propose runs any command Fixed: API-area and resource writes only TestProposeRefusesWhatItCannotRecord
[P3] SDK list can end short or fail null fixed; short CC page and per-location 4xx left as intended (see reply) TestOffsetPagerIgnoresNullFieldsWhenFindingTheArray

README, the skill and the Change Sets page describe the changed rules.

Checks

  • gofmt -l, go vet ./..., make check: clean
  • go test ./... (one package at a time): pass; golden files unchanged
  • -race on the token refresh test: clean
  • Codegen unit tests: pass (54)
  • npm run build in site/: pass
  • Webex Admin Studio's Go build and tests against this branch: pass

🤖 Generated with Claude Code

https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

edumansky and others added 3 commits November 9, 2026 19:54
Contact Center answers 412 for a feature the org does not have, such as
Generated Summaries, so a snapshot skips that type instead of failing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
A field can now state the text it accepts, as a pattern with the rule in
words. A Contact Center global variable's name carries one: Webex takes only
ASCII letters, digits and underscores, up to 80 characters. A skill's name is
fixed once the skill exists, because Webex refuses a PUT that changes it.

Create and update check both rules before any request, as resource create and
set do while reading their arguments, so Studio's applies are covered too. A
value refused this way is a definite rejection, not an unknown outcome:
nothing was written. A field fixed at creation can still be set on an object
that exists only in a change set, where it goes into the create.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcXCiUuYGW54Cn7uwC
- A change set target's query is part of its identity, so updates to two
  users' meeting preferences stay separate operations.
- Capture and the live check read the object with the request's query,
  which can select it.
- A POST is recorded as a create only when the API has a route to one
  object under it; call control and other actions are invokes.
- Change set files are created owner-only and keep an existing file's mode.
- SDK paging sends the caller's headers on every page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

@edumansky edumansky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex review (gpt-6-astra, reasoning effort: high)

The Go and Python test suites pass, but uncovered cases can merge proposals for different objects, discard intended changes, expose recorded credentials, and break SDK requests requiring headers.

Comment thread pkg/changeset/types.go
Comment on lines +231 to +236
func (t Target) Key() string {
parts := []string{t.API, NormalizeRoute(t.Route)}
for _, name := range Placeholders(t.Route) {
parts = append(parts, t.Params[name])
}
return strings.Join(parts, "\x00")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Include query selectors in change-set target identity

When two updates select different objects through query parameters, Target.Key() treats them as the same target. For example, recording /meetingPreferences/audio updates for two different userEmail values merges both into one operation, retaining the first user's query but applying the second user's changes. Include identifying query parameters in the target key so proposals cannot overwrite changes intended for another object.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in 98c826a. Target.Key() now ends with the query parameters, sorted by name, so two userEmail values are two objects and their updates stay separate operations. orgId never reaches Target.Query (capture checks it against the change set's org instead), so the org does not split targets. Regression test: TestTargetsThatDifferOnlyInTheirQueryAreDifferentObjects.

Comment thread internal/client/capture.go Outdated
Comment on lines +223 to +226
for k, v := range req.queryParams {
if strings.EqualFold(k, "orgId") {
get.QueryParam(k, v)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve target selectors when reading change-set baselines

For commands such as meetings preferences update-audio-options --user-email ..., dropping every query parameter except orgId reads the authenticated user's preferences instead of the requested user's. If the requested value matches the authenticated user's value, capture incorrectly reports unchanged and records nothing; otherwise it records the wrong precondition. Preserve the target-selecting parameters here and in readTarget, which also omits Target.Query during live validation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in 98c826a. fetchCurrent now sends every query parameter of the write, and readTarget (used by changeset validate --live) sends Target.Query. Both now read the object the query selects. Regression test: TestCaptureAndLiveCheckReadTheObjectTheQuerySelects. It records the selected user's value as before when the caller's own value matches the new one, and it checks that --live finds drift on that object.

Comment thread pkg/webexops/operation.go
case op.Area == "cc":
p = webexapi.Paging{CC: true, SizeParam: op.Paging.SizeParam}
}
return c.List(ctx, u, p, visit)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Forward operation headers through SDK pagination

webexops.List silently drops args.Header when delegating to the pager. Consequently, listing MeetingsRecordingsListGroup with a hostEmail header and no personId sends neither required selector, while AI Agent lists lose their supplied bot-access-token. Carry the request headers through every page request, as Call already does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in 98c826a. webexapi.Paging has a Header that the CC and offset pagers send on every page request, and webexops.List fills it from args.Header in the same way Call does. Regression test: TestListSendsTheCallersHeadersOnEveryPage. It lists MeetingsRecordingsListGroup across a Link page and checks hostEmail on both requests.

Comment thread pkg/changeset/file.go Outdated
Comment on lines +64 to +67
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
return err
}
return os.Rename(tmp.Name(), path)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep recorded credentials in owner-only files

Recording commands such as calling call-routing create-trunk --password ... stores credentials verbatim in the change set. This unconditional chmod makes them readable by other local users in a traversable directory, even with umask 077 or an existing file protected with mode 0600. Keep new files owner-only and avoid broadening existing permissions during replacement.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in 98c826a. Save now creates a new change set as 0600 and gives a replaced file its existing mode, so it never widens permissions. The Change Sets page says why. Regression test: TestSaveKeepsChangeSetsOwnerOnly, which checks both cases.

Comment thread internal/client/capture.go Outdated
Comment on lines +208 to +214
func isCollection(route string) bool {
if strings.Contains(route, "/actions/") {
return false
}
segments := strings.Split(strings.Trim(route, "/"), "/")
last := segments[len(segments)-1]
return last != "" && !strings.HasPrefix(last, "{") && !actionSegment.MatchString(last)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Record non-creating POST actions as invokes

POST action routes absent from actionSegment, such as /telephony/calls/answer, /hold, and /hangup, pass this collection test. Recording calling call-controls answer --call-id ... therefore produces a create operation and a placeholder ID for an object that will never be created, bypassing the warning provided for invokes. Classify these action endpoints explicitly or default unrecognized POST routes to invoke rather than assuming they create objects.

@edumansky edumansky Nov 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in 98c826a, with a rule based on the operation table instead of more verbs. A POST is a create only when the same API has a route to one object under it (<route>/{id}, or deeper, as with location schedules at /schedules/{type}/{id}). The actionSegment list is gone. I compared the old and new rules over every POST in the table. 90 distinct action routes become invokes, including call control, conference control, meeting participant actions, access-code and number adds, and recording restores. No catalog type's create changes. One route, /attachment/actions, becomes a create, which is right because it has an item route. Regression test: TestCapturePostToARouteWithNoItemsIsAnInvoke (calls/answer is an invoke and a hunt group POST is still a create).

@edumansky

Copy link
Copy Markdown
Contributor Author

Codex review 3: outcome

Codex (gpt-6-astra, high) reviewed 8618be0 and raised 5 findings. All 5 were valid, and all are fixed in 98c826a, each with a regression test that failed before the fix.

Finding Outcome Regression test
[P1] Change set targets ignore query selectors Fixed: the query is part of Target.Key() TestTargetsThatDifferOnlyInTheirQueryAreDifferentObjects
[P1] Capture and --live read without the query Fixed: both send the write's query TestCaptureAndLiveCheckReadTheObjectTheQuerySelects
[P2] SDK paging drops request headers Fixed: Paging.Header is sent on every page TestListSendsTheCallersHeadersOnEveryPage
[P2] Change set files made world-readable Fixed: new files 0600, replaced files keep their mode TestSaveKeepsChangeSetsOwnerOnly
[P2] Action POSTs recorded as creates Fixed: a POST is a create only when the API has an item route under it (90 action routes become invokes, no catalog create changes) TestCapturePostToARouteWithNoItemsIsAnInvoke

The Change Sets page describes the new create rule, the query's part in a target, and file permissions.

Checks

  • gofmt -l, go vet ./...: clean
  • go test ./..., run one package at a time: pass. Golden files unchanged.
  • npm run build in site/: pass
  • Webex Admin Studio's Go build and tests against this branch: pass

🤖 Generated with Claude Code

https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

edumansky and others added 2 commits November 12, 2026 22:01
- The guard checks for a stop again just before each write it sends, so a
  failure during another write's read stops it.
- Capture refuses a body that names another org.
- A DELETE with a body is recorded as an invoke that keeps the body.
- A list page of null fails instead of reading as no items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

@edumansky edumansky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex review (gpt-6-astra, reasoning effort: high)

The Go and codegen tests pass, but uncovered defects allow mutations after a guard failure, mis-scoped or incomplete change-set records, and malformed list responses to be accepted as complete inventories.

Comment thread pkg/guard/guard.go
Comment on lines +256 to +260
if d := resource.Changed(fresh.Values, prior, t.FieldNames()); len(d) > 0 {
return g.fail(&Failure{Conflict, "Conflict: this object changed after the plan was made", strings.Join(d, ", ")})
}
if err := g.API.Delete(ctx, t, key); err != nil {
return g.fail(writeFailure(err, "The delete", ""))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Recheck the stop flag immediately before sending mutations

When operations share a Guard concurrently, one can pass admit and block in Read while another fails and stops the guard. The first operation then resumes and sends its DELETE despite the failure; this is reproducible with a delayed read. Update and Create's follow-up PUT have the same gap. Synchronize mutation admission with the stop state after blocking reads, preserving the stop-after-failure invariant in AGENTS.md:114.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in 2b35081. A new halted() check of the stop flag runs again just before each request that writes: in write (Update and Create's second step) and before API.Delete. A failure that lands while a write is reading now stops that write before it is sent. A stop that lands after the request has gone out cannot recall it, but the read window Codex describes is closed. Regression test: TestAFailureDuringTheReadStopsTheWrite. Its API stops the guard from inside Read, and the test checks that neither Update nor Delete sends anything.

Comment thread internal/client/capture.go Outdated
Comment on lines +169 to +175
for _, params := range []map[string]string{req.queryParams, req.pathParams} {
for k, v := range params {
if org := config.DecodeOrgID(v); strings.EqualFold(k, "orgId") && org != config.OrgID() {
return t, fmt.Errorf("this request is for org %s, but change sets record changes for the --organization org (%s)", org, config.OrgID())
}
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Validate organization selectors in request bodies

Requests that select their organization through the JSON body bypass this check. For example, admin people create-person --organization A --body '{"orgId":"B",...}' --changeset proposal.json successfully records a file scoped to A whose create targets B. Validate body-level organization selectors too, normalizing UUID/base64 forms, so a proposal cannot silently target a different organization from its declared scope.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in 2b35081. Capture now checks the top-level orgId and organizationId keys of a JSON body. As for path and query parameters, it accepts the UUID or base64 form, and it refuses any org other than the change set's. An empty value is ignored. Regression test: TestCaptureChecksTheOrgABodyNames (another org is refused, the same org in base64 is recorded). The Change Sets page now says the path, query or body may name the org.

Comment thread internal/client/capture.go Outdated
Comment on lines +100 to +101
case method == http.MethodDelete && target.ID() != "":
return map[string]any{"recorded": cs.AddDelete(target), "action": changeset.Delete}, nil

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve bodies when recording single-object DELETE requests

A DELETE whose route ends in an ID always becomes a bodyless delete here, silently discarding supplied JSON. Existing commands such as calling converged-recordings delete and meetings transcripts delete accept reason and comment bodies, so their recorded proposals lose the deletion rationale and cannot reproduce the requested call. Record body-bearing deletes as invokes, or extend the delete representation to preserve their bodies.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in 2b35081. A DELETE on an item route is a plain delete only when it has no body. With a body, it is recorded as an invoke that keeps the method and body, the same as bulk deletes. Regression test: TestCaptureDeleteWithABodyIsAnInvoke (a converged recording delete with reason and comment). The Change Sets page lists deletes with a body under invoke.

Comment thread pkg/webexapi/pager.go
Comment on lines +98 to +100
func ccPage(body []byte) (items []json.RawMessage, totalPages int, err error) {
if err := json.Unmarshal(body, &items); err == nil {
return items, 0, nil

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reject null responses instead of treating them as empty lists

If a list request returns HTTP 200 with null, unmarshalling into a slice succeeds and this function reports an empty page. ListCC consequently returns success with an empty or truncated inventory rather than ErrIncompleteList, potentially making existing objects appear deleted. The offset pager similarly accepts bare null or a named array containing null. Require an actual JSON array to preserve the fail-closed paging contract in AGENTS.md:109.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in 2b35081. A new array() helper decodes list pages and refuses null, which json.Unmarshal would otherwise accept as a nil slice. The CC pager, the offset pager's bare-array path and namedArray all use it, so a null page gives ErrIncompleteList. ({"data":null} was already refused.) Regression test: TestPagersFailOnANullList. To check that no real list answers null, I listed all 77 catalog types on a live test org with this build, read-only, and every list succeeded.

@edumansky

Copy link
Copy Markdown
Contributor Author

Codex review 4: outcome

Codex (gpt-6-astra, high) reviewed 98c826a and raised 4 findings. All 4 were valid. They are fixed in 2b35081, each with a regression test that failed before the fix, and 3ac2ba9 updates the Change Sets page.

Finding Outcome Regression test
[P1] A guard stop during a read doesn't stop the write Fixed: the stop flag is checked again just before each write request TestAFailureDuringTheReadStopsTheWrite
[P2] An org named in the body isn't checked Fixed: top-level orgId/organizationId must be the change set's org TestCaptureChecksTheOrgABodyNames
[P2] A single-object DELETE drops its body Fixed: a DELETE with a body is recorded as an invoke with the body TestCaptureDeleteWithABodyIsAnInvoke
[P2] A null list page reads as empty Fixed: null is not an array, so the list fails with ErrIncompleteList TestPagersFailOnANullList

Checks

  • gofmt -l, go vet ./...: clean
  • go test ./..., run one package at a time: pass. Golden files unchanged.
  • npm run build in site/: pass
  • Webex Admin Studio's Go build and tests against this branch: pass
  • Live, read-only: all 77 catalog types listed without error on a test org with this build

🤖 Generated with Claude Code

https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

edumansky and others added 6 commits November 12, 2026 23:55
webex resource was never needed for change sets, which ordinary write
commands record. Removed with it:

- Request.Current and Request.Sets, which existed only for its recording;
- the duplicate pacer in pkg/webex (the SDK keeps pkg/webexapi's);
- the JSON form of auth status, which nothing reads;
- pacing of ordinary CLI Contact Center requests (SDK reads keep it);
- the resource MCP guidance. webex_propose now takes only calling and
  cc commands, the areas with types Studio can apply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Terraform addresses, resource names and import IDs are how Webex Admin
Studio names objects in its config, not facts about the Webex API, so
they move to Studio with the reference-cycle check that guards its
Terraform graph. Only Studio used them. Webex Admin Studio's Swift
build, which still calls them here, stops building against this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Recording guessed what any of the API's routes meant, and Studio refuses
a whole change set over one operation it cannot apply. A write is now
matched to the create, update or delete operation of a writable catalog
type, and anything else is refused with a pointer to the types that can
be recorded.

- The object is read through the catalog's reader, which knows each
  type's read route, including work types read by filter. A read that
  fails refuses the write instead of recording it without preconditions.
- An update records only the paths a catalog field owns, the rule Studio
  imports by, and reports the rest as ignored, so a whole body copied
  from a read records just what changed.
- Query parameters are refused, except the org and a repeated path ID:
  Studio would drop them. A create or update needs a JSON body, and a
  delete may not have one.
- A new: ID must name a create of the same type and parent.
- Operations find their object by catalog type and key, so a queue's
  delete replaces its pending update though the two use different routes.
- validate --live reads the same way; a route of no known type is
  unreadable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
- changeset drop removes an operation, reverting what it proposes, or
  some field changes from an update. A create something else refers to
  waits until that is dropped.
- changeset meta changes the title, rationale, author or links after new.
- changeset types lists the types a change set can record with their
  create, update and delete commands, and given a type, its fields: the
  paths an update records, which a create needs and which are fixed.

The MCP webex_changeset tool offers drop, meta and types too. types
needs no file, and meta clears a field given empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Help, the --changeset flag, the MCP tools, the docs page, README and
skill all say change sets are in beta, and changeset new says so once.

The guidance now matches recording: only types Studio applies can be
recorded (changeset types lists them), an update body holds only the
fields to change, even for Contact Center, and a proposal is never
applied by dropping --changeset. The invoke and query rules are gone,
and drop and meta are documented.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
The guard stopped further writes only after reading the object back to
describe the failed update, which can take seconds, so a concurrent
create or update could still be sent in between.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

@edumansky edumansky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex review (gpt-6-astra, reasoning effort: high)

The Go tests, SDK race tests, and code-generation tests pass. However, a reproduced concurrency defect allows additional writes after a failed update, violating the guard’s safety guarantee.

Comment thread pkg/guard/guard.go Outdated
Comment on lines +152 to +154
if err := g.API.Update(ctx, t, key, raw, delta); err != nil {
actual := g.observe(t, key, baseline)
return actual, g.fail(writeFailure(err, "The write", observed(actual, baseline, planned, t)))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Stop further writes before observing a failed update

During a concurrent apply, an update failure leaves the guard open while g.observe performs its recovery read, potentially for eight seconds. Other operations can pass admission and write during this window; a blocked recovery read reproduces a subsequent Create succeeding after the update has failed. Set the stop flag immediately upon receiving the update error, before observing its outcome, to preserve the stop-after-failure invariant in AGENTS.md.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in ff8e0be. When API.Update fails, write now sets the stop flag first, with the failure summary, and only then calls observe. A concurrent write that reaches admit during the recovery read is refused. Regression test: TestAFailedUpdateStopsOtherWritesBeforeItIsObserved. Its API fails the update and, from inside the observing read, checks whether admit would still let a write through: before the fix it did, now it does not. The guard tests also pass under -race.

@edumansky

Copy link
Copy Markdown
Contributor Author

Codex review 5: outcome

Codex (gpt-6-astra, high) reviewed c3fbee9, the trimmed PR. It raised one finding, which was valid and is fixed in ff8e0be.

Finding Outcome Regression test
[P1] A failed update leaves the guard open while it reads the object back Fixed: the stop flag is set as soon as the update fails, before the recovery read TestAFailedUpdateStopsOtherWritesBeforeItIsObserved

Checks

  • gofmt -l and go vet ./...: clean.
  • go test ./..., run one package at a time: passes.
  • The guard tests also pass under -race.
  • Webex Admin Studio's Rust repo provider tests pass against this branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

edumansky and others added 2 commits November 13, 2026 00:42
The server added --changeset=<path> after the agent's command, so a
command ending in a value flag such as --output swallowed it, and a
--changeset= in the command switched recording off; either sent the
write to Webex. The path now goes in $WEBEX_CHANGESET, which no command
token can reach, and a command that names --changeset is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
- Text that only starts with "new:", such as a description, stays text;
  only a handle-shaped placeholder is resolved.
- A null for a field Webex leaves out is not recorded as a change.
- An update whose fields were all ignored no longer reports unchanged.
- The docs say field rules are checked by Studio's import, not while
  recording, and that Studio does not apply clears yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
@edumansky

Copy link
Copy Markdown
Contributor Author

Fable 5.2 review (high effort) of ff8e0be: outcome

Fable reviewed the whole PR after the trim and raised 5 findings. 4 are fixed, in a18607d and 7dc354b. The 5th is left as designed.

Finding Outcome Regression test
[P1] webex_propose sends a live write when the command ends in a value flag (cc team delete --output swallowed the added --changeset=) Fixed in a18607d. The change set path now reaches the CLI in $WEBEX_CHANGESET, which no command token can reach. A command that names --changeset is refused, which also closes a second way in I found while checking: --changeset= (empty) switched recording off. TestProposeAlwaysRecords
[P2] Recording does not apply Studio's field checks (fixed fields, clears, required fields, value rules), so Studio's import refuses such a file Left as designed. The owner chose to keep field-level checks in Studio's import and out of the CLI. The docs and AGENTS.md now say so instead of implying every recorded file imports. The related no-op (a null for a field Webex leaves out) is fixed in 7dc354b. TestDiffSkipsNullForAnAbsentField
[P3] Docs say null clears a value, but Studio refuses clears Fixed in 7dc354b: the docs say Studio does not apply clears yet. —
[P3] Any body text starting with new: is treated as a placeholder Fixed in 7dc354b: only a handle-shaped new:<handle> is resolved, so other text stays text. TestTextThatStartsWithNewIsNotAPlaceholder
[P3] An update whose fields were all ignored reports unchanged: true Fixed in 7dc354b: it reports only ignored. TestCaptureOnlyIgnoredFieldsIsNotUnchanged

Checks

  • gofmt -l and go vet ./...: clean.
  • go test ./..., run one package at a time: passes, and the golden files are unchanged.
  • The docs site builds.
  • The Rust Studio's importer and catalog tests pass against this branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

edumansky and others added 3 commits November 13, 2026 18:51
A create that copies from an existing object, such as a desktop layout,
reads before it posts, and the guard checked for a stop only before
those reads. The SDK now runs a check given through the context just
before it sends a write, and the guard passes its stop flag that way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
Recording writes into change set files through intercepted CLI commands
kept turning up new defects in review, so it leaves this branch to be
redesigned on its own. Removed: the --changeset flag and
$WEBEX_CHANGESET, the recorder and live check, the webex changeset
commands, the MCP webex_propose and webex_changeset tools, the catalog
matcher and Type.Split that only recording used, and pkg/changeset's
recording helpers. pkg/changeset keeps the file format, validation and
the helpers Webex Admin Studio's importer and exporter use.

Also: webex_help refuses flags in its command, because a trailing value
flag swallowed --help and ran the command instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
- The offset pager ended a list on any short page, so a SCIM list whose
  totalResults said more remained came back short with no error. A stated
  total now ends the list only when reached, and a page with no items
  before then is ErrIncompleteList.
- A failed AI Agent Studio call redacted credentials in the response but
  not in the error, whose message repeats the body. APIError.WithBody
  rebuilds the error from a redacted body, and both the SDK's Call and
  the CLI's AI Agent commands use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

@edumansky edumansky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex review (gpt-6-astra, reasoning effort: high)

Go and codegen tests pass, and comparison against baseline golden output confirms existing CLI requests are preserved. However, targeted reproductions demonstrate silent SDK list truncation and credential leakage through SDK errors.

Comment thread pkg/webexapi/pager.go Outdated
Comment on lines +191 to +194
if next != "" || p.Start == "" || len(items) < offsetPageSize || (total > 0 && seen >= total) {
return nil
}
start += len(items)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Honor totalResults before treating a short page as complete

When a SCIM endpoint returns fewer than 100 resources but totalResults indicates more remain, this condition returns success prematurely. For example, AdminScim2UsersSearch returning one resource with totalResults: 2 produces one item and no error. Continue paging while the declared total remains unmet, or return ErrIncompleteList if progress stops, consistent with the fail-closed paging requirement in AGENTS.md:107.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in 6345dfb. When a page states totalResults, the offset pager now ends the list only once that many items have been read. A page with no items before that is ErrIncompleteList; a page that repeats is already caught. Without a total, a short page still ends the list. Regression tests: TestOffsetPagerReadsToAStatedTotal (a SCIM list served one item per page with totalResults: 2 returns both) and TestOffsetPagerFailsWhenItStopsShortOfItsTotal.

Comment thread pkg/aiagent/aiagent.go
Comment on lines +90 to +94
resp, err := webexops.Call(ctx, c, hosts, op, args)
if resp != nil && op.Redact {
resp.Body = RedactSecrets(resp.Body)
}
return resp, err

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Redact credentials from returned errors as well as responses

When a failed Studio response contains credentials, replacing resp.Body leaves the returned APIError.Body and its cached error message unredacted. A 400 response containing {"auth_token":"secret-token"} therefore returns a sanitized response but exposes the token through ordinary error logging. Sanitize both error representations before returning, preserving the credential-redaction responsibility documented in AGENTS.md:114.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in 6345dfb. The new APIError.WithBody returns a copy carrying another body and rebuilds the body-derived message; a rate-limit message is kept. aiagent.RedactError uses it to redact credentials in the error. aiagent.Call applies it to a failed redacting call, and so do the CLI's generated AI Agent commands (internal/apicmd), which printed the same error. Regression tests: TestCallRedactsTheErrorOfAFailedCall (a 400 carrying auth_token leaves no token in the error message or body) and TestWithBodyRebuildsTheMessage.

@edumansky

Copy link
Copy Markdown
Contributor Author

Codex review 6: outcome

Codex (gpt-6-astra, high) reviewed b140441, the branch after change set recording was removed. It raised 2 findings; both were valid and are fixed in 6345dfb.

Finding Outcome Regression test
[P2] A SCIM list ends at a short page although totalResults says more remain Fixed: a stated total ends the list only when reached; a page with no items before then fails closed TestOffsetPagerReadsToAStatedTotal, TestOffsetPagerFailsWhenItStopsShortOfItsTotal
[P2] A failed AI Agent Studio call leaks credentials through its error Fixed in the SDK's aiagent.Call and the CLI's AI Agent commands, using a new APIError.WithBody TestCallRedactsTheErrorOfAFailedCall, TestWithBodyRebuildsTheMessage

Checks

  • gofmt -l and go vet ./...: clean.
  • go test ./..., run one package at a time: passes, and the golden files are unchanged.
  • Webex Admin Studio's Rust repo Go tests pass against this branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

- webex_read refuses a generated command whose request is not a GET or a
  query POST, judged by its operation: 'calling call-controls get', for
  one, POSTs to retrieve a parked call.
- The SDK pager fails instead of ending a list when the next page is on
  another host, and an empty SCIM list without Resources has no items,
  not its schemas array.
- webexops refuses a header the operation does not declare, as it does a
  query parameter.
- Type.Check refuses a list that is not []any, which a body would write
  as an empty list.
- The guard reads a create or delete back up to three times, as it does
  an update, so read-after-write lag does not stop a run.
- The fake answers 404 for a fault on a route with nothing to commit
  instead of panicking under its lock, and Delete covers every collection.
- The weekly collection refresh runs the tests before opening its PR.
- Removed what nothing uses any more: the CLI's Contact Center pacer and
  CCInterval setting, and stale change set references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
@edumansky

Copy link
Copy Markdown
Contributor Author

Fable 5.2 review (high effort) of 6345dfb: outcome

Fable reviewed the branch after change set recording was removed. It found no P1. It raised one P2, which was already on main, and nine P3s.

It also ran every API command on main and on this branch, 27,665 runs per side covering flags, bodies, org forms, dry run, paging, errors and secret-bearing responses. The only differences were the seven new list-v1/list-v2 commands and the AI Agent error redaction.

Eight are fixed in d7a06ef; one is left for later.

Finding Outcome
[P2] webex_read lets four POST commands through on their verb (e.g. calling call-controls get); already on main Fixed: a generated command is judged by its operation's method. Test: TestReadRefusesWritesAndDoubleDash, TestReadAllowsGETsAndQueryPOSTs
[P3] SDK pager ends a list silently on a next link to another host Fixed: ErrIncompleteList. Test: TestAPagerFailsOnANextPageOnAnotherHost
[P3] Guard reads a create/delete back once, so one stale read stops a run Fixed: up to three reads, as for updates. Test: TestCreateAndDeleteReadBackThroughLag
[P3] An empty SCIM list without Resources yields its schemas array Fixed. Test: TestAnEmptySCIMListWithoutResourcesHasNoItems
[P3] Weekly refresh PR opens without tests (PR #16 shows 0 checks) Fixed: the workflow runs make test before opening the PR
[P3] webexops.Call does not check header names Fixed. Test: TestURLFillsPathAndQueryAndRefusesUnknownArguments
[P3] A Go-typed list ([]string) is written as an empty list Fixed: Type.Check refuses a list that is not []any. Test: TestCheckRefusesAListThatIsNotAnySlice
[P3] No token refresh for a 401 after a 429 wait Not fixed here: the client already behaved this way on main. Left for a separate change
[P3] Fake API deadlocks on a commit-type fault with no handler; Delete misses generic collections Fixed. Tests: TestTheFakeSurvivesAFaultOnAListRoute, TestTheFakeDeletesFromEveryCollection
[P3] Leftovers Removed: the unused CLI Contact Center pacer and CCInterval, stale change set references, the sortedKeys helper, and an inaccurate AGENTS.md line

Checks

  • gofmt -l and go vet ./...: clean.
  • go test ./..., run one package at a time: passes, and the golden files are unchanged.
  • The codegen tests pass, and the site builds.
  • Webex Admin Studio's Rust repo passes its Go tests against this branch, as do cargo xtask tools and cargo test -p studio-engine --test apply (41 passed).

🤖 Generated with Claude Code

https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

- The guard's stop check moves into the transport: webexapi.WithWriteCheck
  runs it before every attempt of a write, so a 429 retry after another
  write failed is not sent. Updates, creates and deletes all use it.
- Type.Check looks inside rows, so a list in a row that is not []any is
  refused instead of written as an empty list.
- Unmet normalizes the planned value as Project does the observed one, so
  an unordered list planned in another order is met.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

@edumansky edumansky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex review (gpt-6-astra, reasoning effort: high)

The Go and codegen tests pass, but targeted reproductions demonstrate writes continuing after the guard stops, nested list values being silently cleared, and unordered lists falsely failing verification.

Comment thread pkg/guard/guard.go Outdated
Comment on lines +149 to +152
if err := g.halted(); err != nil {
return baseline, err
}
if err := g.API.Update(ctx, t, key, raw, delta); err != nil {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Recheck the guard before retrying a write

When concurrent write A receives a 429 and waits, write B can fail and stop the guard, but A still retries and mutates Webex: this check runs only before API.Update, not before each transport attempt. The same gap affects creates and deletes. Propagate the stop check into every write attempt, including retries, to uphold the stop-after-failure protocol.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in 0c34dec. The stop check moves into the transport: webexapi.WithWriteCheck makes Client.Send run it before every attempt of a write, retries after a 429 included, and webex.WithBeforeWrite now delegates to it. The guard passes g.halted that way for updates and deletes as well as creates, and returns the Stopped failure unchanged when the check refuses. Regression tests: TestAWriteCheckRunsBeforeEachAttempt (a PUT answered 429 is not retried once the check fails, and a GET is never checked) and TestAStopAtSendStopsAnUpdateAndADelete (no mutation reaches the fake).

Comment thread pkg/resource/resource.go
Comment on lines +332 to +336
if f.Kind == StringList || f.Kind == ObjectList {
if _, ok := v.([]any); v != nil && !ok {
return fmt.Errorf("want a list as []any, not %T", v)
}
return nil

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Validate nested list fields before constructing write bodies

For an ObjectList, validation returns without checking its row fields. For example, a cc_queue distribution group containing team_ids: []string{"team1"} passes CheckUpdate, but set accepts only []any and serializes this as an empty agentGroups array, clearing the group's teams. Recursively validate row fields so unsupported nested slice types are rejected before sending a destructive write.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in 0c34dec. For an ObjectList, Field.Check now requires each row to be a map[string]any and checks each row field with its own Check, so a []string list inside a row is refused before any body is built. Regression test: TestCheckRefusesAListThatIsNotAnySlice (row case added).

Comment thread pkg/resource/resource.go
Comment on lines +578 to +584
if f.Kind == ObjectList {
if !rowsMeet(have[f.Name], p) {
out = append(out, f.Name)
}
continue
}
if canonical(have[f.Name]) != canonical(p) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Normalize unordered fields when comparing planned values

When a caller supplies an unordered list in a different order, Project sorts the observed values but Unmet compares them against the unsorted plan. For example, cc_desktop_layout with planned team_ids: ["team2", "team1"] remains unmet even after those exact memberships are saved. Guarded writes consequently issue unnecessary updates and report divergence, stopping the run. Normalize both sides using the field definition before comparison, including ObjectList rows.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, fixed in 0c34dec. Unmet normalizes the planned value with the field's Normalize, as Project does for the observed value, including ObjectList rows. Regression test: TestUnmetComparesUnorderedListsAsSets.

@edumansky

Copy link
Copy Markdown
Contributor Author

Codex review 7: outcome

Codex (gpt-6-astra, high) reviewed d7a06ef. It raised 3 findings, all valid; they are fixed in 0c34dec.

Finding Outcome Regression test
[P1] A 429 retry of a write can be sent after another write stopped the guard Fixed: webexapi.WithWriteCheck runs the guard's stop check before every attempt, for updates, creates and deletes TestAWriteCheckRunsBeforeEachAttempt, TestAStopAtSendStopsAnUpdateAndADelete
[P2] A list inside a row that is not []any is written as an empty list Fixed: Field.Check checks each row and its fields TestCheckRefusesAListThatIsNotAnySlice
[P2] An unordered list planned in another order never counts as met Fixed: Unmet normalizes the plan TestUnmetComparesUnorderedListsAsSets

Checks

  • gofmt -l and go vet ./...: clean.
  • go test ./..., run one package at a time: passes, and the golden files are unchanged.
  • Webex Admin Studio's Rust repo passes its Go tests and cargo test -p studio-engine --test apply (41 passed) against this branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

- The write check also runs after BeforeSend, which can wait, as Studio's
  pacer does, long enough for another write to stop the run.
- A write that fails before its request (no route, a URL it cannot
  build, a copy read that fails, a body it cannot encode) returns
  webex.NotSent, which Classify reads as nothing changed and the guard
  reports as not sent, instead of an unknown outcome.
- The fake API answers 400 to a write without a JSON body instead of
  panicking under its lock.
- SDK()'s comment no longer claims Contact Center pacing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW
@edumansky

Copy link
Copy Markdown
Contributor Author

Fable 5.2 review (high effort) of 0c34dec: outcome

No P1 or P2 defects. Fable reran the differential harness against main (about 27,700 runs per side). The only differences are the seven new list-v1/list-v2 commands and the AI Agent error redaction, both intended.

It also checked:

  • every command the MCP read tool admits: all are GETs or listed query POSTs;
  • that codegen reproduces the operation table with no drift;
  • that Studio's Go code builds and passes its tests against this branch.

It raised three P3s and a stale comment. All are fixed in 3ff2ef1.

Finding Outcome Regression test
[P3] The write check runs before BeforeSend, so a write held by Studio's pacer is sent after a stop Fixed: the check also runs after BeforeSend, just before the request is sent TestAWriteCheckRunsAfterBeforeSend
[P3] Writes that were never sent are reported as "outcome unknown" Fixed: webex.NotSent marks failures before the request, which Classify reads as nothing changed and the guard reports as "was not sent" TestAWriteThatWasNeverSentIsNotAnUnknownOutcome
[P3] The fake API deadlocks after a POST with no JSON body Fixed: it answers 400 before taking its lock TestTheFakeRefusesAWriteWithoutABody
[P3] Stale "Contact Center pacing" comment on SDK() Fixed —

Checks

  • gofmt -l and go vet ./...: clean.
  • go test ./..., run one package at a time: passes, and the golden files are unchanged.
  • Webex Admin Studio's Rust repo passes its Go tests and cargo test -p studio-engine --test apply (41 passed) against this branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JAtGHcFAKjQdB6dwDKd8mW

@edumansky
edumansky merged commit fbed925 into main Nov 14, 2026
1 check passed
edumansky added a commit that referenced this pull request Nov 14, 2026
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