Skip to content

feat(local-node-host): coordinate tenant session transitions - #3244

Merged
ctwoodwa merged 2 commits into
mainfrom
feat/3239-mtw-session-transition-gates
Jul 28, 2026
Merged

feat(local-node-host): coordinate tenant session transitions#3244
ctwoodwa merged 2 commits into
mainfrom
feat/3239-mtw-session-transition-gates

Conversation

@ctwoodwa

@ctwoodwa ctwoodwa commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Intent

Complete the MTW-01C session/audience isolation and coordinated-transition authorities:

  • make selected, challenge, and installation cookies authoritative for their own audiences so a
    miss, refusal, or expired selected handle cannot fall through to a legacy credential;
  • add a selected-audience tenant-switch route and restart-safe authority;
  • keep the old selected session live and the target session invisible until the old-tenant
    revocation receipt, target-tenant selection receipt, and installation audit head are all durable;
  • rotate the selected session and revoke the old handle together in one session-store transaction.

The existing red fixtures remain unchanged.

Review round 2 — both blockers closed (fc9fd72e6)

Deep review (council-verdict-2026-07-28T1021Z) returned CHANGES REQUESTED with two blockers.
Both are fixed in fc9fd72e6.

BLOCKER 1 — WebTenantSwitch was not admitted by the home-decision fence

InstallationIdentityHomeDecisionAuthority.RequireAsync dispatches on row.CommandType;
WebTenantSwitchAuthority.CommandType had no arm, so every real switch fell to the default arm
(ValidateStoredCoordinator, which demands TenantMembershipMutation / schema 2) and threw
identity.coordinator_payload_invalid at the first tenant-head finalization — a 500, a coordinator
wedged at Committing behind a deterministic correlation id, and dangling prepared intents in both
tenant authority documents.

The arm is now registered through a new WebTenantSwitchAuthority.ValidateStoredSwitchTenants
adapter.

On ValidateStoredSwitch itself: it needed nothing. It was already correct and current — it is
called on the live path (SwitchAsync resume + post-create revalidation), so it could not have
drifted from the payload. It validates command type + schema version, payload presence, the
recomputed payload digest, the recomputed correlation id and fingerprint, the account security
version, old-vs-target tenant distinctness, and TenantIdsJson against the ordinal-ordered expected
pair. The only gap was shape: the sibling arms return string[] (admitted tenant ids) while
ValidateStoredSwitch returns the payload. The adapter runs the same validation and returns
[OldTenantId, TargetTenantId] in the ordinal order CreateHomeAsync writes — the same set
ValidateStoredSwitch has just proven the row still carries. No behaviour change to the validator.

BLOCKER 2 — the test seam could not see BLOCKER 1

WebTenantSwitchRealSeamTests (new) drives a switch through the REAL
EncryptedTenantMembershipAuthorityStore over REAL SqlCipherEncryptedStores, constructed with the
REAL InstallationIdentityHomeDecisionAuthority over the REAL migrated identity database — the
composition Program.cs builds via TeamContextTenantIdentityAuthorityPartitionResolver. Both
memberships are created by the REAL coordinator, and the old selected session is minted by the REAL
challenge issuer + REAL tenant-selection authority over a REAL Argon2id credential. No session,
membership, or receipt row is inserted directly. Substituted (mirroring the Mtw2 recipe, all
non-teeth): the partition RESOLVER, the party reader, the lease coordinator, and the admission seam.

Two tests: the end-to-end switch (asserting real fence-minted home-decision digests, not the
double's constants), and a direct probe that the arm admits exactly the two tenants of the durable
switch and refuses a third with identity.home_decision_mismatch.

Falsification evidence (arm deliberately deleted, then restored):

Run WebTenantSwitchRealSeamTests + registry arch test WebTenantSwitchAuthorityTests + TenantSwitchRoutesTests
arm registered 3 passed, 0 failed 4 passed
arm deleted 3 failed, 0 passed 4 passed
arm restored 3 passed, 0 failed 4 passed

Every red carried the reviewer's exact stack:

System.InvalidOperationException : identity.coordinator_payload_invalid: command type is not admitted.
   at InstallationIdentityCoordinatorService.ValidateStoredCoordinator(...) :line 1021
   at InstallationIdentityHomeDecisionAuthority.RequireAsync(...) :line 85
   at EncryptedTenantMembershipAuthorityStore.FinalizeSessionRevocationAsync(...) :line 499
   at WebTenantSwitchAuthority.FinalizeTenantHeadsAsync(...) :line 652
   at WebTenantSwitchAuthority.SwitchAsync(...) :line 242

The middle row is BLOCKER 2 stated empirically: the card's original 606-line suite stays fully green
with the defect present, because RecordingMembershipStore fabricates its own receipts and never
crosses the fence.

Drift canary (reviewer's recommendation, adopted)

InstallationIdentityHomeDecisionRegistryArchTests scans every const string CommandType declared
under Data/Identity/ and drives each one through the REAL fence over a REAL identity database. A
registered command type refuses with its OWN error code; only the default arm answers
identity.coordinator_payload_invalid. A new command type without an arm now fails the build
instead of production. It found no other unregistered command type — the four declared today
(WebTenantSelection, WebSelectedSessionLogout, WebTenantSwitch, and the coordinator's own
TenantMembershipMutation, which legitimately owns the default arm) all resolve correctly.

Round-2 verification

  • new tests + switch/identity slice (WebTenantSwitchRealSeamTests, registry arch test,
    WebTenantSwitchAuthorityTests, TenantSwitchRoutesTests, InstallationIdentityCoordinatorServiceTests,
    TenantMembershipAuthorityStoreTests, WebTenantSelectionAuthorityTests,
    WebSelectedSessionLogoutAuthorityTests, Mtw2TwoUserAcceptanceE2E,
    InstallationIdentityDormancyArchTests): 45/45 passed
  • full local-node-host suite: 1586 passed, 18 skipped, 0 failed (1 m 49 s, exit 0 — the VSTest
    host inactivity abort seen on the first round did not reproduce)
  • scoped dotnet format --verify-no-changes on all four changed files: passed
  • git diff --check: passed

Known follow-ups (NOT in this PR — carded separately)

  • MAJOR (from the same verdict): a refused switch consumes the one-time selected antiforgery token
    with no re-issue path.
    TenantSwitchRoutes.SwitchAsync consumes before calling the authority and
    returns 401 on refusal without emitting a replacement; IWebAntiforgeryPolicy has
    RotateChallengeAsync but no RotateSelectedAsync, and IssueAnonymousAsync refuses when a
    selected cookie is present. One refused switch therefore disables every state-changing
    selected-audience request including logout — and clicking your CURRENT tenant in a switcher is
    enough to trigger it. Not introduced by this PR (four sibling routes consume-without-rotate), but
    switch is the first such route whose refusal is routine. Sized before punting: the fix is a new
    interface member, a small RotateAudienceAsync generalisation in WebAntiforgeryPolicy
    (ResolveSubjectAsync already handles the selected audience), one call on the refusal path, plus
    the seven test fakes that implement the interface — roughly 11 files. That is an interface change
    on a security boundary and warrants its own review, so it is not folded in here.
  • MINOR: resume paths skip idle-expiry revalidation. The Committing / Finalizing /
    Completed branches do not call RequireCurrentActiveSessionAsync, which the Preparing branch
    does, and neither RotateSessionsAsync nor HasSameAuthorityCoordinates checks
    IdleExpiresAtUtc. Two upstream gates make it unreachable today; it is an asymmetry worth closing.

Round-1 red-green-red proof (unchanged)

  1. Baseline: MTW00C_RUN_RED=1 dotnet test apps/local-node-host/tests/tests.csproj --filter "Category=RedFixture" failed all 18 red fixtures. The seven MTW-01C fixtures each failed with
    its named MissingAuthorityException.
  2. Green: after implementation, the exact seven MTW-01C red fixtures passed 7/7.
  3. Reverse red: with only the seven authority registrations temporarily removed, the same exact
    filter failed 7/7 with the original named MissingAuthorityException reasons.
  4. Restored green: restoring the registrations made the exact seven pass 7/7 again.

The deep review correctly identified this proof as tautological (a red fixture's pass/fail is a pure
function of a string literal in a HashSet). The real-seam test above is the gate that actually
exercises the wiring; the red-fixture harness itself is tracked on its own card and is deliberately
untouched here.

Closes #3239

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@ctwoodwa, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a0e4566d-e7d9-4446-8909-b3f01c738f10

📥 Commits

Reviewing files that changed from the base of the PR and between 70ad2c7 and fc9fd72.

⛔ Files ignored due to path filters (1)
  • apps/local-node-host/tests/Identity/Mtw00AInventory/mtw-00a-identity-inventory.generated.md is excluded by !**/*.generated.*
📒 Files selected for processing (16)
  • apps/local-node-host/Data/Identity/InstallationIdentityCoordinatorService.cs
  • apps/local-node-host/Data/Identity/WebTenantSwitchAuthority.cs
  • apps/local-node-host/Health/SharedHostedWebApp.cs
  • apps/local-node-host/Health/WebSession/HostedWebSessionApiEndpoint.cs
  • apps/local-node-host/Health/WebSession/TenantSwitchRoutes.cs
  • apps/local-node-host/Program.cs
  • apps/local-node-host/tests/Health/SharedHostedWebAppCallerAuthTests.cs
  • apps/local-node-host/tests/Identity/InstallationIdentityDormancyArchTests.cs
  • apps/local-node-host/tests/Identity/InstallationIdentityHomeDecisionRegistryArchTests.cs
  • apps/local-node-host/tests/Identity/Mtw00CRedFixtures/Mtw00CRedFixtureHarness.cs
  • apps/local-node-host/tests/Identity/Mtw00CRedFixtures/Mtw00CRedFixtureMetaTests.cs
  • apps/local-node-host/tests/Identity/TenantSwitchRoutesTests.cs
  • apps/local-node-host/tests/Identity/WebSelectedSessionStoreTests.cs
  • apps/local-node-host/tests/Identity/WebSessionRecordContractTests.cs
  • apps/local-node-host/tests/Identity/WebTenantSwitchAuthorityTests.cs
  • apps/local-node-host/tests/Identity/WebTenantSwitchRealSeamTests.cs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/3239-mtw-session-transition-gates

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

thought (non-blocking):

Accessibility audit (advisory)

The sharded axe audit is report-only while the baseline and runtime budget mature.
Unknown moderate-or-higher findings must be resolved or added to the reviewed
accessibility baseline.

Shard Result Findings
1/4 pass 0 axe finding(s), 1 coverage warning(s)
2/4 pass 0 axe finding(s), 1 coverage warning(s)
3/4 pass 0 axe finding(s), 1 coverage warning(s)
4/4 pass No moderate-or-higher findings
Shard 1 report
[shipyard-syncstate-indicator] skipping directionalIcons enforcement: component has not opted in via [data-shipyard-direction="<state>"] sentinel.
Shard 2 report
[shipyard-button] skipping keyboardMap enforcement: component has not opted in via data-shipyard-keyboard-map="enabled".
Shard 3 report
[shipyard-dialog] skipping keyboardMap enforcement: component has not opted in via data-shipyard-keyboard-map="enabled".

The installation home-decision authority dispatches on the coordinator row's
command type, and the switch command type had no arm. Every real tenant switch
therefore fell to the default arm, which admits only the coordinator's own
membership-mutation schema, and threw at the first tenant-head finalization: a
500, a coordinator wedged at Committing behind a deterministic correlation id,
and prepared intents left dangling in both tenant authority documents.

Register the arm through a new ValidateStoredSwitchTenants adapter. It runs the
existing stored-payload validation and returns the ordinal-ordered old/target
tenant pair the sibling arms' contract expects, which is the same pair the
authority already writes to the coordinator row.

The card's existing switch tests could not see this: they drive the authority
through a hand-rolled membership-store double that fabricates its own receipts
and never crosses the fence. With the arm deliberately deleted those four tests
still pass. Two gates now close that hole:

- a real-seam test that drives a switch through the real encrypted tenant
  membership stores and the real home-decision authority, with the old session
  minted by the real challenge and selection authorities over a real Argon2id
  credential. It goes red when the arm is removed.
- an architecture test asserting every command type declared under Data/Identity
  resolves to its own arm rather than the default one, so the next coordinator
  author gets a failing build instead of a production incident.

Full local-node-host suite: 1586 passed, 18 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7ge8CQJE4tyGHQSddBYmJ
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.

MTW-01C — session/audience isolation + R3-H coordinated transition completion gates

1 participant