Skip to content

dir-sim: Exchange hybrid recipients, the RemoteMailbox lifecycle, and hybrid identity anchors (vocabulary only) - #320

Merged
AdaWorldAPI merged 8 commits into
mainfrom
ccr-0455e606-wmtsor
Oct 7, 2026
Merged

AdaWorldAPI merged 8 commits into
mainfrom
ccr-0455e606-wmtsor

Conversation

@AdaWorldAPI

@AdaWorldAPI AdaWorldAPI commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

What

This PR adds three things, all vocabulary:

  • the Exchange hybrid recipient model, grounded in the flag table of jahube/PShell RemoteMailbox-Archive/Enable-Remotemailbox.PS1;
  • the RemoteMailbox lifecycle;
  • the hybrid identity anchors that link an on-premises object to its cloud object.

There is no executor, storage, shell, cmdlet text, AD/Graph/Exchange write, credential or actuator code. Plans name semantic operations (Operation::RemoteMailbox { op }), never Enable-RemoteMailbox ....

Contract

Ingest (ogar-ad, append-only)

  • Schema v2 adds msExchRemoteRecipientType and msExchRecipientDisplayType as U32 values (the display type is bit-cast because it is signed), and msExchRecipientTypeDetails as its 64-bit decimal text.
  • Schema v3 adds mS-DS-ConsistencyGuid and msDS-ExternalDirectoryObjectId as 128-bit ids, never as AD strings. They go in inline GUID slots that DirRecord ABI minor 1 carves from the reserved bytes: four 16-byte slots (AttrKind::Guid). A minor-0 record reads as "no id".
    • In: the anchor's mixed-endian bytes become a Guid128. User_ / Group_ is stripped and must match the object kind (user → User_, group → Group_); otherwise the entry is refused with BadId.
    • Out: ogar_ad::external_directory_object_id(rec) adds the label back from the kind; ogar_ad::consistency_guid(rec) returns the AD bytes.
    • One label codec. It lives in ogar_dir_core::label; ogar-dir-sim re-exports it. ogar-az stores a Guid attribute from Graph's bare GUID.
  • mailNickname (the Exchange Online Alias) was already ingested.

Recipients (exchange)

  • Recipient is one of:

    • NotMailEnabled
    • OnPremisesMailbox { archive }
    • RemoteMailbox(RemoteMailbox)
    • Other(raw)

    Decoding is strict: a value decodes only if re-encoding it reproduces the observed triplet plus targetAddress; anything else stays Other.

  • REMOTE_RECIPIENT_TYPES has 27 codes: the script's 26, plus 97 = 64+32+1. 97 is the provisioned shared mailbox that New-RemoteMailbox -Shared writes; the script does not list it. Shared is 96 (room + equipment); 100 is migrated shared.

  • Validity. A RemoteMailbox with an unlisted code cannot be constructed. It has a routing address iff it is not deprovisioned.

  • Lifecycle (RemoteMailboxOp). The steps are Enable (user / room / equipment / shared), EnableArchive, DisableArchive, CompleteMove (from on-premises), SetType and Disable. A step returns NotApplicable if it does not start from the current state, and NotInTable if it would land on an unlisted code. For example, 35 → shared would be 99, which is refused.

  • Changes. between(from, to) returns the unique single step. Change::SetRecipient is compare-and-set; NodeState.recipient: None means "not read". It lowers to Operation::RemoteMailbox with Precondition::RecipientEquals(from), or NotActuatable if no single step reaches the target.

Hybrid identity (identity)

  • SourceAnchor(Guid128) comes from mS-DS-ConsistencyGuid (mixed-endian, like objectGUID). immutable_id() is its base64, which is the cloud ImmutableId.
  • ExternalObjectId { label: CloudLabel, object_id: Guid128 } comes from msDS-ExternalDirectoryObjectId. The Entra (formerly MSOL) ObjectId, Exchange Online's ExternalDirectoryObjectId and AD's msDS-ExternalDirectoryObjectId are one id: the cloud spellings are the bare GUID, and only AD's adds User_. The prefix is a label rendered on egress, never stored, so all three compare as 128-bit values.
  • Strict decoding. Each value must render back to exactly what was observed. Unknown labels, wrong case, braces and the nil id are refused.
  • ogar-dir-core::base64 gains encode.

⚠ Merge order

NodeState.recipient and Change::SetRecipient break the exhaustive matches and NodeState literals in lance-graph lance-graph-dir-sim. lance-graph main will not compile against this until the companion lance-graph PR lands. That PR will:

  • ingest the attributes and decode them;
  • add recipient lanes and overlay;
  • handle SetRecipient in apply, diff and plan;
  • make address uniqueness count every recipient regardless of the enabled flag.

It also depends on lance-graph #1386.

Tests

The workspace passes 999 tests; fmt and clippy -D warnings are clean. Every disable below was red, then green once restored. Each was run against a committed tree.

disabled caught by
decode without the round-trip check inconsistent_attributes_stay_raw
table not enforced inconsistent_attributes_stay_raw, steps_outside_the_table_are_refused
Disable keeps the routing address lifecycle_transitions_land_on_listed_codes
deprovisioned still counted as a recipient lifecycle_transitions_land_on_listed_codes
shared bits decoded as room exactly_the_27_listed_remote_recipient_types_decode
97 dropped from the table no_two_ops_reach_the_same_recipient, steps_outside_the_table_are_refused, +1
SetRecipient compare-and-set ignores from set_recipient_is_compare_and_set
precondition taken from to a_recipient_change_lowers_to_one_lifecycle_step
two ops made to coincide no_two_ops_reach_the_same_recipient
CompleteMove removed no_two_ops_reach_the_same_recipient (reachability)
ExternalObjectId::parse without the render check anything_that_does_not_render_back_stays_raw
nil cloud id accepted anything_that_does_not_render_back_stays_raw
anchor read in textual byte order instead of mixed-endian (dir-sim) the_immutable_id_is_the_base64_of_the_consistency_guid
from_immutable_id not exact the_immutable_id_is_the_base64_of_the_consistency_guid
base64 encode without padding encodes_rfc4648_vectors_and_round_trips
ogar-ad v2 row renamed exchange_recipient_attributes_are_kept_raw
ingest label not checked against the kind a_wrong_or_missing_label_is_refused
egress drops the label hybrid_identity_anchors_are_ids_not_strings
AD anchor read in textual byte order (ogar-ad) hybrid_identity_anchors_are_ids_not_strings
GUID presence bit aliased onto the numeric bits guid_slots_are_inline_and_additive
GUID read ignoring presence guid_slots_are_inline_and_additive
schema validation ignoring the GUID space schema_validation_knows_the_guid_space
label::strip without the render check anything_that_does_not_render_back_is_refused

Two checks were not load-bearing:

  • The explicit Enable(Shared) guard was removed. It had refused shared because the code was unlisted; once 97 was added, shared became valid anyway.
  • between's uniqueness check cannot fire with the current op set. An exhaustive walk now pins that every (from, to) pair has at most one op, and that all 27 codes are reachable. Codes 2 and 16 are reached as on-premises mailboxes with a cloud archive.

Open

  • The routing smtp: proxy is not added to the proxy relation; the executor decides who owns it.
  • Nothing yet matches an on-premises node to its cloud object by anchor or cloud id; that is the lance-graph side.
  • There is no mailbox routing emulation (out of scope).

🤖 Generated with Claude Code

https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg

claude added 3 commits October 7, 2026 17:26
… strictly, and simulate the remote-mailbox lifecycle

What an object is to Exchange comes from msExchRemoteRecipientType,
msExchRecipientDisplayType, msExchRecipientTypeDetails and targetAddress,
not from the account's enabled flag: shared, room and equipment mailboxes
are disabled accounts and still recipients.

ogar-ad, schema v2: ingests the triplet raw. The remote recipient type
and the (signed, bit-cast) display type take numeric slots 1 and 2; the
64-bit type details keep their LDAP decimal text in pooled slot 14.

ogar-dir-sim::exchange:
- Recipient decodes the attributes strictly: exactly the 26
  msExchRemoteRecipientType values the hybrid lifecycle produces (the
  table of jahube/PShell RemoteMailbox-Archive/Enable-Remotemailbox.PS1),
  with their display and type codes and, while the mailbox exists, its
  routing address; an on-premises mailbox with its remote-archive state;
  not mail-enabled. Anything else is Other, kept raw. A decoded state
  re-encodes to exactly its input.
- RemoteMailboxOp is the lifecycle: Enable (user / room / equipment, with
  routing address), EnableArchive, DisableArchive, CompleteMove, SetType,
  Disable. Each step refuses outside the table (NotApplicable /
  NotInTable).
- NodeState gains recipient: Option<Recipient> (None = not read, distinct
  from NotMailEnabled); Change::SetRecipient is compare-and-set through
  NodeState::apply; a plan lowers it to the single lifecycle step joining
  the two roles (Operation::RemoteMailbox, Precondition::RecipientEquals),
  or refuses it as NotActuatable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg
…lready refuses code 97

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg
…h every listed code

between() returns the unique step; the uniqueness check was unreachable with
the current op set. An exhaustive walk now pins both properties, so an op
added later that makes two steps coincide fails a test instead of silently
returning None.

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

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 83394433-eb44-42fc-bb58-f56e1abe5aab
📥 Commits

Reviewing files that changed from the base of the PR and between c49907b and cc35140.

📒 Files selected for processing (15)
  • crates/ogar-ad/src/lib.rs
  • crates/ogar-ad/tests/main.rs
  • crates/ogar-az/src/lib.rs
  • crates/ogar-dir-core/src/base64.rs
  • crates/ogar-dir-core/src/label.rs
  • crates/ogar-dir-core/src/lib.rs
  • crates/ogar-dir-core/src/record.rs
  • crates/ogar-dir-core/src/schema.rs
  • crates/ogar-dir-core/tests/main.rs
  • crates/ogar-dir-sim/src/change.rs
  • crates/ogar-dir-sim/src/exchange.rs
  • crates/ogar-dir-sim/src/identity.rs
  • crates/ogar-dir-sim/src/lib.rs
  • crates/ogar-dir-sim/src/plan.rs
  • docs/DIRECTORY-SIMULATION-POC.md
 __________________
< X marks the bug. >
 ------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption.


Comment @coderabbitai help to get the list of available commands.

ogar-ad schema v3 ingests mS-DS-ConsistencyGuid (source anchor, base64 =
ImmutableId) and msDS-ExternalDirectoryObjectId (Entra Connect backsync,
User_<cloud object id>) raw; mailNickname (= the Exchange Online Alias) was
already ingested.

ogar-dir-sim::identity decodes both to Guid128. The User_/Group_ prefix is a
CloudLabel rendered on egress, not a stored string, so the two ids compare
as 128-bit ids. Decoding is strict: anything that does not render back to
exactly the observed value stays raw.

exchange: 97 (64 + 32 + 1, what New-RemoteMailbox -Shared writes) joins the
code table, which the source script does not list. A provisioned room may now
become shared (33 -> 97); an archived one still may not (35 -> 99).
ogar-dir-core::base64 gains encode for the ImmutableId.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg
@AdaWorldAPI AdaWorldAPI changed the title dir-sim: Exchange hybrid recipients and the RemoteMailbox lifecycle (vocabulary only) dir-sim: Exchange hybrid recipients, the RemoteMailbox lifecycle, and hybrid identity anchors (vocabulary only) Oct 7, 2026
claude added 4 commits October 7, 2026 17:48
…nd AD msDS-ExternalDirectoryObjectId are one id

Only AD's backsync copy carries the User_ label; the two cloud spellings are
the bare GUID. Documented and pinned by a test.

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

DirRecord ABI minor 1 gives the first 64 reserved bytes four inline 128-bit
id slots (AttrKind::Guid, presence bits 36..40). A minor-0 record has them
zero with the bits clear, so it reads as no id.

ogar-ad schema v3 now puts mS-DS-ConsistencyGuid and
msDS-ExternalDirectoryObjectId there instead of pooled text. In: the anchor's
mixed-endian bytes become a Guid128; the User_/Group_ label is stripped and
must match the object kind, else the entry is refused (BadId). Out:
external_directory_object_id() adds the label back from the kind,
consistency_guid() returns the AD bytes.

The label codec has one definition, ogar_dir_core::label; ogar-dir-sim's
identity module re-exports it instead of keeping a copy. ogar-az stores a
Guid attribute from Graph's bare GUID.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg
LabelPattern holds the template; strip matches the text around {0}, render
substitutes the id. A new object kind is one more template, not new code.
ogar-ad decodes with the object kind's own template, so a User_ id on a
group does not match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G22yT6htkcdyXsihxxXdrg
@AdaWorldAPI
AdaWorldAPI marked this pull request as ready for review October 7, 2026 18:22
@AdaWorldAPI
AdaWorldAPI merged commit 5cb6d69 into main Oct 7, 2026
4 checks passed
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-07T18:23:04.334082Z cc35140 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

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.

2 participants