Skip to content

feat: read and change app owners one at a time, against the store that has them - #98

Open
leet-c1 wants to merge 5 commits into
mainfrom
feat/app-owner-commands
Open

feat: read and change app owners one at a time, against the store that has them#98
leet-c1 wants to merge 5 commits into
mainfrom
feat/app-owner-commands

Conversation

@leet-c1

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

Copy link
Copy Markdown
Contributor

Why

c1i exposed one sixth of the v1 app-owner API — set-owners (PUT) and nothing else.

  • Reading owners meant hand-rolling c1i api --path=/api/v1/apps/$APP_ID/ownerids. The embedded guide literally instructed that, and CLAUDE.md is explicit: a raw api call documented for a common workflow is a missing command, not a documentation task.
  • Adding one owner meant building the whole list from a read and PUTting it back. set-owners replaces the list with exactly the ids you pass, so anything added between your read and your write is silently removed — including the owner the platform assigns on creation.

What

apps owners <app-id>                    GET .../owners — NDJSON, auto-paginated
apps add-owner <user-id> --app-id X     POST .../owners/{user_id}
apps remove-owner <user-id> --app-id X  DELETE .../owners/{user_id}

The positional id is the resource's own id, scoping ids are flags, per CLAUDE.md's enforced convention. That deliberately differs from set-owners <app-id> --user-id, which addresses the owner list under one app and is correct as it stands.

This is the store that's actually populated. apps get's appOwners reads the ownership-v2 model, which is empty tenant-wide (see #96); these read v1.

Every claim measured, not inferred

Claim Evidence
Response is {list: [flat user objects], nextPageToken} live GET; the sibling ownerids uses a bespoke {"userIds": []}, so this was worth checking rather than assuming
Writes land in 45-150s a dozen timed writes; two past 120s, so the "1-2 minutes" this started with was optimistic — now 45-150s everywhere, set-owners included
apps create auto-assigns its caller as an owner appeared at 46s, silently; explains why 46 of 47 apps carry an owner
Two simultaneous add-owner calls both land fired concurrently, both landed at 92s

That last one replaced a claim that add/remove are "safe to run concurrently with another add-owner/remove-owner/set-owners call". The add/add half is now verified. The set-owners half was never true as stated and is gone — racing an add against set-owners is ordering-dependent (in one trial the add applied after the PUT and survived), so the text now states the mechanism a caller can act on rather than a safety guarantee.

Review

Adversarial + live-validation on independent axes, then a fix round.

The live pass drove the built binary end to end: full round trip, NDJSON typing (deleted_at is null, so jq 'select(.deleted_at)' correctly skips live owners), real multi-page pagination at 15 owners (auto-paginate returns all 15, --page-token "" returns exactly one page, --limit caps exactly across a page boundary), exit codes (400 → 2, 404 → 4), and --dry-run proven to send nothing past the async window rather than immediately.

Defects caught before merge, none by unit tests:

  • add-owner would have 400'd on every call. It passed a nil body, which marshals to null; the server parses the body as a protobuf message and rejects it (unexpected token null). Now sends {}, pinned by a test — the mutation paths do no HTTP in tests, so nothing else could catch it.
  • A dry-run assertion that pinned that same wrong assumption (asserted the preview shows no body) — inverted to require the preview match the wire.
  • set-owners' own success message still pointed at raw GET .../ownerids, the workflow this PR replaces.
  • A comment asserting this endpoint has no department field. It returns one.
  • The CHANGELOG contradicted the README on concurrency.

🤖 Generated with Claude Code

…t has them

c1i exposed one sixth of the v1 app-owner API: set-owners (PUT) and nothing
else. Reading owners meant hand-rolling "c1i api --path", which the guide
literally instructed -- a raw api call documented for a common workflow is a
missing command, not a documentation task. Adding one owner meant building
the full list from a read and PUTting it back, and set-owners replaces the
list with exactly the ids you pass, so anything added in between is silently
removed.

  apps owners <app-id>                    GET .../owners, NDJSON, auto-paginated
  apps add-owner <user-id> --app-id X     POST .../owners/{user_id}
  apps remove-owner <user-id> --app-id X  DELETE .../owners/{user_id}

The positional id is the resource's own id and scoping ids are flags, per
CLAUDE.md; that deliberately differs from set-owners <app-id> --user-id,
which addresses the owner LIST under one app and is correct as it stands.

This is the store that is actually populated. apps get's appOwners reads the
ownership-v2 model, which is empty tenant-wide; these read v1.

Every factual claim in the new text was measured on a live tenant, not
inferred:

  - the response envelope is {list: [flat user objects], nextPageToken}
  - owner writes land in roughly 45-150s (a dozen writes; two past 120s, so
    the "1-2 minutes" this started with was optimistic and is now 45-150s
    everywhere, set-owners included)
  - apps create auto-assigns its caller as an owner, at 46s, silently
  - two add-owner calls issued simultaneously BOTH land

That last one replaced a claim that add/remove are "safe to run concurrently
with another add-owner/remove-owner/set-owners call". The add/add half is now
verified; the set-owners half was never true in the form stated and is gone.
Racing an add against set-owners is ordering-dependent -- in one trial the add
applied after the PUT and survived -- so the text states the mechanism a
caller can act on instead of a safety guarantee.

Also fixes a comment asserting this endpoint has no department field. It
returns one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread cmd/agents.md Outdated
Comment thread CHANGELOG.md Outdated
Comment thread cmd/apps_add_owner_test.go Outdated
Comment thread README.md Outdated
Comment thread cmd/apps_set_owners.go
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown

General PR Review: feat: read and change app owners one at a time, against the store that has them

Blocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0
Criteria: Criteria status: none loaded - .claude/skills/ci-review.md was not found at trusted base 5d3ca115dbc1.
Review mode: incremental since 5453506f
View review run

Review Summary

The new commits are documentation and comment-only: CHANGELOG/README rewrapping, a rewritten appOwnerRow doc comment, a rewrapped add-owner Long, and a new docs_guide.go paragraph explaining that --wait polls .../ownerids rather than the apps owners view. Two prior findings are addressed — the appOwnerRow run-on comment now reads cleanly (cmd/apps_owners.go:25-30), and docs_guide.go replaced the "already satisfied if you used --wait" claim with an explicit note that the two reads are separate views that were only observed to converge. The still-open prior finding about writeRawObject printing nothing on an empty response body (cmd/apps_add_owner.go:63, cmd/apps_remove_owner.go:55) is unchanged and not re-raised here. The full PR diff was scanned for security and correctness: client.Path escaping, the empty/trailing-segment path guard, %w error wrapping, format-string arity in apps_set_owners.go, and the pagination loop against the repo's established list pattern all check out; no security issues found.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • CHANGELOG.md:20 — "All three honor --dry-run" overstates: apps owners is a read with no dryRunActive() check, so --dry-run does not suppress its GET. Only add-owner/remove-owner honor it.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `CHANGELOG.md`:
- Around line 20: The Unreleased "Added" entry says "All three honor `--dry-run`", but
  `cmd/apps_owners.go` contains no `dryRunActive()` check — it is a read command, so
  `c1i apps owners <app-id> --dry-run` still sends the GET and prints owner rows. This
  matches `cmd/api.go:136`, which deliberately skips dry-run previews for GET, so the
  code is correct and the CHANGELOG text is what is wrong. Narrow the claim to the two
  write commands, e.g. "`add-owner` and `remove-owner` honor `--dry-run`", so the entry
  does not promise dry-run behavior for `apps owners` that the command does not implement.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No blocking issues found.

All five are the same shape this change set keeps hitting -- one fact stated
in several places, updated in some of them.

  - cmd/agents.md kept #96's set-owners-only 96-129s while every other
    surface moved to 45-150s. Both numbers are real but measure different
    writes, so each is now scoped rather than flattened into one range.
  - The CHANGELOG shipped both windows eight lines apart in one release with
    nothing saying why they differ. The Added entry now names which
    operations its range covers and points at the narrower one below.
  - apps_add_owner_test.go's doc comment still said "no request body, since
    the endpoint takes none" -- the exact assumption that produced the 400
    this branch fixed, contradicting the assertion directly beneath it.
  - README's "two issued at once both land" read as covering any two of the
    commands named, including add-owner and remove-owner on the same user,
    where "both land" has no meaning. The measurement was two concurrent
    add-owner calls; it says that now.
  - set-owners' --wait timeout error still sent users to
    "c1i api --method GET --path .../ownerids" -- the raw call this branch
    replaces, and the sibling of the success message already corrected.
    Both render verified against the real command.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread CHANGELOG.md
Comment thread README.md Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No blocking issues found.

leet-c1 and others added 2 commits August 26, 2026 00:36
  - CHANGELOG said the 96-129s band came from "five timed writes". Four
    converged; the fifth was still pending at 108s, which the code comment
    already said. The artifact announcing a fix for overstated claims should
    not contain one.
  - "Two add-owner calls issued at the same time both land" read as a
    guarantee built from a single trial. A race that usually resolves
    benignly passes one trial. Now stated as what it is -- an observation.
  - cmd/agents.md wrapped mid-sentence, unlike its neighbouring bullets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread cmd/apps_add_owner.go Outdated
Comment thread cmd/apps_set_owners.go
Comment on lines +73 to +86
// parses the body as a protobuf message even though it needs nothing from it,
// so a nil body (which json.Marshal renders as `null`) is rejected live with
// 400 "failed to unmarshal body: proto: syntax error ... unexpected token
// null". Unit tests never see that, because the mutation path does no HTTP --
// this pins the one value that keeps it from happening again.
func TestAppsAddOwnerSendsEmptyObjectNotNull(t *testing.T) {
got, err := json.Marshal(addOwnerEmptyBody())
if err != nil {
t.Fatalf("marshal: %v", err)
}
if string(got) != "{}" {
t.Errorf("add-owner request body = %s, want {} (a nil body marshals to null and the API 400s)", got)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Suggestion: neither add-owner nor remove-owner has a test that drives the real HTTP path — this pins addOwnerEmptyBody() in isolation, and the only call-site coverage is the dry-run preview. CLAUDE.md asks for a test that exercises the wired end-to-end path, and the repo already has the harness (cmd/requests_create_test.go, cmd/policies_update_test.go use httptest). A wired test would additionally pin the method, the client.Path escaping of <user-id>, and that {} — not null — actually reaches the wire. Confidence: high (test gap, not a defect).

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.

Agreed, and this is the gap that actually mattered — the nil-body 400 lived in exactly this hole and no unit test could see it. You are right that pinning addOwnerEmptyBody() in isolation would still pass if RunE stopped passing body to c.Post.

Being built now on a follow-up branch stacked on this one: var newXClient = newClient seams for both mutations plus wired httptest cases pinning the method, the client.Path escaping of the ids, and that {} rather than null reaches the wire. Leaving this thread OPEN until that lands rather than resolving it on a promise.

Comment thread CHANGELOG.md
Comment thread README.md Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No blocking issues found.

Comment thread cmd/apps_add_owner.go
return fmt.Errorf("API error: %w", err)
}

return writeRawObject(cmd, data)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Suggestion: unlike its siblings, this mutation can succeed completely silently. apps delete prints Deleted app: id=%s and apps set-owners prints setOwnersSuccessFmt (which also names the async lag and the verify command), but writeRawObject returns without writing anything when the response body is empty (cmd/fields.go:531). Since this command's own help says the write takes 45-150s to become visible, a bare exit 0 gives the caller nothing to distinguish "accepted" from "no-op" — consider printing a confirmation line naming the app, the user, and c1i apps owners <app-id>. Same applies to cmd/apps_remove_owner.go:55. (medium confidence — depends on whether the endpoint returns a body, which I can't verify from the diff)

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 wrong on the premise, and the valid part is going to the follow-up.

The endpoint does return a body — {} — so writeRawObject prints that rather than nothing; verified live, both mutations emit {} on success. So it is not silent in the strict sense.

The substance stands though: {} is a poor confirmation next to siblings that print a line naming the app, the user and the async lag, and this command's own help says the write takes 45-150s to become visible. Queued for the follow-up branch alongside the HTTP seam, since it changes output and wants its own test. Leaving OPEN.

Comment thread cmd/apps_owners.go Outdated
Comment thread cmd/docs_guide.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No blocking issues found.

I fixed its first five suggestions and then stopped reading the thread. Ten
more had accumulated, several of them defects in those fixes.

The one that mattered: the success message and timeout error now send users to
"c1i apps owners" (GET .../owners) while --wait polls GET .../ownerids, and
the guide claimed a check there was "already satisfied if you used --wait".
That asserts convergence on one endpoint proves convergence on the other,
which nothing had tested. Tested it: a single add, both endpoints polled on
one clock, both empty through 97s and both populated at 108s. So they front
the same store and moved together -- but that is one trial at 10s
granularity, not a guarantee, so the guide now says what was measured and
tells the reader to check the read they care about.

Also:
  - The row-builder comment I rewrote last round did not parse: an em-dash
    aside swallowed the deleted_at clause and read as though deleted_at were
    empty on every owner, the opposite of the sentence after it. Two plain
    sentences instead.
  - #96's changelog entry described an end state this PR then changed -- it
    said the docs were pointed at ownerids, which is now true only of the
    set-owners help.
  - Two ragged wraps from my own edits, one of them in a Long that --help
    renders verbatim.

Still open, deliberately deferred to the follow-up that carries #96's guard
rebuild: no test drives the mutations' real HTTP path (the gap the null-body
bug lived in), and both mutations confirm with a bare {} where their siblings
print a line naming the async lag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread CHANGELOG.md
(NDJSON, auto-paginated); `apps add-owner <user-id> --app-id <id>` and
`apps remove-owner <user-id> --app-id <id>` change one owner at a time via
POST/DELETE `.../owners/{user_id}`, so they do not read-modify-write the
list the way `set-owners` does. All three honor `--dry-run`; owner

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Suggestion: "All three honor --dry-run" isn't accurate for apps ownerscmd/apps_owners.go has no dryRunActive() check, so c1i apps owners <id> --dry-run still issues the GET and prints rows. That's the right behavior for a read (cmd/api.go:136 deliberately skips dry-run for GET), but the CHANGELOG claim covers all three commands. Confidence: high. Suggest narrowing to "Both writes honor --dry-run".

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.

Confirmed and it is my error — cmd/apps_owners.go has no dryRunActive() check, so --dry-run is a no-op there, which as you say is correct for a read. The CHANGELOG claim covers all three commands and is therefore false for one of them.

Not yet fixed: this branch is currently checked out by another task, so I cannot amend it this second without disrupting that. It is queued as the next change to this PR, narrowing to the two writes. Leaving OPEN until the fix is pushed.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No blocking issues found.

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