Skip to content

feat(organizations): support many independent organizations per process - #2549

Merged
vieiralucas merged 32 commits into
mainfrom
feat/2543-multi-organization
Sep 24, 2026
Merged

vieiralucas merged 32 commits into
mainfrom
feat/2543-multi-organization

Conversation

@vieiralucas

@vieiralucas vieiralucas commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #2543 (second half; the first was #2546).

Organizations state was a process-wide Option<OrganizationState> — at most one organization could ever exist. CreateOrganization rejected on "an organization exists" rather than "the caller is already in one":

if guard.is_some() {
    return Err(... "AlreadyInOrganizationException" ...);
}

So once any account created an organization, every other account in the process was permanently locked out of creating its own. The reporter's script vends two management accounts and expects two independent organizations with two child accounts each; the second CreateOrganization failed purely because the first organization existed.

The state becomes an OrganizationsRegistry keyed by organization id. An account still belongs to at most one organization, which is what makes "the caller's organization" well defined, so almost every handler now resolves through org_of_account instead of reaching for the singleton. Organizations never see each other: a caller in none gets the same AWSOrganizationsNotInUseException as a process with no organizations at all.

Behavior

  • CreateOrganization rejects only when the calling account is already a member — same for the CloudFormation AWS::Organizations::Organization provisioner. DeleteOrganization removes exactly the caller's organization instead of clearing the world.
  • An account can be in one organization only. Inviting an account another organization already holds is HandshakeConstraintViolationException / Reason=ALREADY_IN_AN_ORGANIZATION, re-checked when the handshake is accepted — the target may have joined elsewhere while the invitation sat open.
  • Handshakes resolve by id across organizations, because the account answering an invitation is not yet a member of the inviting one. ListHandshakesForAccount likewise spans organizations. DescribeHandshake is limited to the handshake's two parties.
  • SCP ceilings and AssumeRoot resolve the principal's/caller's own organization, so one organization's SCPs can never reach another's accounts.
  • StackSets auto-deployment reconciles each stack set against its own administrator's organization. Reconciling every stack set against one organization would have deployed into another organization's accounts.

Bugs found and fixed along the way

  • TerminateResponsibilityTransfer cleared active_handshake_id before cloning the snapshot it then read the id back from, so the riding handshake was never cancelled and a withdrawn transfer could still be accepted.
  • Accepting a TRANSFER_RESPONSIBILITY handshake left the transfer at REQUESTED with a live handshake id — two sources of truth disagreeing. It now moves with the handshake.
  • resolve_handshake enrolled the target on any accepted handshake, so accepting a transfer would have enrolled the other organization's management account. Enrollment is now INVITE-only.
  • Responsibility transfers were only readable from the organization storing them, so the inbound party could never see the transfer it was invited to take over. Both parties can now read it; only the source can rename or withdraw it (AccessDeniedException, not a misleading not-found).
  • InviteAccountToOrganization trusted Target.Id without checking it against Target.Type, so {Type: ACCOUNT, Id: "bob@corp.com"} opened a handshake nobody could ever accept. Now InvalidInputException.

Reads that were satisfied by "an organization exists" now require membership — ListParents, ListChildren, ListTagsForResource, DescribeEffectivePolicy, DescribeResourcePolicy (which ignored the caller entirely) and the responsibility-transfer ops. With one organization these were harmless; with two they are cross-organization reads.

Persistence

Snapshot schema goes to v2, holding the registry. v1's single-organization field is still read and folded in (into_registry), so an existing snapshot keeps loading across the upgrade; it is never written again. The loader's version guard is >, not !=, so this is a true in-place upgrade.

Introspection (wire change)

GET /_fakecloud/organizations/accounts now spans every organization: each account carries its organizationId, and a new organizations array summarises each one. The flat managementAccountId/masterAccountId stay populated when exactly one organization exists — so single-organization callers are unaffected — and are null with several, rather than reporting an arbitrary pick. /_fakecloud/organizations/responsibility-transfers rows gain organizationId for the same reason.

Test plan

  • cargo nextest run -p fakecloud-organizations — 160 passed, including new coverage for: two accounts each running their own organization; a member of another organization being refused; cross-organization invite/accept rejection; organization isolation of reads (ListParents/ListChildren/ListTagsForResource/DescribeResourcePolicy/DescribeEffectivePolicy); DescribeHandshake party gating; EMAIL-target invite resolution; Target.Type/Target.Id validation; the inbound side of a responsibility transfer; source-only terminate; and v1→v2 + v2 round-trip snapshot migration.
  • cargo nextest run -p fakecloud-e2e --test organizations --test organizations_scp_enforcement --test organizations_handshake --test organizations_trusted_services --test organizations_lifecycle --test organizations_introspection --test organizations_persistence --test organizations_h4 --test cloudformation_stack_sets_auto_deployment --test multi_account — 54 passed, including two_accounts_each_run_their_own_organization, an end-to-end replay of the issue's repro against the real aws-sdk-organizations.
  • cargo nextest run -p fakecloud-conformance --test organizations — 8 passed.
  • cargo clippy --workspace --all-targets -- -D warnings — clean. gofmt, ruff, mypy, prettier, eslint — clean.

Surface sync

  • SDKs (all 7) — Rust, Go, Python, TypeScript, Java, PHP, .NET carry organizationId on accounts and transfers plus the new organizations summary. Every addition is appended or defaulted, and Java/.NET keep delegating constructors, so no positional caller breaks.
  • Reference docs — reference/organizations.md drops "one organization per fakecloud process" and documents the per-caller rejection, the one-organization-per-account rule and its error code; reference/introspection.md and services/organizations.md document the new response shape.
  • Machine metadata — no service/op/variant counts move, so no conformance baseline, ops-index or repo-description change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu


Summary by cubic

Fixes #2543 by replacing the process-wide single-organization state with an OrganizationsRegistry keyed by organization id. Previously one account's organization permanently locked every other account out of creating its own; now any account outside an organization can create one, and organizations never see each other.

Behavior

  • CreateOrganization/DeleteOrganization and the CloudFormation provisioner reject only the caller's own membership and remove only the caller's organization.
  • An account belongs to at most one organization, enforced at invite, acceptance, and join; registry-wide id minting with in-flight reservations holds the invariant across organizations.
  • Handshakes resolve across organizations so an invited account can answer; SCP ceilings, AssumeRoot, and StackSets auto-deployment stay in the caller's own organization.
  • Reads that previously only required "an organization exists" now require membership; responsibility-transfer reads resolve by party, with renaming source-only while REQUESTED and either party able to end an accepted transfer.
  • ListHandshakesForOrganization, ListAWSServiceAccessForOrganization, ListDelegatedAdministrators, and ListDelegatedServicesForAccount admit delegated administrators through the same gate as DescribeResourcePolicy; mutations stay management-only.

Bug fixes

  • One shared predicate decides who a handshake target is, used by the handshake and transfer gates and the account's own listing, which now includes handshakes the caller sent and accepts a TRANSFER_RESPONSIBILITY action filter.
  • Withdrawn transfers could still be accepted, accepted ones kept a live handshake id and an end timestamp, and targets were enrolled on any accepted handshake rather than INVITE-only; a TRANSFER_RESPONSIBILITY handshake now carries the transfer type, start time, and management account/email under the transfer id and stores the id itself.
  • Reserved ids count as claimed on every enrolling path — CreateOrganization, invites, acceptance, and the create-admin shortcut — and acceptance enrolls the account the party gate proved; a failed CreateAccount drops the tags it attached synchronously.
  • Account addresses are unique except the GovCloud mirror sharing its owner's; the first caller wins a contested address, the failure is persisted as EMAIL_ALREADY_EXISTS on the completion tick, closed accounts release their address, and the synthetic address belongs to its id even before the account exists.
  • Target resolution prefers the registered address over the synthetic decode so one address names one account; the management gate runs before address checks, and the CloudFormation provisioner resolves the stack's own organization first.
  • Transfer targets are recorded as named and the party check asks the caller its own address; naming a target in the caller's own organization — by id, synthetic address, or registered email — is rejected with SOURCE_AND_TARGET_CANNOT_MATCH, and a plain member of another organization is refused while a standalone account stays valid.
  • AcceptHandshake rejects an invitee who joined another organization while the invitation sat open and enrolls with a free address; the party gate runs before the state check so a bystander learns only that it is not a party; the staleness re-check also covers transfer targets.
  • Duplicate-handshake detection scans INVITE handshakes only; both invite ops validate Target.Type against Target.Id and require it; only ListInboundResponsibilityTransfers honors the optional Id filter; DescribeEffectivePolicy rejects a foreign TargetId and root ids widen to 12 characters.
  • A delegated-administrator grant ends with the membership it was granted to; a TRANSFER_RESPONSIBILITY handshake without a stored transfer id falls back to the transfer's ActiveHandshakeId; and DeregisterDelegatedAdministrator now names the account rather than the service principal when nothing is registered.
  • Snapshot schema goes to v2 with in-place v1 migration; GET /_fakecloud/organizations/accounts spans all organizations (organizationId per account plus an organizations array) with flat managementAccountId/masterAccountId occupied only when exactly one organization exists, across all seven SDKs.

Written for commit 7733614. Summary will update on new commits.

Review in cubic

vieiralucas and others added 30 commits September 24, 2026 00:25
Organizations state was a process-wide `Option<OrganizationState>` --
at most one organization could ever exist. `CreateOrganization`
rejected on "an organization exists" rather than "the caller is already
in one", so once any account created an organization, every other
account in the process was permanently locked out of creating its own:

    if guard.is_some() {
        return Err(... "AlreadyInOrganizationException" ...);
    }

That is the remaining half of #2543. The reporter's script vends two
management accounts and expects two independent organizations with two
child accounts each; the second `CreateOrganization` failed purely
because the first organization existed.

The state becomes an `OrganizationsRegistry` keyed by organization id.
An account still belongs to at most one organization, which is what
makes "the caller's organization" well defined, so almost every handler
resolves through `org_of_account` instead of reaching for the singleton.
Organizations never see each other: a caller in none gets the same
`AWSOrganizationsNotInUseException` as a process with no organizations
at all.

Behavior:

- `CreateOrganization` rejects only when the CALLING account is already
  a member. Same for the CloudFormation `AWS::Organizations::Organization`
  provisioner, and `DeleteOrganization` now removes exactly the caller's
  organization instead of clearing the world.
- An account can be in one organization only. Inviting an account another
  organization already holds is `AccountAlreadyRegisteredException`, and
  that is re-checked when the handshake is ACCEPTED -- the target may have
  joined elsewhere while the invitation sat open.
- Handshakes resolve by id across organizations, because the account
  answering an invitation is not yet a member of the inviting one.
  `ListHandshakesForAccount` likewise spans organizations: a standalone
  account's invitations all live in organizations it does not belong to.
- SCP ceilings and `AssumeRoot` resolve the principal's/caller's own
  organization, so one organization's SCPs can never reach another's
  accounts.
- StackSets auto-deployment reconciles each stack set against ITS OWN
  administrator's organization. Reconciling every stack set against one
  organization would have deployed into another organization's accounts.

Reads that were satisfied by "an organization exists" now require
membership -- `ListParents`, `ListChildren`, `ListTagsForResource`,
`DescribeEffectivePolicy`, `DescribeResourcePolicy` (which ignored the
caller entirely) and the responsibility-transfer ops. `DescribeHandshake`
is limited to the handshake's two parties. With one organization these
were harmless; with two they are cross-organization reads.

Persistence: snapshot schema goes to v2, holding the registry. v1's
single-organization field is still read and folded in, so an existing
snapshot keeps loading across the upgrade; it is never written again.

Introspection `GET /_fakecloud/organizations/accounts` now spans every
organization: each account carries its `organizationId`, and a new
`organizations` array summarises each one. The flat
`managementAccountId`/`masterAccountId` stay populated when exactly one
organization exists, so single-organization callers are unaffected, and
are null with several rather than reporting an arbitrary pick. All seven
SDKs and the docs follow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
… gates

Follow-ups from reviewing the multi-organization change, three of them
regressions it introduced:

- `TerminateResponsibilityTransfer` refused an ACCEPTED transfer. Syncing
  the transfer's status with its handshake meant accepting moved it
  REQUESTED -> ACCEPTED, and terminate still required REQUESTED, so a
  transfer could never be ended once it started -- which is the case the
  operation exists for. It now ends a REQUESTED or ACCEPTED transfer and
  refuses only one already in a terminal state.
- An ACCEPTED transfer was stamped with an `EndTimestamp`, reporting it
  as simultaneously running and already over. Only DECLINED, CANCELED
  and EXPIRED end a transfer.
- `InviteAccountToOrganization` rejected any EMAIL target that was not an
  address fakecloud had minted. Inviting the account owner's real address
  is AWS's primary flow; an address fakecloud cannot resolve now yields
  an OPEN handshake its source can read and cancel, exactly as before the
  owner acts on it. The type/shape checks stay.
- `DescribeHandshake` allowed only the two parties. AWS documents it as
  callable "from any account in the organization", so membership of the
  organization that owns the handshake is enough -- it is another
  organization's handshakes that must stay invisible, and they do.
- `InviteOrganizationToTransferResponsibility` validated nothing. It now
  applies the same `Target.Type`/`Target.Id` check as the account-invite
  path, so a mismatched target cannot open a handshake keyed by a string
  no caller can authenticate as.
- `LeaveOrganization`'s doc comment still described the deleted
  `AccountNotFoundException` branch; `ResponsibilityTransferRow.direction`
  now documents that it is relative to the row's own organization and
  therefore always OUTBOUND.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…nd a transfer

- The duplicate-handshake scan matched any handshake to the target,
  including a `TRANSFER_RESPONSIBILITY` one. An outstanding billing
  transfer to an account therefore blocked a membership invitation to
  that same account with `DuplicateHandshakeException`. AWS keeps the
  two handshake actions independent, so the scan now looks at `INVITE`
  handshakes only.
- `TerminateResponsibilityTransfer` was source-only. Once a transfer is
  accepted its riding handshake is gone, so the target management
  account -- actively carrying the delegated responsibility -- had no
  operation left that could end the arrangement. Either party can now
  end it; renaming stays the source's, since the source named it.
- `validate_invite_target`'s doc still described the resolvability rule
  that was dropped, and the .NET `OrganizationId` doc comment sat inside
  the record's positional parameter list, where C# emits CS1587 and
  discards it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
`target_account_id` only recognised the synthetic
`<account-id>@example.com` form, but `CreateAccount` stores the caller's
own `Email` verbatim. An account created with a real address was
therefore invisible to the "one organization per account" guards: it
could be invited again while already a member, and the
cross-organization checks on invite and accept were both bypassed.
Resolution now matches the address each member account is actually
registered with, falling back to the synthetic form.

Also from review:

- A responsibility transfer names existing management accounts on both
  sides, so an EMAIL target must resolve to one. `Target.ManagementAccountId`
  is an account id in the Smithy shape, and an unresolved address was
  being written into it; an `ACCOUNT` target was also being given a
  synthesized `target_email`, which `handshake_payload` then preferred,
  so the response echoed an address under `Type: ACCOUNT`.
- Terminating a transfer that is still `REQUESTED` is the source's
  alone: `WITHDRAWN` means the inviter pulled the offer, and the
  target's answer to an open offer is `DeclineHandshake`. Once ACCEPTED
  the riding handshake is gone, so either party can end it.
- The Organizations reference claimed organizations never see each
  other; the handshake and responsibility-transfer ops are
  cross-organization by nature, so the page now says which and how they
  stay scoped to the caller's own involvement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…unique registry-wide

- A responsibility transfer resolved its EMAIL target to an account id
  but still recorded `target_kind: "EMAIL"`, so resolving it again
  returned `None`: the target could neither accept nor describe its own
  handshake, and the transfer was stuck REQUESTED forever. The stored
  kind now matches what was stored.
- The target was resolved BEFORE the management gate, so the difference
  between `InvalidInputException` and `AWSOrganizationsNotInUseException`
  told any caller whether an address was registered in some other
  organization -- the kind of cross-organization leak the rest of this
  work closes. Authorization now runs first.
- An EMAIL target that resolves to no account is rejected: nothing could
  prove it is the target, so the handshake would sit OPEN until it
  expired. An ACCOUNT target still needs no prior existence, since any
  account can authenticate as itself.
- `next_account_id` only avoided collisions within one organization.
  `CreateAccount` could therefore mint an id another organization already
  owned, and `org_of_account` would resolve it to whichever organization
  sorted first -- breaking the one-organization-per-account invariant.
  Ids are now minted from the registry; `begin_create_account` takes the
  id so the caller, which holds the registry, guarantees uniqueness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…es by type

- `resolve_target_account` scanned every organization for a matching
  member email, which turned an invitation into an email-to-account-id
  oracle: name an address, be told "already a member of an
  organization", and read back a 12-digit id from an organization the
  caller has no relationship with. The address lookup is now scoped to
  the one organization the call is about.
- `handshake_payload` preferred `target_email` whenever it was recorded,
  so a handshake that stores both rendered an address under
  `Type: ACCOUNT` -- the id/type pairing `validate_invite_target`
  rejects on input. The party's id is now picked by its own type.
- `party_org_of_transfer` treated every non-ACCEPTED status as "still
  open", so terminating an already DECLINED or WITHDRAWN transfer told
  the target to decline a handshake that no longer exists instead of
  returning the real transition error. Only a REQUESTED offer is the
  source's alone to withdraw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…ation

`InviteOrganizationToTransferResponsibility` accepted any account id as
the target, and resolved an EMAIL target against the SOURCE
organization's member addresses -- exactly backwards. A management
account could open (and, once accepted, act on) a billing transfer
pointed back into its own organization, while a genuine other-
organization target with a real address could never resolve at all.

The target is now looked up among management accounts, and must belong
to a different organization. Every rejection -- unknown address, not a
management account, or the caller's own organization -- reports the same
message, so the error cannot be read as an oracle for what exists
elsewhere.

Also:

- `is_transfer_target` decoded the recorded target EMAIL as if it
  encoded an account id, so a stranger whose id an address happened to
  spell (`333333333333@example.com`) passed the party check: it could
  read both parties' ids and emails, and end an accepted transfer. The
  target is resolved before it is stored, so this is now a plain
  comparison.
- `handshake_payload` fixed the address-under-`Type: ACCOUNT` bug in
  `Parties` but left it in `Resources`. `HandshakeResourceType` models
  EMAIL separately, so an email-target invite now reports the address
  as EMAIL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…ler instead

The previous round over-corrected. Resolving an EMAIL transfer target
against other organizations' management accounts answered "does this
address exist there, and what is its account id?" for organizations the
caller has nothing to do with -- the oracle the registry's own doc
comment says must not exist -- and requiring the target to already be
another organization's management account narrowed behavior that used
to work in a single-organization process.

AWS echoes the target party back as named, so fakecloud does too: the
target is recorded verbatim with the type the caller declared, and the
party check runs the other way -- it asks the CALLER what its own
registered address is and compares that. Nothing is looked up in an
organization the caller is not part of, and no target is rejected for
not existing.

An organization still cannot transfer responsibility to itself, by id or
by its own address; AWS models that as
`HandshakeConstraintViolationException` with
`Reason: SOURCE_AND_TARGET_CANNOT_MATCH`.

Also from review:

- `CreateGovCloudAccount` minted two ids back to back with nothing
  reserving the first, so both could be the same id; minting also
  ignored ids already reserved by in-flight `CreateAccount` requests,
  where a second request could reserve one already promised and
  overwrite the first account on completion.
- Both responsibility-transfer list ops model an optional `Id` to fetch
  a single transfer, which was ignored.
- `OrganizationsSummary.from_dict` indexed its fields directly, so a
  payload missing one raised `KeyError` for the whole `get_accounts()`
  call instead of returning what it did have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
Three places answered "does this target name that account?" and two of
them disagreed. An account invited by the synthetic
`<account-id>@example.com` form could accept the handshake — the
handshake gate resolved the address — and then find the transfer it had
just accepted invisible to `ListInboundResponsibilityTransfers`,
unreadable through `DescribeResponsibilityTransfer`, and unendable,
because the transfer gate compared the stored string raw. The same hole
hid an invitation from `ListHandshakesForAccount` for any account whose
registered address was the stored target, so it could act on an
invitation it had no way to discover.

`OrganizationsRegistry::account_matches_target` is now the single
answer, used by the handshake party gate, the transfer party gate and
the account's own handshake listing. It resolves the synthetic form or
matches the address the CALLER is registered with — never a scan of
other organizations, so it still reports nothing about who else exists.

Also:

- `next_account_id_besides` skipped the `gov_cloud_account_id` of an
  in-flight request, so a concurrent `CreateAccount` could mint an id a
  pending GovCloud pairing had already reserved and overwrite it on
  completion.
- Dropped three comment blocks left over from the previous round that
  described resolution the code no longer does, one of which
  contradicted the block directly below it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…n transfer targets

The self-target check only rejected the source's own management account,
so a plain member of the SAME organization could be named: the handshake
opened, that member could accept it, and the result was a
"cross-organization" billing transfer whose two ends were one
organization. The check now asks whether the target belongs to the
caller's organization at all.

Also from review:

- `ListInbound/OutboundResponsibilityTransfers` required membership
  while `DescribeResponsibilityTransfer` did not, so an invited account
  that had not yet created an organization of its own could read the
  transfer if handed its id but had no way to discover that id. Both
  now answer any party.
- `list_handshakes` kept a target filter that every caller passed `None`
  to, and which encoded the id-only rule the shared predicate replaced --
  dead code that would silently mismatch the next caller. Dropped.
- `OrganizationState::next_account_id` carried two concatenated doc
  paragraphs after the registry form was added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
The self-target guard compared the raw target against the management
account's address and the account-id keys, so it only caught the
`ACCOUNT` spelling. Naming a plain member of the caller's own
organization by address -- `333333333333@example.com`, or that member's
registered email -- still opened a `TRANSFER_RESPONSIBILITY` handshake
that the member could then accept, which is the same-organization
"transfer" the previous commit meant to close. The guard now runs the
shared matcher over the organization's members, so all three spellings
resolve identically.

Also from review:

- `TransferParticipant.ManagementAccountId` is modeled as an `AccountId`
  (exactly 12 digits), but an email-targeted transfer records the
  address there and `transfer_payload` emitted it verbatim, so a
  validating SDK would reject the response. The target party now carries
  the id only when it resolves to one, and the email otherwise.
- Only `ListInboundResponsibilityTransfers` models the optional `Id`
  filter, and it models a not-found error; the filter was being read on
  the outbound op too and an unmatched id returned an empty page.
- `list_handshakes` kept its old "optionally filtered by destination
  account id" paragraph above the new one after the parameter was
  dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…bound membership gate

- `target_participant` asked `target_account_id("ACCOUNT", ...)`, which
  returns its input unchanged, so the only real filter was the 12-digit
  test and an email-targeted transfer never reported a
  `ManagementAccountId` -- not even for the synthetic
  `<account-id>@example.com` form that resolves everywhere else. It now
  resolves the address form, matching its own doc comment.
- The inbound/outbound list ops still called `require_member`, so an
  account that accepted a transfer without running an organization of
  its own could not enumerate the transfer it was carrying; it could
  only reach it by an id it had no way to obtain. Listing resolves by
  party, like `DescribeResponsibilityTransfer`.
- `reconcile_auto_deployments_once` deep-cloned the administrator's whole
  organization -- accounts, OUs, policies, attachments, handshakes -- once
  per stack set. Candidates are grouped by administrator, so the clone is
  now taken once per administrator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…lls who it proved

- `account_is_enrolled` looked only at enrolled members, so an id
  reserved by an in-flight `CreateAccount` was free for a second or two.
  A client that bootstrapped credentials for that id and called
  `CreateOrganization` inside the completion delay got its own
  organization; the background tick then enrolled the same id into the
  creating organization, and it was in two at once. Reserved ids now
  count as claimed.
- `resolve_handshake` re-derived the account to enroll from the stored
  target, id-only, while the party gate had already resolved it
  registry-aware. The two could disagree: an accept by an account
  registered with the target address enrolled a different, phantom
  account, and an accept on an external address reported ACCEPTED while
  enrolling nobody. The caller now passes the account its gate proved.
- Dropping the membership gate from the transfer list ops went one op too
  far: the OUTBOUND caller is always a source management account, so
  AWS's modeled `AWSOrganizationsNotInUseException` still applies there.
  INBOUND stays ungated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…path

`account_is_enrolled` learned about ids reserved by an in-flight
`CreateAccount`, but only `CreateOrganization` asked it. The invite
gate, the accept gate and the `create-admin` shortcut each did their own
`org_of_account` lookup, which sees an enrolled member and not a
reserved one. So during the completion delay an id org A had just
minted could still be invited into org B, accept that invitation, or be
enrolled straight into B by the bootstrap shortcut -- and A's tick then
enrolled it too, leaving it in two organizations.

All four paths now go through `claimed_by_other_org`, which answers
"enrolled somewhere else, or about to be".

`HandshakeParty.Type` is also modeled required. Defaulting it to
`ACCOUNT` meant an omitted type had the id validated against a type the
caller never declared, so `{Id: "bob@corp.com"}` failed as a malformed
account id rather than as the missing field it was. Both invite paths
now require it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…e-policy targets

- Target resolution decoded the synthetic `<account-id>@example.com`
  form before consulting the address members are actually registered
  with, so an account created with
  `CreateAccount(Email = "222222222222@example.com")` -- which mints a
  random id -- resolved to `222222222222`, an account nobody owns.
  Neither the cross-organization guard nor the already-a-member check
  fired, and the invitation opened for a member already enrolled; the
  party gate, which did consult the registered address, then let that
  member accept it. Both matchers now try the registered address first,
  as their docs always said.
- `DescribeEffectivePolicy` resolved the caller's organization but never
  checked that `TargetId` belonged to it. A target in another
  organization walked no hierarchy and came back as an empty, successful
  "no effective policy" -- the worst possible answer for a caller
  auditing one. It is now `TargetNotFoundException`, as modeled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
`CreateAccount` stores the caller's own `Email`, so an account can be
registered with an address that happens to spell a different account id
(`222222222222@example.com` on an account whose id is random). The
resolvers accepted BOTH readings:

- `account_matches_target` returned true for the registered owner AND
  for the account the address merely spells, so an invitation could be
  read and accepted by an account it was never addressed to, enrolling
  the wrong one.
- `invite_account` resolved the synthetic form first -- the opposite
  order to the service-layer guard -- so an invitation to an address
  already held by a member resolved to an account nobody owns, and both
  the already-a-member and duplicate-handshake guards missed it. The
  account could then hold two live invitations under its two spellings.

The registered address now wins everywhere, and the synthetic decode is
a fallback used only when no account claims that address, so one target
resolves to one account. The invite-side resolver stays scoped to a
single organization: resolving across the registry would let a caller
read a foreign account id back out of the "already a member" error,
while the boolean matcher may look wider because it only ever confirms
an account the caller already named.

Root ids also widen from 4 to 12 random characters. Four was fine while
one organization could exist; across a registry two organizations
collided at 1/65536, and another organization's root id would then pass
the new `DescribeEffectivePolicy` target check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…applies

No behavior change; three comments and one doc page said things the code
does not do.

- `account_matches_target` claimed it "never scans other organizations".
  It does, and that is safe there: it only ever CONFIRMS an account the
  caller already named, never hands one back. The resolvers that return
  an id stay scoped to one organization, which is what keeps the error
  from being read as an oracle. The precedence (registered address wins,
  synthetic decode is the fallback) and its one cost -- registering an
  address elsewhere takes over its synthetic reading -- are now stated.
- The cross-organization invite guard said it "applies to an EMAIL target
  too, once it resolves". It resolves only within the caller's own
  organization, so an address registered elsewhere slips past and is
  caught at accept time instead, leaving a handshake OPEN until it
  expires. That is the deliberate price of the anti-oracle scoping, and
  the comment now says so rather than promising a guard that does not
  fire.
- `managementAccountId`/`masterAccountId` being null used to mean "no
  organization exists" and now also means "more than one, so there is no
  single answer". The services page says to test `organizations`
  instead, which is empty only in the first case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…n the transfer target

- `CreateGovCloudAccount` enrolled the paired account with the SAME email
  as its commercial twin, so one address named two accounts and every
  target resolution picked whichever id sorted first. The mirror now
  carries its own synthesized address.
- `target_participant` decoded only the synthetic address form while the
  party gate resolves registered addresses too, so an account that could
  accept, describe, list and end a transfer was missing from the payload
  that names its target. It now resolves the same way; these ops are
  already party-gated, so the anti-oracle scoping that applies to
  invitation-time resolution is not needed here.
- A responsibility transfer could name a plain member of another
  organization, which cannot take over billing for one; AWS reports that
  as a handshake constraint violation. A standalone account is still a
  valid target -- it may create an organization before accepting, as AWS
  invites an owner it has not seen yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…d scope the id it returns

- The "not another organization's management account" guard asked
  `org_of_account` about the raw target, which for an EMAIL target is an
  address and never an account-id key -- so the guard never fired for
  that spelling. `333333333333@example.com` got through where
  `333333333333` was refused, and the plain member could then accept the
  transfer, since the cross-organization re-check on accept only runs
  for `INVITE` handshakes. The target is now resolved once, the way
  every party gate resolves it, before either check. That scan drives a
  rejection and never hands an id back, so it is not the oracle the
  id-returning resolvers are scoped to avoid.
- `target_participant` did hand an id back, from a registry-wide address
  lookup: once another organization registered the address a transfer
  had been opened to, the source could read that organization's account
  id straight out of `DescribeResponsibilityTransfer`. The lookup is now
  scoped to the organization holding the transfer, falling back to the
  synthetic form, which decodes only an id the caller already spelled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…eled not-in-use errors

Resolving a target by address is authorization-relevant -- it decides
who may accept an EMAIL-targeted invitation -- so the address has to
name one account. AWS already requires that (`EMAIL_ALREADY_EXISTS`),
and fakecloud was not enforcing it: two accounts could share one, and
whichever id sorted first won.

- `CreateAccount` / `CreateGovCloudAccount` reject an address already in
  use, including one reserved by an in-flight request.
- The GovCloud mirror keeps the owner's address, as AWS returns it --
  the previous commit had given it a synthesized one to dodge the
  duplicate. That pair is the one legitimate sharing case, so
  `account_registered_with` skips the mirror instead.
- `AcceptHandshake` now rejects an account that joined the INVITING
  organization while its invitation sat open. `invite_account` already
  rejects an existing member at invite time; the accept path flipped the
  handshake to ACCEPTED with no error and no effect.
- `ListInbound/OutboundResponsibilityTransfers` and
  `DescribeResponsibilityTransfer` require membership again. Both parties
  to a transfer are management accounts, so a caller in no organization
  has no transfers either way, and all three model
  `AWSOrganizationsNotInUseException`.
- `target_participant` keeps only the synthetic decode: the source-org
  lookup it had could never hit, since a target inside the source
  organization is rejected at invite time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…fer-target rule

- `CreateAccount` / `CreateGovCloudAccount` ran the registry-wide address
  check BEFORE the management gate, so any caller could learn whether an
  address was registered in an organization it has nothing to do with --
  the same oracle the transfer-invite path was reordered to close. The
  management gate now runs first.
- The CloudFormation account provisioner minted from the registry but
  skipped the address-uniqueness check, so two stacks could register one
  address in two organizations and make resolution depend on id order.
- `invite_account` and `resolve_target_account` matched addresses without
  skipping the GovCloud mirror, unlike `account_registered_with`. A
  GovCloud pair shares one address by design, so the three resolvers
  could name different ids for it.

The transfer-target rule has flip-flopped across reviews, so it is now
stated once, with its reasoning, next to both halves of the code: reject
the caller's OWN organization and a plain MEMBER of another (neither has
billing responsibility to hand over), and allow an account that belongs
to no organization -- AWS's invite takes an `EMAIL` party precisely to
address an owner it has not seen yet. That is why the inbound reads are
party-scoped rather than membership-gated: the target has to be able to
find the transfer addressed to it before it has an organization.
`ListOutbound...` stays gated, since its caller is a source management
account by construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…lease it on close

The address-uniqueness rule added last round was enforced in the wrong
places.

- `CreateAccount` / `CreateGovCloudAccount` rejected a duplicate
  synchronously with an error neither op models. Clients hand back a
  request id and poll -- Terraform's `aws_organizations_account` does --
  so an unmodeled exception breaks them. The request is now accepted and
  the completion tick fails it with `EMAIL_ALREADY_EXISTS`, which is the
  `CreateAccountFailureReason` AWS defines for exactly this. The check
  ignores the request's own reservation, or every create would fail.
- A closed account keeps its record but now releases its address.
  `CloseAccount` -- and the CloudFormation delete that calls it -- only
  suspends, so counting suspended accounts made a deleted stack
  impossible to re-deploy with the same address.
- Accepting an invitation enrolled the new member with whatever address
  the invitation named, with no uniqueness check, so an address
  registered elsewhere while the invitation sat open would land on two
  accounts. The accept now picks a free address -- the named one, else
  the account's own synthetic one -- rather than punishing an invitee
  for somebody else's registration.
- `DescribeResourcePolicy` is management-only, like `PutResourcePolicy`
  and `DeleteResourcePolicy`. The document can carry cross-account
  grants, and AWS restricts the read the same way; it was readable by
  any member.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…ounts release theirs

- Two in-flight `CreateAccount` requests for one address made whichever
  completion tick fired FIRST fail itself, since it still saw the other
  as `IN_PROGRESS` -- so the caller that asked first was the one refused.
  Only requests made before this one count now.
- The `EMAIL_ALREADY_EXISTS` failure was not persisted. The request had
  already been written as `IN_PROGRESS`, so a restart re-armed it and a
  poller watched it go FAILED -> IN_PROGRESS -> FAILED.
- `invite_account` and `resolve_target_account` matched addresses on
  SUSPENDED accounts while `email_in_use` and `account_registered_with`
  skip them. A closed account's address could therefore be re-used by
  `CreateAccount` yet never invited again -- refused as "already a
  member" by one resolver while another reported it named nobody.
- The accept-time staleness re-check covered `INVITE` only, so a
  responsibility-transfer target that had since become a plain member of
  some organization could still accept, recording a non-management
  account as the transfer's target. That is exactly what the invite-time
  guard rejects, and the same "the world moved while this sat open"
  reasoning applies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
`<account-id>@example.com` is the address fakecloud mints for the
accounts it creates, so it belongs to that id whether or not the account
exists yet. Nothing enforced that: `CreateAccount` could register
another id's synthetic address (minting a random id to hold it), and
`CreateOrganization` never checked at all before registering its own
management account with one. Either way two live accounts ended up
sharing an address, and `account_registered_with` then answered with
whichever organization sorted first -- the answer that decides who may
accept an EMAIL-targeted handshake.

`CreateAccount` now fails such a request with `EMAIL_ALREADY_EXISTS`,
and `CreateOrganization` refuses when the management account's own
address is taken.

That also removes the trap in the accept path, where an `ACCOUNT`
handshake's "named" and "synthetic" addresses are the same string: with
both seen as taken, the accept failed as "already a member of this
organization", which the invitee was not and could never fix. It now
enrolls with the synthetic address, which nothing else may hold.

Also: `target_participant` and `transfer_payload` carried a `registry`
argument that was discarded with `let _ =`, and two doc comments had
been mis-merged onto the wrong functions.

Not changed, deliberately: a SUSPENDED account keeps its account id
claimed even though it releases its address. AWS keeps a closed account
in the organization, and the address release exists only because
CloudFormation's delete path suspends rather than removes -- a
re-deployed stack mints a fresh id, so the id needs no release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…hs too

The CloudFormation provisioner creates the same resources as the API and
must apply the same rules; it had picked up only half of them.

- `AWS::Organizations::Account` checked that the address was unused but
  not that it was the synthetic form reserved for a different id, so a
  template could squat `999999999999@example.com`. Resolution by address
  then named the squatter, and the real `999999999999` was permanently
  locked out of `CreateOrganization`.
- `AWS::Organizations::Organization` never checked the management
  account's own address at all.

Also:

- `DescribeResourcePolicy` is management OR delegated administrator, not
  management-only. The model documents Put/Delete as management-only but
  the read as "the management account or a member account that is a
  delegated administrator"; the previous commit tightened it too far.
- The accept path's "no free address" case reported
  `AccountAlreadyMember`, which is false there -- the gates above have
  just proved the caller is a member of no organization. It now says
  what actually happened.
- Dropped an accessor nothing calls and a comment describing a check
  that has since moved into the completion tick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…ze the CFN path first

- `is_transfer_target` asked the matcher to read the stored target as an
  address even when it is a bare 12-digit account id. `CreateAccount`
  does not validate the address format, so an account registered with
  the literal string "222222222222" passed as the target of a transfer
  addressed to ACCOUNT 222222222222 -- it could read the transfer, see
  it in the inbound listing, and end it once accepted. Only the
  EMAIL-form target resolves further now, matching `target_participant`.
- The CloudFormation account provisioner ran its registry-wide address
  checks before resolving the stack account's own organization, so a
  stack in no organization could learn whether an address was registered
  in one it has nothing to do with -- the oracle the API paths authorize
  first to avoid. It now resolves the organization first and reports
  "Organization not yet created" for that case, as it did before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
… gates

Re-accepting an already-accepted handshake reported
`HandshakeConstraintViolationException` / `ALREADY_IN_AN_ORGANIZATION`
instead of the modeled transition error. The membership gates added for
multi-organization run before `resolve_handshake`, which is the only
place terminal state was checked -- and a successful accept necessarily
makes the caller a member, so the second accept always tripped them. A
client retrying after a timeout (the AWS SDKs retry by default) read a
join that had actually succeeded as a hard constraint failure, and
Decline and Cancel disagreed, since their gates are keyed on ACCEPTED.

Also: `CreateAccount` applies create-time tags to the reserved account
id synchronously, on the assumption that every request ends SUCCEEDED.
The `EMAIL_ALREADY_EXISTS` failure introduced here breaks that, leaving
tags on an id that never becomes an account and that AWS answers for
with `TargetNotFoundException`. `fail_create_account` now drops them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…an account sent

- Moving the terminal-state check ahead of the membership gates put it
  ahead of the PARTY gate too, so an account outside the organization
  could tell "exists, resolved" from "exists, open" from "no such
  handshake" by guessing ids -- the enumeration the new
  `DescribeHandshake` gate exists to prevent. The order is now party,
  then state, then membership: a bystander learns only that it is not a
  party, while a party still gets the transition error a retry needs.
- `is_transfer_target` asked the matcher to read the stored target as an
  address even for a 12-digit account id. `CreateAccount` does not check
  that `Email` is address-shaped, so an account registered with the
  literal string "222222222222" passed as the target of a transfer
  addressed to ACCOUNT 222222222222.
- `ListHandshakesForAccount` returned only handshakes an account was the
  TARGET of. AWS lists those "associated with the account of the
  requesting user", which includes the invitations it sent -- a
  management account got an empty list right after inviting someone.

Not changed: `Target.Type` stays required. `HandshakeParty.Type` does
carry `smithy.api#required` in `aws-models/organizations.json`, so
rejecting an omitted type is what the model asks for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…ter, name the caller in its own transfer

- `Filter.ActionType = TRANSFER_RESPONSIBILITY` was rejected as an
  unrecognized action. It is a real `ActionType`, and this work is what
  makes those handshakes visible to both parties -- a source management
  account now sees its own outbound transfer handshakes, so filtering
  for them is the obvious next call.
- `target_participant` resolved an EMAIL target only through the
  synthetic form, so a recipient whose registered address the transfer
  names read back `Target.ManagementAccountId: null` -- its own id
  withheld, even though the party gate had just used that very
  resolution to let it in. A registered address now resolves, but only
  to the CALLER itself: telling a caller its own id reveals nothing,
  while an address naming somebody else still reports no id, so the
  lookup cannot be read as an oracle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…omplete the transfer handshake

Four fixes from a self-review of this branch.

Delegated administrators were locked out of the organization they
administer. `ListHandshakesForOrganization`,
`ListAWSServiceAccessForOrganization`, `ListDelegatedAdministrators`
and `ListDelegatedServicesForAccount` gated on the management account
alone, so the account AWS registers precisely to run a service's
org-wide integration could not read the organization it was registered
for. They now go through one shared `management_or_delegated_org` gate,
which `DescribeResourcePolicy` already needed and now shares. Mutating
operations stay management-only via `management_org_mut`; with no
management-only read left, the read-side `management_org` is gone.

`InviteOrganizationToTransferResponsibility` had no duplicate guard,
though `DuplicateHandshakeException` is modeled on it. A caller could
stack OPEN transfers on one target, and accepting any of them would
move billing while the rest stayed OPEN against an organization that no
longer owned it. It now enforces one live offer per target, comparing
the RESOLVED target so the same account named by id and by its address
collides. Resolution falls back to the synthetic address form, since
the target of this invite usually exists in no organization yet.

A TRANSFER_RESPONSIBILITY handshake reported only ORGANIZATION and its
target, leaving the invited account unable to tell a billing transfer
from a plain invitation without a second call.
`HandshakeResourceType` models RESPONSIBILITY_TRANSFER, TRANSFER_TYPE,
TRANSFER_START_TIMESTAMP, MANAGEMENT_ACCOUNT and MANAGEMENT_EMAIL for
exactly that, so the handshake now carries them nested under the
transfer id. The handshake stores the transfer id itself rather than
reading the link from the transfer's `ActiveHandshakeId`, which is
cleared on resolution -- reading it from that side made an ACCEPTED
handshake report no transfer at all.

An in-flight `CreateAccount` whose address is the synthetic form of an
id OTHER than the one it reserved is already doomed: the completion
tick fails it with EMAIL_ALREADY_EXISTS. It was still holding the
address meanwhile, so any caller could park another account's address
for the length of the creation delay and block that account's own
`CreateOrganization`. `email_in_use` and `email_in_use_besides` now
share one `reservation_holds` predicate that skips a doomed
reservation.

Tests: four regression tests covering each fix, including that the
transfer resource survives the handshake's resolution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
…ership it was granted to

Four fixes from a self-review of the previous commit.

A delegated-administrator registration is an organization's grant to
one of its OWN members, so it cannot outlive the membership.
`remove_account` detached the account's policy attachments but left its
registrations behind, so `ListDelegatedServicesForAccount` still
answered for an account the organization no longer contains -- and, now
that the same registration unlocks the organization's read operations,
an account that left and was later re-invited came back holding
delegated-administrator authority nobody had granted it. It is dropped
with the membership, the way `DisableAWSServiceAccess` already drops
the registrations for a principal it untrusts.
(`ListDelegatedAdministrators` never showed the symptom: its handler
already drops any registration whose account is not in `accounts`.)

`remove_account` left the account's tags behind for the same reason,
which `ListTagsForResource` went on answering with and a later
re-enrollment restored. `fail_create_account` already drops the tags it
reserved on an id that never became an account; removal is the same
case.

A `TRANSFER_RESPONSIBILITY` handshake written before the handshake
carried its transfer id deserializes without one, and nothing backfills
it, so a restored organization reported no RESPONSIBILITY_TRANSFER
resource at all. The payload falls back to the transfer's own
`ActiveHandshakeId`, which covers every restored handshake still OPEN.
One that had already resolved is beyond recovery -- resolution is what
clears that field -- so this is as far back as the stored data reaches.

Two doc comments described the function below the one they had been
attached to, after the previous commit inserted a helper between them.

Tests: a delegated administrator that leaves is neither answered for
nor authorized after rejoining, a removed account's tags are gone, and
a handshake stripped of its transfer id still reports the transfer.
Each new assertion was checked to fail with the fix reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
@vieiralucas
vieiralucas force-pushed the feat/2543-multi-organization branch from 3cce86f to af5e157 Compare September 24, 2026 13:23
`DeregisterDelegatedAdministrator` has two ways to find nothing to drop:
the service principal has other delegates but not this account, or it
has no delegates at all. The second arm passed the SERVICE PRINCIPAL
into an error whose message is "Account {id} is not registered as a
delegated administrator.", so a caller deregistering for a principal
nobody is registered under read "Account config.amazonaws.com is not
registered as a delegated administrator." Both arms now name the
account, which is what AWS reports and what the error code means.

Pre-existing, and reachable before this branch; found while reviewing
the delegated-administrator cleanup that added one more route to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeYYZqG5anqYXR8761ZjWu
@vieiralucas
vieiralucas merged commit eadb31d into main Sep 24, 2026
157 checks passed
@vieiralucas
vieiralucas deleted the feat/2543-multi-organization branch September 24, 2026 15:02
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.

Introspection endpoint /_fakecloud/iam/create-admin joins to existing organization

1 participant