feat: add PENDING status for invitation-style principals - #1104
Conversation
Add RESOURCE_STATUS_PENDING and STATUS_PENDING as value 4 on the resource and user-trait status enums so connectors can express a principal whose account creation was initiated but is not yet usable, such as an unaccepted invitation. Mirror the value onto the v3 StatusRecord enum so pebble translation stays in lockstep. Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
General PR Review: feat: add PENDING status for invitation-style principalsBlocking Issues: 0 | Suggestions: 0 | Threads Resolved: 0 Review Summary The new commit is test-only: Security Issues None found. Correctness Issues None found. Suggestions None. |
…ents The v2 ResourceStatus, v2 UserTrait status, and v3 StatusRecord status enums are hand-mirrored and cast numerically; assert every shared value agrees by name so drift fails CI instead of mistranslating stored data. AgentTrait_AgentStatus is a numeric prefix of ResourceStatus, not a mirror - reword the two comments that overstated it as identical and assert the subset property. Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| // AgentTrait_AgentStatus is cast numerically into Status_ResourceStatus. | ||
| // It is a prefix, not a mirror (READY maps to ENABLED); every AgentStatus | ||
| // number must exist in ResourceStatus so a new AgentStatus value cannot | ||
| // silently surface as an unrelated resource status. | ||
| for num, name := range v2.AgentTrait_AgentStatus_name { | ||
| _, ok := v2.Status_ResourceStatus_name[num] | ||
| require.Truef(t, ok, "AgentTrait_AgentStatus value %d (%s) has no Status_ResourceStatus counterpart", num, name) | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: this guard doesn't catch the failure mode its comment describes. AgentStatus currently stops at 3, and ResourceStatus now goes to 4 — so the next AgentStatus value added (4) will find a counterpart in Status_ResourceStatus_name and pass, while syncAgentTraitToResource's numeric cast silently renders it as RESOURCE_STATUS_PENDING. Existence-only checking is exactly what the added PENDING value made insufficient. Pinning the mapping by name, so a new AgentStatus value forces the table to be extended deliberately, would close it:
| // AgentTrait_AgentStatus is cast numerically into Status_ResourceStatus. | |
| // It is a prefix, not a mirror (READY maps to ENABLED); every AgentStatus | |
| // number must exist in ResourceStatus so a new AgentStatus value cannot | |
| // silently surface as an unrelated resource status. | |
| for num, name := range v2.AgentTrait_AgentStatus_name { | |
| _, ok := v2.Status_ResourceStatus_name[num] | |
| require.Truef(t, ok, "AgentTrait_AgentStatus value %d (%s) has no Status_ResourceStatus counterpart", num, name) | |
| } | |
| // AgentTrait_AgentStatus is cast numerically into Status_ResourceStatus. | |
| // It is a prefix, not a mirror (READY maps to ENABLED). Pin the mapping by | |
| // name so a new AgentStatus value cannot silently inherit an unrelated | |
| // ResourceStatus meaning — extend this table deliberately when adding one. | |
| expectedAgentMirror := map[int32]string{ | |
| 0: "RESOURCE_STATUS_UNSPECIFIED", | |
| 1: "RESOURCE_STATUS_ENABLED", | |
| 2: "RESOURCE_STATUS_DISABLED", | |
| 3: "RESOURCE_STATUS_DELETED", | |
| } | |
| require.Len(t, v2.AgentTrait_AgentStatus_name, len(expectedAgentMirror), | |
| "new AgentTrait_AgentStatus value: confirm its numeric cast into Status_ResourceStatus is still meaningful, then extend expectedAgentMirror") | |
| for num, name := range v2.AgentTrait_AgentStatus_name { | |
| want, ok := expectedAgentMirror[num] | |
| require.Truef(t, ok, "AgentTrait_AgentStatus value %d (%s) has no reviewed Status_ResourceStatus counterpart", num, name) | |
| require.Equalf(t, want, v2.Status_ResourceStatus_name[num], | |
| "AgentTrait_AgentStatus value %d (%s) casts to an unexpected Status_ResourceStatus", num, name) | |
| } |
There was a problem hiding this comment.
Good catch — existence-only stopped guarding anything the moment ResourceStatus outgrew AgentStatus. Applied your suggestion as-is in 9de8e65: the mapping is now pinned by name via expectedAgentMirror, with a Len assertion so a new AgentStatus value fails with an instruction to review the cast and extend the table deliberately.
Existence-only subset checking stopped guarding anything once ResourceStatus grew past AgentStatus: a new AGENT_STATUS_* = 4 would pass while casting to RESOURCE_STATUS_PENDING. Pin each value's counterpart by name so extending AgentStatus forces a deliberate table update. Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem
Connectors have no way to say "this principal is a pending invitation, not a usable account." Both status enums stop at
DELETED = 3, so an invitation-style principal can only be expressed asSTATUS_UNSPECIFIED— and even that requires the deprecatedWithStatusoption, becauseNewUserTraitforce-defaults an unset status toENABLED. Consumers are left inferring "pending" from an absent value, which is indistinguishable from a connector that simply never set a status.Change
Additive enum value
4on both status enums:c1.connector.v2.Status.ResourceStatus→RESOURCE_STATUS_PENDING = 4c1.connector.v2.UserTrait.Status.Status→STATUS_PENDING = 4c1.storage.v3.StatusRecord.ResourceStatusis mirrored as well — that enum documents itself as an explicit mirror of the v2 one, and the pebble translation layer casts between them numerically, so it has to move in lockstep.Trait ↔ resource status mirroring (
GetStatus,syncUserTraitToResource,WithResourceStatus/WithDetailedStatus/WithStatus) already round-trips by numeric cast, soPENDINGflows through unchanged with no new branches. The one enumerating switch,getUserStatusin the CSV exporter, gains aPendingcase.NewUserTrait's enabled-by-default behavior for an unset status is unchanged; an explicitly-setPENDINGis not overwritten, and there's a test proving it.Wire compatibility
Proto3 enums are open, so this is additive and existing values are untouched — nothing is renumbered or reused. The one caveat: the status fields carry
(validate.rules).enum = {defined_only: true}, which is enforced against the generated_namemap. A consumer running an older generated.pb.validate.gowill reject value4until it regenerates. Consumers should upgrade before connectors start emittingPENDING.Testing
go test -tags=baton_lambda_support ./...andgolangci-lint runboth clean. Protos regenerated withbuf generate(no hand edits);buf lintandbuf breaking --against origin/mainpass.Refs IGA-1212.
🤖 Generated with Claude Code