From 874c082205291d6660f0c07684beb8cf65284de6 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 7 Oct 2026 13:59:45 -0400 Subject: [PATCH 1/2] test(mock): an active leaver's calendar sweep ends exit 50, as live The first live offboarding showed `all users` includes the still-active leaver, so every sweep ends on their own calendar: exit 50, "Cannot change your own access level", tolerated. The mock's default sweep exited 0, a path live GAM never takes for an active leaver; it now ends the same way. SWEEPCLEAN keeps the exit-0 path for a leaver suspended first. Co-Authored-By: Claude Opus 5.5 --- tests/fixtures/mock_gam.sh | 10 +++++++++- tests/test_lifecycle.py | 15 ++++++++++----- tests/test_mock_gam.py | 13 ++++++++++++- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/tests/fixtures/mock_gam.sh b/tests/fixtures/mock_gam.sh index 9a9d008..dc033e0 100755 --- a/tests/fixtures/mock_gam.sh +++ b/tests/fixtures/mock_gam.sh @@ -934,6 +934,10 @@ if [ "${1:-}" = "all" ] && [ "${2:-}" = "users" ] && [ "${3:-}" = "delete" ] && scope="$1" case "$scope" in *SWEEPSLOW*) exec sleep 30 ;; + *SWEEPCLEAN*) + # A leaver suspended before offboarding is not among `all users`: no own calendar visited, exit 0. + printf 'User: alice@example.com, Calendar: alice@example.com, Calendar ACL: (Scope: user:%s), Deleted\n' "$scope" + exit 0 ;; *OWNACL*) printf ' Calendar: %s, Calendar ACL: (Scope: user:%s), Delete Failed: Cannot change your own access level.\n' "$scope" "$scope" 1>&2 exit 50 ;; @@ -951,8 +955,12 @@ if [ "${1:-}" = "all" ] && [ "${2:-}" = "users" ] && [ "${3:-}" = "delete" ] && printf ' Calendar: %s, Calendar ACL: (Scope: user:%s), Delete Failed: Cannot change your own access level.\n' "$scope" "$scope" 1>&2 exit 50 ;; esac + # Seen live (2026-10-07, GAM 7.48.14): `all users` includes the leaver, who is still active after the + # reset, so every sweep ends on their own primary calendar, which GAM refuses to change — exit 50 with + # this line, tolerated as OWN_ACL. A clean exit 0 never happens for an active leaver. printf 'User: alice@example.com, Calendar: alice@example.com, Calendar ACL: (Scope: user:%s), Deleted\n' "$scope" - exit 0 + printf ' Calendar: %s, Calendar ACL: (Scope: user:%s), Delete Failed: Cannot change your own access level.\n' "$scope" "$scope" 1>&2 + exit 50 fi # `gam user remove calendars ` -> PERMANENTLY delete a secondary calendar. diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index f4778bf..9ee02d3 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -283,13 +283,18 @@ async def test_offboard_calendar_sweep_tolerates_own_acl(connector): @pytest.mark.asyncio async def test_offboard_calendar_sweep_clean_success_and_real_failure(connector): - # The sweep's exit-0 path (the mock used to fail it unconditionally, so it was never exercised) - # and a failure the connector must NOT tolerate (a missing scope is not a per-entity notice). - ok = await connector.remove_from_all_calendars("leaver@example.com") - assert ok.ok and not ok.detail + # The live run (2026-10-07): an active leaver is among `all users`, so the sweep always ends on their + # own calendar, exit 50 "Cannot change your own access level" — tolerated, and audited as such. + live = await connector.remove_from_all_calendars("leaver@example.com") + assert live.ok and "best-effort" in (live.detail or "") rec = connector.audit.tail()[-1] assert rec["ok"] is True and rec["argv"] == ["all", "users", "delete", "calendaracls", "primary", "leaver@example.com"] - assert "tolerated" not in rec.get("extra", {}) + assert rec["extra"]["tolerated"] is True and "own access level" in rec["extra"]["error"] + # The exit-0 path (a leaver suspended first, so never visited), and a failure the connector must NOT + # tolerate (a missing scope is not a per-entity notice). + ok = await connector.remove_from_all_calendars("SWEEPCLEAN-leaver@example.com") + assert ok.ok and not ok.detail + assert "tolerated" not in connector.audit.tail()[-1].get("extra", {}) bad = await connector.remove_from_all_calendars("SWEEPFAIL@example.com") assert not bad.ok and "insufficient authentication scopes" in bad.detail diff --git a/tests/test_mock_gam.py b/tests/test_mock_gam.py index 6e6759f..ff8cfee 100644 --- a/tests/test_mock_gam.py +++ b/tests/test_mock_gam.py @@ -43,7 +43,8 @@ C.subscribe_calendar("bob@example.com", CAL, selected=False)], "remove_calendar": [C.remove_calendar("alice@example.com", CAL)], "delete_event": [C.delete_event(CAL, "evt-1")], - "remove_all_calendar_acls": [C.remove_all_calendar_acls("leaver@example.com")], + # A leaver suspended first (SWEEPCLEAN): an active one ends exit 50 on their own calendar (a test below). + "remove_all_calendar_acls": [C.remove_all_calendar_acls("SWEEPCLEAN-leaver@example.com")], "add_calendar_event": [C.add_calendar_event("mgr@example.com", "Confirm", "2026-07-23", "2026-07-24"), C.add_calendar_event("mgr@example.com", "Confirm", "2026-07-23", "2026-07-24", description="d", attendee="it@example.com")], @@ -131,6 +132,16 @@ async def test_mock_accepts_every_shape_the_app_emits(runner, domain, argv): await runner.run_authenticated(domain, argv, serialize=True) +async def test_mock_sweep_of_an_active_leaver_ends_on_their_own_calendar_as_live_gam_did(runner, domain): + # Live, 2026-10-07 (GAM 7.48.14): `all users` includes the still-active leaver, so the sweep's last + # word is GAM refusing to change their own primary calendar — exit 50, tolerated by the connector. + with pytest.raises(GAMError) as err: + await runner.run_authenticated(domain, C.remove_all_calendar_acls("leaver@example.com"), serialize=True) + assert err.value.exit_code == 50 and err.value.kind == GAMErrorKind.OWN_ACL + assert ("Calendar: leaver@example.com, Calendar ACL: (Scope: user:leaver@example.com), " + "Delete Failed: Cannot change your own access level.") in err.value.stderr + + @pytest.mark.hand_built_argv # partial `vacation` shapes (set_vacation names every field) to seed the merge async def test_mock_vacation_merges_like_gam(runner, domain, gam_state): # GAM's setVacation updates only the fields the command names; a stateless mock (or one that From 9cf5bbbb8b8871deafead9210c9a949c5d955e21 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Wed, 7 Oct 2026 13:59:45 -0400 Subject: [PATCH 2/2] docs: record the first live offboarding All eight steps ok on a real leaver (GAM 7.48.14). README now confirms deprovision signout, forward off, the auto-reply's start/end form, the single transfer call (accepted, completion not recorded) and the sweep's shape. The runbook, guide and both plans say what it proved and what is still open. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +++++----- docs/domains/lifecycle-offboarding.md | 19 +++++++++++++++---- docs/guide.md | 9 ++++++--- docs/plans/2026-09-23-ten-out-of-ten.md | 7 ++++--- .../2026-10-02-configurable-offboarding.md | 6 +++--- 5 files changed, 33 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 66f28ae..9cefb8f 100644 --- a/README.md +++ b/README.md @@ -165,16 +165,16 @@ for the Builder's External-only filter, and reading the connected admin and its | Remove group member | Groups, Users, Builder | **confirmed** | | Add delegate | Users, Offboarding, Builder | **confirmed** | | Remove delegate | Users, Builder | **confirmed** | -| Set vacation (auto-reply) | Users, Offboarding, Builder | **confirmed**, including the explicit `contactsonly false domainonly false` and dated `start`/`end` the user page sends; offboarding's "starts now, no end" `start Started end NotSpecified`, not yet | +| Set vacation (auto-reply) | Users, Offboarding, Builder | **confirmed**, including the explicit `contactsonly false domainonly false` and dated `start`/`end` the user page sends; and offboarding's "starts now, no end" `start Started end NotSpecified` (the first live offboarding, 2026-10-07, GAM 7.48.14) | | Clear vacation | Users, Builder | **confirmed** | | Reset password (it runs no sign-out: offboarding's next step does, or *Sign out everywhere*) | Offboarding, Builder | **confirmed** | | Sign out everywhere | Users, Builder | not yet | -| Revoke access: app passwords, backup codes, OAuth tokens, and sign out (`deprovision signout`) | Offboarding | not yet | +| Revoke access: app passwords, backup codes, OAuth tokens, and sign out (`deprovision signout`) | Offboarding | **confirmed** (the first live offboarding, 2026-10-07, GAM 7.48.14) | | Suspend / unsuspend | Users (both), Builder (suspend) | not yet | | Delete an account | Users, Builder | not yet | | Undelete an account | Builder | not yet | -| Transfer Drive + calendar data | Offboarding, Builder | **confirmed** as two calls; the single call offboarding now makes (with `all`: private and shared Drive files) is not yet | -| Remove the leaver from everyone's calendars | Offboarding | not yet | +| Transfer Drive + calendar data | Offboarding, Builder | **confirmed**: as two calls, and the single call offboarding makes (`drive,calendar … all`) was accepted (the first live offboarding, 2026-10-07, GAM 7.48.14); that the transfer *completed* and moved every file is not yet recorded | +| Remove the leaver from everyone's calendars | Offboarding | **confirmed** the shape (the first live offboarding, 2026-10-07, GAM 7.48.14): GAM visited every user and ended, as expected, on the leaver's own calendar ("Cannot change your own access level", exit 50, tolerated); that a colleague's share was removed is not yet recorded | | Add a calendar event (the manager's reminder) | Offboarding | **confirmed**; with an invitee it now adds `sendupdates all` (so they get the invitation email), not yet run live | | Share a calendar (add an ACL) | Calendars, Users (their own calendar) | **confirmed** | | Subscribe someone, so a shared calendar appears | Calendars, Onboard | **confirmed** | @@ -182,7 +182,7 @@ for the Builder's External-only filter, and reading the connected admin and its | Unshare a calendar (remove an ACL) | Calendars, Users | not yet | | Delete an event | Calendars | not yet | | Delete a secondary calendar | Calendars | **confirmed** | -| Forwarding: add an address, forward on / off | Builder (forward off also Offboarding) | not yet | +| Forwarding: add an address, forward on / off | Builder (forward off also Offboarding) | forward off **confirmed** (the first live offboarding, 2026-10-07, GAM 7.48.14); adding an address and forward on, not yet | | Create / delete an alias | Builder | not yet | | Create a group | Builder | not yet | | Export a result to a Google Sheet | Builder | not yet | diff --git a/docs/domains/lifecycle-offboarding.md b/docs/domains/lifecycle-offboarding.md index 0ec60cf..b3dc264 100644 --- a/docs/domains/lifecycle-offboarding.md +++ b/docs/domains/lifecycle-offboarding.md @@ -315,12 +315,23 @@ one run per leaver (`test_offboard_refuses_a_second_run_for_a_leaver_whose_offbo and the preview's commands: each step's exact `gam` line in the page, and the previewed argv = what the mock received (`test_offboard_preview_commands_are_what_runs`). **Not proven offline** (the mock lies): a live DTS transfer of a real user's Drive+Calendar, the -actual per-user calendar sweep at domain scale, `deprovision signout` and `forward off` (never run -live: their output, and `deprovision` for a user with no app passwords or tokens), and `delete_user` -itself. Per CLAUDE.md, these must be run against a **throwaway** account before being trusted. +actual per-user calendar sweep at domain scale, `deprovision signout` and `forward off`, and +`delete_user` itself. Per CLAUDE.md, these must be run against a **throwaway** account before being trusted. +**First live run (2026-10-07, GAM 7.48.14, a real leaver, plan D8):** all eight steps `ok` in Audit, +"8 of 8". Confirmed: `deprovision signout`, `forward off`, the auto-reply's `start Started end +NotSpecified`, and the single `create datatransfer … drive,calendar … all` call (accepted; completion +not recorded). The sweep visited every user and ended exit 50 on the leaver's own primary calendar, +"Cannot change your own access level" — `all users` includes the still-active leaver, so this happens +on **every** offboarding and is tolerated as `OWN_ACL` (the mock's default sweep now does the same; +`SWEEPCLEAN` keeps the exit-0 path for a leaver suspended first). Still unproven live: that the +transfer completed with nothing left owned, that a colleague's share was removed (only the last +stderr line of each kind is audited), the reminder with an invitee (`sendupdates all`), and +`delete_user`. ## First live run checklist -Offboarding a real user is the live test (plan D8). Keep this page open. +Offboarding a real user is the live test (plan D8; first run 2026-10-07, all eight `ok` — see +"Testing / live-verification status" for what it proved and what is still open). Keep this page open +for the next one: the after-run checks in Google are what remain. **In the preview, before Run** - The header names the right person and says "8 steps" (nothing ticked as already done). diff --git a/docs/guide.md b/docs/guide.md index d36d1a7..c6dae3a 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -111,8 +111,9 @@ transfer has finished. **Before:** enter the leaver and the manager and click **Preview steps**. Read every warning. GamGUI refuses to offboard the admin it is connected as — revoking that account's access would cut off -GamGUI mid-run — so connect as a different super admin first if you need to. Several steps, and -the routine as a whole, have not yet run on a live domain, so **work through the +GamGUI mid-run — so connect as a different super admin first if you need to. The routine has run +live once, every step succeeding; a few effects (the transfer finishing, a colleague's calendar share +removed) are still unconfirmed, so **work through the [first live run checklist](domains/lifecycle-offboarding.md#first-live-run-checklist)** with it open: what to look at in the preview, and what to verify in Google afterwards. @@ -127,7 +128,9 @@ Anything beyond these steps, like listing the leaver's Drive files first, is a r **While it runs:** the calendar sweep can take minutes. Don't quit the app; to halt it, use **Stop** (see [Jobs](#jobs-and-stop)). -**After:** the panel should say "Offboarding complete — 8 of 8 steps succeeded". If a step failed, +**After:** the panel should say "Offboarding complete — 8 of 8 steps succeeded". In **Audit**, the +calendar clean-up's record shows "Cannot change your own access level": that is GAM declining to change +the leaver's own calendar, which it visits too, and is expected on every run. If a step failed, its line says why and later steps that depended on it were not run. Fix the cause, tick the steps that already succeeded, preview again and run — the checklist covers each failure. diff --git a/docs/plans/2026-09-23-ten-out-of-ten.md b/docs/plans/2026-09-23-ten-out-of-ten.md index 15fa606..46b8152 100644 --- a/docs/plans/2026-09-23-ten-out-of-ten.md +++ b/docs/plans/2026-09-23-ten-out-of-ten.md @@ -220,9 +220,10 @@ plan's Phase 0 F1–F3. that ends at the guide; `make setup`'s interpreter choice stated as it is; screenshots regenerated with the tenant header). **Open:** V1; V3–V5 are new scope, and D6 says none for now. -- **Phase 8** — **open** (D8). The operator's first real offboarding is its first live test: README - "Live verification status" marks every step still unproven, and - `docs/domains/lifecycle-offboarding.md` has the first live run checklist. +- **Phase 8** — **mostly done** (D8). The operator's first real offboarding ran 2026-10-07, all eight + steps `ok`; README "Live verification status" and the offboarding runbook record what it confirmed. + Open: the transfer's completion, a colleague's share removed, the reminder with an invitee, and the + account delete. **Beyond the plan**, also on the branch (each has a failure-log entry): every confirm step runs what its preview held, under a single-use token — offboarding, signatures, onboarding, bulk department, diff --git a/docs/plans/2026-10-02-configurable-offboarding.md b/docs/plans/2026-10-02-configurable-offboarding.md index 135bf2d..2a7c7a1 100644 --- a/docs/plans/2026-10-02-configurable-offboarding.md +++ b/docs/plans/2026-10-02-configurable-offboarding.md @@ -95,9 +95,9 @@ the leaver group by group, the memberships recorded in the preview so it can be ## Before P2 ships -Offboarding has never run on a real user (README "Live verification status", runbook "first live run -checklist"). Prove today's eight on a throwaway user first, so new steps aren't layered on unproven -ones. +Done in part: the operator's first live offboarding (2026-10-07) ran all eight `ok`. Still open +before relying on new steps: the transfer's completion and a colleague's share actually removed (the +runbook's after-run checks). ## Status by item