docs(plan): coordinator + 3 step-level plans for parallel Phase 9/10/11 fan-out

Ships four documents:
  * 2026-04-27-parallel-fanout-9-10-11.md — coordinator. File-conflict map,
    prep-lane scope rationale, post-prep parallel-safety guarantee, merge
    order rule, and risks.
  * 2026-04-27-prep-shell-and-pending.md — small ~1hr prep lane: extract
    AppContext, add --first-run flag scaffold (with stub firstrun::run),
    add daemon GetPendingStatus D-Bus method + GUI client wrapper, new
    PendingStatus wire type. 6 tasks, single agent.
  * 2026-04-27-phase-10-firstrun.md — first-login flow. Fullscreen modal,
    60s idle watchdog (TDD'd as pure WatchdogState), enrollment via Phase
    8 enroll_dialog, ClearPendingFlag on success, gnome-session-quit on
    idle, autostart .desktop entry, debian install. 6 tasks.
  * 2026-04-27-phase-11-totp.md — TOTP support behind default-on Cargo
    feature. Daemon-side: 160-bit secret + base32 + otpauth URI + atomic
    0600 writes in pam_google_authenticator format. PAM profile renderer
    extension. D-Bus surface. CLI subcommand. GUI tab with QR modal.
    Deviation flagged: TOTP recovery codes reuse the Phase 12 recovery
    flow rather than introducing a parallel hashed-recovery file format.
    9 tasks.

Execution model: prep lane first (single agent ~1hr), then dispatch three
subagents in parallel worktrees for the three follow-on lanes. Per the
conflict map, the lanes are file-disjoint after prep merges; merge order
is any-order.

If all four lanes land cleanly, the roadmap jumps from 13/19 to 17/19
phases code-complete in a single session, leaving Phase 13 (deb finalization),
14 (PPA + smoke), 17 (integration tests), 18 (user docs), and the v1.0
release tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
michael
2026-04-27 10:28:23 -07:00
parent b624f005b2
commit d20643cde5
4 changed files with 2509 additions and 0 deletions

View File

@@ -0,0 +1,141 @@
# Parallel-Fan-out Strategy: Phases 9 + 10 + 11 (and the Phase 12 GUI tab)
> **For Claude:** This is a coordinator document, not an implementation plan. Each lane it points to has its own step-level plan that uses `superpowers:executing-plans`. Run the prep lane first; then the three follow-on lanes can execute concurrently in their own worktrees.
**Goal:** Land Phases 9, 10, 11, and the Phase 12 GUI tab as quickly as possible by maximizing concurrent work across worktree-isolated agents.
**Reality:** The naive "three worktrees, all branched off main" approach hits a hard wall — every lane wants to edit `gui/src/main.rs`, two of them want to edit `daemon/src/{dbus,state}.rs`, and at least one tab will need plumbing through `gui/src/bus.rs`. A direct fan-out merges sequentially in practice because every lane has a `main.rs` conflict to resolve.
**Strategy:** A small **prep lane** establishes shared scaffolding — `AppContext`, the `--first-run` flag branch in `main.rs`, and the daemon's pending-flag query D-Bus methods — *before* the fan-out. After prep merges, the three follow-on lanes are file-disjoint and merge in any order.
---
## Conflict map (no prep)
| File | Phase 9 + 12-GUI | Phase 10 (first-login) | Phase 11 (TOTP) |
|---|:---:|:---:|:---:|
| `gui/src/main.rs` | adds `Policy` + `Recovery` ViewStack tabs | adds `--first-run` flag + alternate startup branch | adds `TOTP` ViewStack tab |
| `gui/src/bus.rs` | adds 5 policy/recovery methods | adds 1 pending-status method | adds ~3 TOTP methods |
| `gui/src/keys_page.rs` | constructor migrates to `AppContext` | — | — |
| `gui/src/app_context.rs` | **creates** | reads | reads |
| `daemon/src/dbus.rs` | — | adds `GetPendingStatus` | adds 3 TOTP methods |
| `daemon/src/state.rs` | — | promotes `has_pending` out of `#[cfg(test)]` | adds TOTP enrollment surface |
| `daemon/src/policy_apply.rs` | — | — | renders `pam_google_authenticator.so` when TOTP required |
| `common/src/types.rs` | — | adds `PendingStatus` wire type | adds `TotpEnrollment` + `TotpRecoveryCodes` wire types |
| `daemon/Cargo.toml` | — | — | adds `totp` feature, `totp-rs`, `data-encoding` deps |
| `Cargo.toml` workspace | — | — | adds `totp-rs`, `data-encoding`, `qrcode` to `[workspace.dependencies]` |
| `gui/Cargo.toml` | — | — | adds `qrcode` (feature-gated) |
| `debian/control` | — | maybe (Depends) | adds `Recommends: libpam-google-authenticator` |
| `gui/src/firstrun.rs` | — | **creates** | — |
| `gui/src/totp_page.rs` | — | — | **creates** |
| `gui/src/policy_page.rs` + `recovery_page.rs` | **creates** | — | — |
| `daemon/src/totp/mod.rs` | — | — | **creates** |
**Read:** four files (`gui/src/main.rs`, `gui/src/bus.rs`, `daemon/src/dbus.rs`, `daemon/src/state.rs`) collide across two or three lanes. Without prep, every fan-out merge resolves them by hand.
## Conflict map (with prep lane in place)
After the prep lane lands the shared scaffolding:
| File | Phase 9 + 12-GUI | Phase 10 | Phase 11 |
|---|:---:|:---:|:---:|
| `gui/src/main.rs` | adds 2 lines (tab registrations) | **untouched** — uses prep's `--first-run` branch | adds 1 line (tab registration) |
| `gui/src/bus.rs` | adds 5 methods (additive) | **untouched** — prep has the pending methods | adds 3 methods (additive) |
| `daemon/src/dbus.rs` | — | **untouched** — prep has `GetPendingStatus` | adds 3 methods |
| `daemon/src/state.rs` | — | **untouched** — prep promoted `has_pending` | adds TOTP methods |
| `gui/src/app_context.rs` | reads | reads | reads |
**Read:** the only remaining `main.rs` collisions are tab-registration lines from Lane A and Lane C — those slot into the same ViewStack but on disjoint lines and resolve trivially. All three lanes can land in any order.
---
## The four lanes
### Prep lane (single-agent, ~1 hr wall-clock)
**Plan:** [2026-04-27-prep-shell-and-pending.md](2026-04-27-prep-shell-and-pending.md)
Establishes the cross-cutting infrastructure. Drops Task 1 from the existing Phase 9 plan (AppContext refactor moves here). Adds the daemon's `GetPendingStatus` D-Bus method that Phase 10 needs. Adds `--first-run` flag parsing + a stub `firstrun::run()` so Phase 10 fills it without re-touching `main.rs`.
**Acceptance gate:** workspace tests pass, the `--first-run` flag is parseable but no-ops with a placeholder, the new D-Bus methods round-trip through the p2p test fixture.
**MUST land first.** All three follow-on lanes branch off the merge of prep into main.
### Lane A — GUI policy + recovery (Phase 9 + Phase 12 GUI tab)
**Plan:** [2026-04-27-phase-9-and-12-gui.md](2026-04-27-phase-9-and-12-gui.md) (already written — pre-merge of prep, drop Task 1).
**File footprint:** `gui/src/{policy_form,policy_page,recovery_page}.rs` (new), additive `gui/src/bus.rs` policy + recovery methods, two `add_titled_with_icon` lines in `main.rs`, optional `keys_page.rs` ToastOverlay tweak.
### Lane B — First-login flow (Phase 10)
**Plan:** [2026-04-27-phase-10-firstrun.md](2026-04-27-phase-10-firstrun.md)
**File footprint:** `gui/src/firstrun.rs` (new — fills the prep stub), `gui/data/authforge-firstrun.desktop` (new autostart), `debian/authforge-gui.install` (install autostart entry).
### Lane C — TOTP support (Phase 11)
**Plan:** [2026-04-27-phase-11-totp.md](2026-04-27-phase-11-totp.md)
**File footprint:** `daemon/src/totp/mod.rs` (new), additions to `daemon/src/{state,dbus,policy_apply}.rs`, `daemon/Cargo.toml` feature flag, `gui/src/totp_page.rs` (new), one `main.rs` tab line, `common/src/types.rs` (two new wire types), `debian/control`, workspace + GUI `Cargo.toml`.
---
## Execution model
### Prep lane
```bash
git worktree add .claude/worktrees/prep-shell-and-pending -b feature/prep-shell-and-pending
cd .claude/worktrees/prep-shell-and-pending
# follow 2026-04-27-prep-shell-and-pending.md task-by-task
# tests + clippy + fmt + release build green
# merge --no-ff back to main
git worktree remove .claude/worktrees/prep-shell-and-pending
git branch -d feature/prep-shell-and-pending
```
### Three concurrent follow-on lanes
After prep merges, dispatch three subagents in parallel via `superpowers:dispatching-parallel-agents`:
```text
Subagent 1 (worktree feature/phase-9-and-12-gui):
→ executes 2026-04-27-phase-9-and-12-gui.md (skip Task 1, prep handled it)
Subagent 2 (worktree feature/phase-10-firstrun):
→ executes 2026-04-27-phase-10-firstrun.md
Subagent 3 (worktree feature/phase-11-totp):
→ executes 2026-04-27-phase-11-totp.md
```
Each lane verifies tests + clippy in its own worktree before reporting. Once all three return green, merge them serially into main in any order. The `gui/src/main.rs` ViewStack-tab additions from Lane A and Lane C may produce a 1-line conflict per merge — resolve by including both tab lines.
### Merge order rule
**Any order works.** The conflict on `gui/src/main.rs` is bounded to "both lanes added a tab line in the same area." Resolve by keeping both. No semantic interaction.
---
## Risks
| Risk | Mitigation |
|---|---|
| Prep lane mis-defines the `AppContext` shape and Lane A's plan needs revision. | Lane A's plan is already in this repo and was written assuming the AppContext shape that prep will land. Prep mirrors the structure verbatim — see prep Task 1. If prep's Task 1 deviates, update Lane A's plan in the same commit. |
| Concurrent `cargo` runs across three worktrees race on `~/.cargo/registry` lock. | Brief synchronization (per Phase 4+5 closeout note), not a hang. Acceptable. |
| Phase 11 introduces a `totp` Cargo feature that's default-on; CI must build with feature off too. | Add a `cargo build --workspace --no-default-features` line to the CI workflow as part of Lane C's Task 1. |
| Lane B's `--first-run` runtime behavior depends on a daemon that's running. | Watchdog keeps the modal alive 60s; if no daemon, the modal logs and exits gracefully (mirrors Phase 8's disconnect-banner pattern). Spelled out in Phase 10 Task 4. |
| Lane C's `pam_google_authenticator.so` must already be installed for the rendered profile to validate. | `debian/control` lists it as `Recommends:`, not `Depends:` — installing the daemon doesn't pull it in unless the user opts in. Daemon's `policy_apply::apply` uses the existing rollback path if `pam-auth-update` rejects the profile. |
| Three subagents producing simultaneous output saturates the main session's context. | Subagents run with `report in under 200 words` instructions in their dispatch prompts; details live in commit messages. |
---
## Once-fully-landed roll-up
After all four lanes merge:
- Phase 9: ✅ Code complete (GUI smoke deferred to Phase 14)
- Phase 10: ✅ Code complete (full-flow VM smoke deferred to Phase 14)
- Phase 11: ✅ Code complete (TOTP enrollment + recovery codes; `pamtester` smoke deferred to Phase 14)
- Phase 12: ✅ Done (backend, GUI tab, end-to-end CLI all in)
**Roadmap progress** jumps from 13/19 to **17/19** in the same session, leaving only Phase 13 (deb finalization), 14 (PPA + smoke), 17 (integration tests), 18 (user docs), and the v1.0 release tag.

View File

@@ -0,0 +1,588 @@
# Phase 10: First-Login Flow — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
>
> **EXECUTION GATE:** Do **not** start until the prep lane ([2026-04-27-prep-shell-and-pending.md](2026-04-27-prep-shell-and-pending.md)) has merged to `main`. This lane fills the `firstrun::run()` stub the prep lane established and consumes the `GetPendingStatus` D-Bus method the prep lane added.
**Goal:** Land Flow C from the design doc end-to-end. When a user with `chage -d 0` (forced password change) logs in for the first time, the GTK GUI launches in fullscreen modal mode, queries the daemon's pending flag for the current user, prompts them through enrollment, clears the pending flag on success, and exits cleanly. A 60-second watchdog falls back to `gnome-session-quit --logout` if the user goes idle.
**Architecture:**
- `gui/src/firstrun.rs` is the modal. Replaces the prep lane's stub (`fn run(_ctx) { exit(0) }`) with the real flow.
- The modal is an `adw::ApplicationWindow` configured `fullscreened(true)`, with `decorated(false)` and a sticky-on-top hint. No `HeaderBar`, no ViewSwitcher.
- Flow: connect to daemon → `GetPendingStatus(current_user)` → if absent, exit; if present, present an `AdwStatusPage` with a "Get started" button → on click, run the same `enroll_dialog` Phase 8 wired up → on success, call `ClearPendingFlag` → close window + exit. On user inactivity (no clicks/keypresses for 60s) → `gnome-session-quit --logout --no-prompt`.
- Autostart: `gui/data/authforge-firstrun.desktop` is a `.desktop` entry installed to `/etc/xdg/autostart/` with `OnlyShowIn=GNOME` and `X-GNOME-Autostart-Phase=Initialization`. It runs `authforge --first-run` at session start; the binary returns immediately if the daemon reports no pending flag, so the cost on every session start is one D-Bus call (~ms).
**Tech Stack:**
- `gtk4-rs` + `libadwaita` (already pinned).
- `zbus` (workspace tokio config).
- `gtk::EventControllerKey` + `gtk::GestureClick` for activity tracking the watchdog.
- `glib::source::timeout_add_local` for the watchdog tick.
- `std::process::Command::new("gnome-session-quit")` for the fallback logout.
**Reference:**
- Master plan: [2026-04-26-authforge-implementation.md](2026-04-26-authforge-implementation.md) § Phase 10 (line 1209).
- Design doc: [2026-04-26-authforge-design.md](2026-04-26-authforge-design.md) § Flow C.
- Sibling page (mirror this style): `gui/src/keys_page.rs` (post-Phase 8, post-prep AppContext refactor).
- Enrollment dialog (reuse): `gui/src/enroll_dialog.rs` (Phase 8).
- Daemon contract: `GetPendingStatus(user) -> PendingStatus` (added by prep lane), `ClearPendingFlag(user)` (Phase 1).
- Coordinator: [2026-04-27-parallel-fanout-9-10-11.md](2026-04-27-parallel-fanout-9-10-11.md).
---
## Conventions
- One logical change per commit. Conventional prefixes scoped to `gui` / `pkg` / `docs`.
- Always commit with `--no-gpg-sign`.
- TDD only for the watchdog timer logic in `firstrun::watchdog::WatchdogState` (pure logic — testable without a display server).
- After every commit: `cargo fmt --all && cargo clippy -p authforge-gui --all-targets -- -D warnings && cargo build -p authforge-gui`.
- Manual smoke (the only realistic acceptance gate for the modal UX) is in [Task 6](#task-6-acceptance-gate--vm-smoke-recipe). CI does not run it.
---
## Task 1: `WatchdogState` pure-logic helper (TDD)
**Files:**
- Create: `gui/src/firstrun/watchdog.rs`
- Modify: `gui/src/firstrun.rs` (currently a one-line stub from prep — convert to a module dir or add `mod watchdog;`)
**Background:** Watchdog state is "did anything happen in the last N seconds?" — that's testable without GTK. Pull it out so the widget code in [Task 2](#task-2-fullscreen-modal-window--enrollment-flow) is just a glue layer.
**Step 1: Convert `firstrun.rs` to a module dir**
If `gui/src/firstrun.rs` exists as a single file (from prep lane Task 2), turn it into:
```
gui/src/firstrun/mod.rs <- the existing run(ctx) stub
gui/src/firstrun/watchdog.rs <- new
```
Move the prep-lane stub from `firstrun.rs` to `firstrun/mod.rs`, then delete `firstrun.rs`.
(If the prep lane already kept it as a single file and `mod firstrun;` resolves to `firstrun.rs`, deleting the file + creating `firstrun/mod.rs` is the same module from `main.rs`'s perspective — no `main.rs` edit needed.)
**Step 2: Write the test + impl**
```rust
// gui/src/firstrun/watchdog.rs
//! Tracks the last-activity timestamp; the GTK timer queries this each tick
//! to decide whether 60s of idle has elapsed.
use std::time::{Duration, Instant};
pub(crate) struct WatchdogState {
last_activity: Instant,
timeout: Duration,
}
impl WatchdogState {
pub fn new(timeout: Duration) -> Self {
Self {
last_activity: Instant::now(),
timeout,
}
}
pub fn poke(&mut self) {
self.last_activity = Instant::now();
}
/// True iff `timeout` has passed since the last poke. Once expired,
/// stays expired until the caller drops + recreates.
pub fn expired(&self) -> bool {
self.last_activity.elapsed() >= self.timeout
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresh_state_is_not_expired() {
let w = WatchdogState::new(Duration::from_secs(60));
assert!(!w.expired());
}
#[test]
fn zero_timeout_is_immediately_expired() {
let w = WatchdogState::new(Duration::from_millis(0));
std::thread::sleep(Duration::from_millis(2));
assert!(w.expired());
}
#[test]
fn poke_resets_the_clock() {
let mut w = WatchdogState::new(Duration::from_millis(100));
std::thread::sleep(Duration::from_millis(150));
assert!(w.expired());
w.poke();
assert!(!w.expired());
}
}
```
**Step 3: Wire `mod watchdog;` into `firstrun/mod.rs`**
```rust
// gui/src/firstrun/mod.rs
mod watchdog;
pub(crate) use watchdog::WatchdogState; // re-export for the widget code
#![allow(dead_code)] // run() body still stub; Task 2 fills it.
use crate::app_context::AppContext;
pub(crate) fn run(_ctx: AppContext) {
eprintln!("authforge: --first-run mode is a Phase 10 stub; exiting.");
std::process::exit(0);
}
```
**Step 4: Run tests**
Run: `cargo test -p authforge-gui firstrun::watchdog`
Expected: 3 PASS.
**Step 5: Commit**
```bash
git add gui/src/firstrun/mod.rs gui/src/firstrun/watchdog.rs
git rm gui/src/firstrun.rs # if it existed as a single file
git commit --no-gpg-sign -m "feat(gui): WatchdogState pure-logic helper for first-run modal"
```
---
## Task 2: Fullscreen modal window + enrollment flow
**Files:**
- Modify: `gui/src/firstrun/mod.rs` — replace the stub `run()` with the real flow.
**Background:** The window is fullscreen, undecorated, and shows an `AdwStatusPage` with three states: connecting / pending-flag-found / pending-flag-absent. On a present pending flag, the user clicks "Get started" → `enroll_dialog::present` runs against the daemon → on success, `ClearPendingFlag` → window closes → process exits.
**Step 1: Replace `run()`**
```rust
// gui/src/firstrun/mod.rs
mod watchdog;
pub(crate) use watchdog::WatchdogState;
use crate::app_context::AppContext;
use crate::bus::{current_user, Daemon};
use crate::error::user_message;
use adw::prelude::*;
use gtk::glib;
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
const WATCHDOG_TIMEOUT: Duration = Duration::from_secs(60);
pub(crate) fn run(ctx: AppContext) {
let win = adw::ApplicationWindow::builder()
.application(&gtk::gio::Application::default()
.expect("application set on connect_activate")
.downcast::<adw::Application>()
.expect("adw::Application"))
.title("Authentication setup")
.decorated(false)
.fullscreened(true)
.build();
let _ = ctx; // window owns its own context for the modal; not strictly needed
let status = adw::StatusPage::builder()
.icon_name("dialog-password-symbolic")
.title("Setting up authentication")
.description("Connecting to the authentication daemon…")
.build();
win.set_content(Some(&status));
// Watchdog state shared across the activity-poke handlers and the timer.
let watchdog = Rc::new(RefCell::new(WatchdogState::new(WATCHDOG_TIMEOUT)));
install_activity_pokes(&win, watchdog.clone());
install_watchdog_timer(&win, watchdog.clone());
// Async startup: GetPendingStatus → render flow.
let win_for_async = win.clone();
let status_for_async = status.clone();
glib::MainContext::default().spawn_local(async move {
let daemon = match Daemon::connect().await {
Ok(d) => d,
Err(e) => {
status_for_async.set_description(Some(&user_message(&e)));
// Stay open so the user sees the error; watchdog will
// logout in 60s if they don't take action.
return;
}
};
let user = current_user();
let s = match daemon.get_pending_status(&user).await {
Ok(s) => s,
Err(e) => {
status_for_async.set_description(Some(&user_message(&e)));
return;
}
};
if !s.present {
// Nothing for us to do — exit cleanly. Most session starts
// hit this path; cost is one D-Bus call.
win_for_async.close();
std::process::exit(0);
}
render_get_started(&win_for_async, &status_for_async, daemon, user);
});
win.present();
}
fn render_get_started(
win: &adw::ApplicationWindow,
status: &adw::StatusPage,
daemon: Daemon,
user: String,
) {
status.set_title("Welcome");
status.set_description(Some("Enroll a security key to finish setting up your account."));
let go = gtk::Button::builder()
.label("Get started")
.css_classes(["pill", "suggested-action"])
.halign(gtk::Align::Center)
.build();
let win_for_click = win.clone();
let daemon_for_click = daemon.clone();
let user_for_click = user.clone();
go.connect_clicked(move |btn| {
// Disable the button so a second click doesn't double-prompt.
btn.set_sensitive(false);
let win_inner = win_for_click.clone();
let daemon_inner = daemon_for_click.clone();
let user_inner = user_for_click.clone();
crate::enroll_dialog::present(
&win_inner.clone().upcast::<gtk::Window>(),
daemon_inner.clone(),
move || {
// On success: clear the pending flag, then exit.
let win_done = win_inner.clone();
let daemon_done = daemon_inner.clone();
let user_done = user_inner.clone();
glib::MainContext::default().spawn_local(async move {
if let Err(e) = daemon_done.clear_pending_flag(&user_done).await {
// Even if clearing fails, exit; the user enrolled
// a key and the next login will succeed via pam_u2f
// even if pending is still set.
eprintln!("authforge: ClearPendingFlag failed: {}", user_message(&e));
}
win_done.close();
std::process::exit(0);
});
},
);
});
status.set_child(Some(&go));
}
fn install_activity_pokes(win: &adw::ApplicationWindow, watchdog: Rc<RefCell<WatchdogState>>) {
// Any keypress or click counts as activity.
let key = gtk::EventControllerKey::new();
let w = watchdog.clone();
key.connect_key_pressed(move |_, _, _, _| {
w.borrow_mut().poke();
glib::Propagation::Proceed
});
win.add_controller(key);
let click = gtk::GestureClick::new();
let w = watchdog.clone();
click.connect_pressed(move |_, _, _, _| w.borrow_mut().poke());
win.add_controller(click);
}
fn install_watchdog_timer(win: &adw::ApplicationWindow, watchdog: Rc<RefCell<WatchdogState>>) {
let win_for_timer = win.clone();
glib::source::timeout_add_local(Duration::from_secs(5), move || {
if watchdog.borrow().expired() {
// 60s idle — kick the user back out. gnome-session-quit
// is the documented path for "log this user out cleanly."
let _ = std::process::Command::new("gnome-session-quit")
.arg("--logout")
.arg("--no-prompt")
.spawn();
win_for_timer.close();
return glib::ControlFlow::Break;
}
glib::ControlFlow::Continue
});
}
pub(crate) fn run_or_stub(_ctx: AppContext) {
// Old prep stub kept as a fallback name during PR review; remove this fn
// once the real `run()` is reviewed.
}
```
(Drop the `run_or_stub` function before commit — it's only there to ease review of the diff against the prep stub.)
**Step 2: Build + clippy**
```bash
cargo build -p authforge-gui
cargo clippy -p authforge-gui --all-targets -- -D warnings
```
Expected: clean. The first compile may surface API-shape mismatches against the installed gtk4-rs minor (fullscreened builder, StatusPage::set_child, EventControllerKey return type); fix locally — these are stable surface in gtk4-rs 0.8 + libadwaita 0.6.
**Step 3: Smoke test (deferred to manual; CI cannot run a fullscreen window)**
Skip: covered in [Task 6](#task-6-acceptance-gate--vm-smoke-recipe).
**Step 4: Commit**
```bash
git add gui/src/firstrun/mod.rs
git commit --no-gpg-sign -m "feat(gui): first-run modal with enrollment + 60s idle watchdog"
```
---
## Task 3: Autostart `.desktop` entry
**Files:**
- Create: `gui/data/authforge-firstrun.desktop`
- Modify: `debian/authforge-gui.install` — install the autostart entry to `/etc/xdg/autostart/`
**Step 1: Create the .desktop file**
```ini
# gui/data/authforge-firstrun.desktop
[Desktop Entry]
Type=Application
Name=AuthForge First-Run Setup
Comment=Prompts new users to enroll a security key.
Exec=authforge --first-run
NoDisplay=true
OnlyShowIn=GNOME;
X-GNOME-Autostart-Phase=Initialization
X-GNOME-Autostart-enabled=true
```
`NoDisplay=true` keeps it out of the apps menu — it's an autostart-only entry. `OnlyShowIn=GNOME;` matches the rest of the project's GNOME-first stance (other DEs can ship their own entries later). `X-GNOME-Autostart-Phase=Initialization` runs it before the user's normal autostarts.
**Step 2: Install via debian/authforge-gui.install**
Read the existing `debian/authforge-gui.install` — it likely has a single line installing the binary. Append:
```
gui/data/authforge-firstrun.desktop /etc/xdg/autostart
```
**Step 3: Verify the file syntax**
`desktop-file-validate` is the canonical check, but the harness sandbox may not have it. As a fallback, verify the file is parseable as INI:
```bash
python3 -c "import configparser; c = configparser.ConfigParser(); c.read('gui/data/authforge-firstrun.desktop'); print(dict(c['Desktop Entry']))"
```
Expected: dict prints with `Type`, `Name`, `Exec`, `OnlyShowIn`, etc.
**Step 4: Commit**
```bash
git add gui/data/authforge-firstrun.desktop debian/authforge-gui.install
git commit --no-gpg-sign -m "feat(pkg): autostart .desktop entry running authforge --first-run on GNOME login"
```
---
## Task 4: Daemon-disconnect graceful exit
**Files:**
- Modify: `gui/src/firstrun/mod.rs`
**Background:** Task 2 leaves a daemon-connect failure visible-but-stuck (the watchdog eventually fires, but the user sees no feedback for 60s). Tighten the UX: a connect failure shows a "Daemon unavailable" status with a manual "Skip" button that just exits the modal.
**Step 1: Add the skip button on connect failure**
Replace the `Err(e) => { status_for_async.set_description(...); return; }` arm in `run()`:
```rust
Err(e) => {
status_for_async.set_title("Setup unavailable");
status_for_async.set_description(Some(&user_message(&e)));
let skip = gtk::Button::builder()
.label("Skip")
.css_classes(["pill"])
.halign(gtk::Align::Center)
.build();
let win_skip = win_for_async.clone();
skip.connect_clicked(move |_| {
win_skip.close();
std::process::exit(0);
});
status_for_async.set_child(Some(&skip));
return;
}
```
Also adjust the `GetPendingStatus`-error arm to render the same Skip button.
**Step 2: Build + clippy**
Run: `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings`
Expected: clean.
**Step 3: Commit**
```bash
git add gui/src/firstrun/mod.rs
git commit --no-gpg-sign -m "feat(gui): firstrun modal shows Skip button on daemon-unreachable"
```
---
## Task 5: Master plan closeout note
**Files:**
- Modify: `docs/plans/2026-04-26-authforge-implementation.md`
**Step 1: Flip Phase 10 to ✅ Code complete**
In the roadmap row at line 40 (post-prep, post-Phase-12-merge):
```
| 10 | First-login flow: autostart entry + fullscreen modal | 4 days | ✅ **Code complete** (yyyy-mm-dd) — VM smoke deferred |
```
**Step 2: Append a closeout note**
After the Phase 12 backend closeout block:
```markdown
### Phase 10 closeout notes (yyyy-mm-dd)
Landed via [2026-04-27-phase-10-firstrun.md](2026-04-27-phase-10-firstrun.md). Built atop the prep lane's `--first-run` flag scaffold and the `GetPendingStatus` D-Bus method.
**Done:**
- `gui/src/firstrun/{mod,watchdog}.rs` — fullscreen `adw::ApplicationWindow` modal, 60s idle watchdog (3 unit tests on `WatchdogState`), enrollment via the Phase 8 `enroll_dialog`, `ClearPendingFlag` on success, `gnome-session-quit --logout` on idle.
- `gui/data/authforge-firstrun.desktop` — autostart entry installed to `/etc/xdg/autostart/`. `OnlyShowIn=GNOME`, `X-GNOME-Autostart-Phase=Initialization`. Cost on every session start: one D-Bus call to `GetPendingStatus`; no pending flag → immediate exit 0.
- `debian/authforge-gui.install` — installs the autostart entry alongside the binary.
**Plan deviations:** _(fill in)_.
**Deferred until Phase 14 VM smoke:** the full Flow C walkthrough (`useradd -m testuser; chage -d 0 testuser; sudo authforgectl pending set testuser --methods fido2; logout; login; see modal; enroll; see desktop`).
```
**Step 3: Commit**
```bash
git add docs/plans/2026-04-26-authforge-implementation.md
git commit --no-gpg-sign -m "docs: phase 10 closeout notes"
```
---
## Task 6: Acceptance gate + VM smoke recipe
**Files:**
- Create: `gui/FIRSTRUN-TESTING.md`
**Step 1: Document the manual recipe**
```markdown
# Testing the First-Run Modal (Phase 10)
Phase 10's user-visible flow can only be exercised against a real GTK
session. CI verifies that the binary builds and `--first-run` parses;
this recipe is the manual gate.
## Prerequisites
- Ubuntu 24.04 VM with GNOME on Wayland (matches the project's target).
- `authforge-daemon` + `authforge-gui` debs installed (Phase 14 PPA build).
- A USB FIDO2 device (Yubikey, Solo, etc.) attached to the VM.
## Walk-through
```bash
# As root in the VM:
sudo useradd -m testuser
sudo passwd testuser # set a temporary password
sudo chage -d 0 testuser # force password change at next login
sudo authforgectl pending set testuser --methods fido2
# Log out of the current session, log in as testuser.
# Expected sequence:
# 1. GDM forces a password change → set the real password.
# 2. Session starts.
# 3. authforge --first-run launches via the autostart entry.
# 4. Fullscreen modal appears: "Welcome — Enroll a security key…"
# 5. Click "Get started" → enrollment dialog appears.
# 6. Touch the Yubikey → modal closes → desktop usable.
# 7. Verify: authforgectl pending list (run as root in another tty) shows
# no pending flag for testuser.
# Re-test the auto-logout watchdog:
sudo authforgectl pending set testuser --methods fido2
# Log out, log in as testuser, do nothing for 60+ seconds.
# Expected: the user is logged out via gnome-session-quit and returned
# to GDM.
# Re-test the daemon-down case:
sudo systemctl stop authforge-daemon.service
# Log out, log in as testuser. The modal shows "Setup unavailable" with
# a Skip button; clicking it returns to the desktop.
sudo systemctl start authforge-daemon.service
```
## Pass criteria
- [ ] The modal appears within 2s of session start.
- [ ] Successful enrollment clears the pending flag and exits the modal.
- [ ] 60s idle triggers a clean session logout.
- [ ] Daemon-down shows a graceful Skip button rather than hanging.
- [ ] No pending flag → no modal → session start cost is < 100ms (verify
with `journalctl --since=now -f` for the autostart unit).
```
**Step 2: Commit**
```bash
git add gui/FIRSTRUN-TESTING.md
git commit --no-gpg-sign -m "docs(gui): VM smoke recipe for the first-run modal"
```
---
## Phase 10 Acceptance Gate
Verify before merging:
- [ ] `cargo build --workspace --release` clean.
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean.
- [ ] `cargo test --workspace` gui test count up by 3 (the new `WatchdogState` tests).
- [ ] `cargo fmt --all -- --check` clean.
- [ ] `target/release/authforge --first-run` exits 0 in either of: (a) "no pending flag" path (immediate exit); (b) "daemon down" path (modal opens, Skip available verified by hand only because no display server in CI).
- [ ] `python3 -c "import configparser; ..."` parses `gui/data/authforge-firstrun.desktop` clean.
- [ ] Manual VM smoke (deferred to Phase 14, recipe in `gui/FIRSTRUN-TESTING.md`): four pass criteria all green.
---
## Risks / known unknowns
| Risk | Mitigation |
|---|---|
| `gnome-session-quit` is GNOME-only; on KDE / non-GNOME the watchdog logout is a no-op. | The `.desktop` entry is `OnlyShowIn=GNOME;` so on non-GNOME the modal never autostarts. Acceptable v1. KDE port (out of scope) ships its own first-run path. |
| The user closes the modal via Alt-F4 / window manager kill before enrollment finishes. | The modal is `decorated(false)` no window controls. Alt-F4 still works (Wayland) but the watchdog catches abandonment. If a user kills the modal, the pending flag stays set and they hit it again next login. |
| `--first-run` is invoked manually (e.g. by a developer) on a system where the daemon isn't installed. | Daemon-connect failure path renders the Skip button. Process exits 0. |
| `glib::source::timeout_add_local` requires `!Send` callbacks the watchdog `Rc<RefCell<...>>` is fine, but writing tests against the GTK main loop is not. | `WatchdogState` is pure-logic and Send/Sync; the GTK glue is tested manually. |
| The modal grabs focus before GDM's password-change dialog has fully closed visual glitch / focus race. | The autostart entry uses `X-GNOME-Autostart-Phase=Initialization` which runs *before* the user's normal autostart phase but *after* GDM hands the session to GNOME that's the right slot. If a race manifests in VM smoke, fall back to phase=`Application` (slightly later). |
| Wayland `decorated(false)` + `fullscreened(true)` is the right combination older X11 needed `keep_above` too. | We target Ubuntu 24.04 which is Wayland-by-default. X11 fallback is already covered by the autostart entry running per-session-type without explicit X-only targeting. |
---
## Execution Handoff
Plan complete. Single lane, single agent. Suggested execution: open a fresh session in a worktree using `superpowers:using-git-worktrees`, then drive task-by-task with `superpowers:executing-plans`. Do **not** start until the prep lane has merged to `main`.
After this lane lands, the coordinator doc lists the parallel-safe order: any of (Phase 9 + 12 GUI), Phase 10, Phase 11 can merge in any order.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,487 @@
# Prep Lane: GUI shell scaffolding + pending-flag query — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
>
> Coordinator: [2026-04-27-parallel-fanout-9-10-11.md](2026-04-27-parallel-fanout-9-10-11.md). This lane lands the cross-cutting infrastructure for Phases 9, 10, 11, and the Phase 12 GUI tab so the three follow-on lanes can run truly concurrent in worktrees.
**Goal:** Land `AppContext`, the `--first-run` flag scaffold (with a stub `firstrun::run()` function), and the daemon's `GetPendingStatus` D-Bus method (plus the matching GUI client wrapper) — without doing any of the actual phase work. After this lane merges, Phases 9, 10, 11 are file-disjoint at the level of `main.rs`, `bus.rs`, and `daemon/src/{dbus,state}.rs`.
**Architecture:**
- `AppContext` is the small shared-handle struct foreseen by Phase 8's Task 7 risk note ([2026-04-27-phase-8-gui-keys.md:812](2026-04-27-phase-8-gui-keys.md#L812)). It owns `parent_window`, `toast_overlay`, and the `Rc<RefCell<Option<Daemon>>>`. `KeysPage::new` switches to taking it.
- `--first-run` is a clap-ish flag in `gui/src/main.rs`. The `connect_activate` body branches: when set, it calls `firstrun::run(ctx)`; otherwise it builds the normal ViewStack-style window. Phase 10 fills `firstrun::run()` with the real modal + watchdog.
- `GetPendingStatus(user)` is a new D-Bus method that returns a `PendingStatus { present: bool, flag: PendingFlag }` wire type. One round-trip carries both "is there a pending flag" and "what's in it." Phase 10's first-login modal uses this on startup to decide whether to show or exit.
**Tech Stack:**
- `gtk4-rs` + `libadwaita` (already in tree post-Phase 8).
- `zbus` (workspace tokio config — same as Phase 8 lands with).
- `authforge_common` for the new `PendingStatus` wire type.
**Reference:**
- Phase 8 plan + commits (post-merge `4ec6911`): contracts the prep lane builds on.
- [daemon/src/storage/pending.rs](../../daemon/src/storage/pending.rs): `PendingStore::get` already returns `Result<Option<PendingFlag>>`.
- [daemon/src/state.rs](../../daemon/src/state.rs): `has_pending` exists at line 229 but is `#[cfg(test)]`-gated; this plan promotes it.
---
## Conventions
- One logical change per commit. Conventional prefixes: `chore:`, `refactor(gui):`, `feat(gui):`, `feat(daemon):`, `test:`.
- Always commit with `--no-gpg-sign`.
- TDD only for the wire-type round-trip test in `common`. Widget refactor (Task 1) is a mechanical move; D-Bus method gets a p2p integration test alongside the existing recovery tests.
- After every commit: `cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings && cargo build --workspace`.
---
## Task 1: `AppContext` struct + `KeysPage` migration
**Files:**
- Create: `gui/src/app_context.rs`
- Modify: `gui/src/main.rs` (add `mod app_context;`, construct + pass)
- Modify: `gui/src/keys_page.rs` (constructor + field changes)
**Step 1: Create the struct**
```rust
// gui/src/app_context.rs
//! Shared per-window handles passed to every page constructor.
//!
//! `daemon` is a `RefCell<Option<...>>` because the connect-on-startup attempt
//! may fail (pages render their disconnected banner); a successful Retry
//! repopulates this same cell, and every page sees the new value via the Rc
//! clone chain.
use crate::bus::Daemon;
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Clone)]
pub(crate) struct AppContext {
pub parent_window: gtk::Window,
pub toast_overlay: adw::ToastOverlay,
pub daemon: Rc<RefCell<Option<Daemon>>>,
}
impl AppContext {
pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Self {
Self {
parent_window,
toast_overlay,
daemon: Rc::new(RefCell::new(None)),
}
}
}
```
**Step 2: Migrate `KeysPage`**
In `gui/src/keys_page.rs`:
- Change `pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Rc<Self>` to `pub fn new(ctx: AppContext) -> Rc<Self>`.
- Replace the `parent_window` and `toast_overlay` and `daemon` fields with a single `ctx: AppContext`.
- Replace all `self.parent_window` with `self.ctx.parent_window.clone()`, `self.toast_overlay` with `self.ctx.toast_overlay.clone()`, and `self.daemon` with `self.ctx.daemon.clone()` (or borrow as needed).
- Add `use crate::app_context::AppContext;` at the top.
**Step 3: Update `main.rs`**
Replace the existing `KeysPage::new(...)` site with the AppContext flow:
```rust
mod app_context;
mod bus;
mod enroll_dialog;
mod error;
mod keys_page;
// inside connect_activate:
let toast_overlay = adw::ToastOverlay::new();
let ctx = app_context::AppContext::new(win.clone().upcast(), toast_overlay.clone());
let keys = keys_page::KeysPage::new(ctx.clone());
stack.add_titled(&keys.root, Some("keys"), "Keys");
// ... rest unchanged ...
```
**Step 4: Build + clippy + test**
Run: `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings && cargo test -p authforge-gui`
Expected: Phase 8's 4 error-classifier tests still PASS; build clean.
**Step 5: Commit**
```bash
git add gui/src/app_context.rs gui/src/keys_page.rs gui/src/main.rs
git commit --no-gpg-sign -m "refactor(gui): extract AppContext for shared page handles"
```
---
## Task 2: `--first-run` flag + stub `firstrun::run()`
**Files:**
- Create: `gui/src/firstrun.rs`
- Modify: `gui/src/main.rs` (parse flag, branch)
**Background:** Phase 10 needs a place to put its fullscreen modal logic that doesn't conflict with the ViewStack work landing in Phases 9 + 11. Establish the entry point now; Phase 10 fills the body.
**Step 1: Stub the firstrun module**
```rust
// gui/src/firstrun.rs
//! First-login modal. Phase 10 implements the body of `run`.
#![allow(dead_code)] // body lands in Phase 10.
use crate::app_context::AppContext;
/// Entry point for `authforge --first-run`. Phase 10 fills this with the
/// fullscreen modal + watchdog + enroll-then-clear-pending logic.
/// Until then it exits cleanly so a developer who passes `--first-run`
/// doesn't get a stuck process.
pub(crate) fn run(_ctx: AppContext) {
eprintln!("authforge: --first-run mode is a Phase 10 stub; exiting.");
std::process::exit(0);
}
```
**Step 2: Wire the flag into `main.rs`**
GTK uses `gio::ApplicationFlags::HANDLES_COMMAND_LINE` and a `connect_command_line` handler to receive argv. The minimum-disruption pattern: parse `std::env::args` directly before constructing `adw::Application`, set a static / closure-captured bool, and branch on it inside `connect_activate`.
```rust
// gui/src/main.rs
use adw::prelude::*;
use gtk::glib;
mod app_context;
mod bus;
mod enroll_dialog;
mod error;
mod firstrun;
mod keys_page;
const APP_ID: &str = "io.dangerousthings.AuthForge";
fn main() -> glib::ExitCode {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("build tokio runtime");
let _guard = runtime.enter();
let first_run = std::env::args().any(|a| a == "--first-run");
let app = adw::Application::builder().application_id(APP_ID).build();
app.connect_activate(move |app| {
let win = adw::ApplicationWindow::builder()
.application(app)
.default_width(720)
.default_height(540)
.title("Authentication")
.build();
let toast_overlay = adw::ToastOverlay::new();
let ctx = app_context::AppContext::new(win.clone().upcast(), toast_overlay.clone());
if first_run {
firstrun::run(ctx);
return;
}
// Normal mode: header + ViewStack with the Keys tab. Phases 9 and 11
// append more tabs here.
let header = adw::HeaderBar::new();
let stack = adw::ViewStack::new();
let switcher = adw::ViewSwitcher::builder()
.stack(&stack)
.policy(adw::ViewSwitcherPolicy::Wide)
.build();
header.set_title_widget(Some(&switcher));
let keys = keys_page::KeysPage::new(ctx.clone());
stack.add_titled(&keys.root, Some("keys"), "Keys");
let layout = gtk::Box::new(gtk::Orientation::Vertical, 0);
layout.append(&header);
layout.append(&stack);
stack.set_vexpand(true);
toast_overlay.set_child(Some(&layout));
win.set_content(Some(&toast_overlay));
win.present();
});
app.run()
}
```
**Step 3: Build + clippy + smoke**
```bash
cargo build -p authforge-gui
cargo clippy -p authforge-gui --all-targets -- -D warnings
target/debug/authforge --first-run # prints "...stub; exiting." and returns 0
```
(Manual smoke is a one-liner; CI doesn't have a display server but the stub doesn't try to open a window.)
**Step 4: Commit**
```bash
git add gui/src/firstrun.rs gui/src/main.rs
git commit --no-gpg-sign -m "feat(gui): --first-run flag + firstrun::run stub for Phase 10"
```
---
## Task 3: `PendingStatus` wire type
**Files:**
- Modify: `common/src/types.rs`
**Step 1: Add the wire type and a Default impl on `PendingFlag`**
zvariant doesn't carry `Option<T>`, so `GetPendingStatus` returns a struct with an explicit `present: bool` next to the (possibly-empty) `flag`.
```rust
// at the top of common/src/types.rs, ensure PendingFlag derives Default.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct PendingFlag {
pub required_methods: Vec<Method>,
pub created_unix: u64,
pub deadline_unix: u64,
pub re_enroll: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
pub struct PendingStatus {
pub present: bool,
pub flag: PendingFlag, // contents are the default when !present; consumer must check present first
}
```
(If `PendingFlag` already has `Default`, leave the existing derive — adding `Default` to the existing derive list is the only change.)
**Step 2: Add a serde round-trip test**
Append to the existing `mod tests` in `common/src/types.rs`:
```rust
#[test]
fn pending_status_serde() {
let s = PendingStatus {
present: true,
flag: PendingFlag {
required_methods: vec![Method::Fido2],
created_unix: 1_700_000_000,
deadline_unix: 0,
re_enroll: false,
},
};
let bytes = serde_json::to_vec(&s).unwrap();
let back: PendingStatus = serde_json::from_slice(&bytes).unwrap();
assert_eq!(s, back);
}
#[test]
fn pending_status_default_when_absent() {
let s = PendingStatus {
present: false,
flag: PendingFlag::default(),
};
assert!(!s.present);
assert!(s.flag.required_methods.is_empty());
}
```
**Step 3: Run the tests**
Run: `cargo test -p authforge-common`
Expected: previous count + 2 PASS.
**Step 4: Commit**
```bash
git add common/src/types.rs
git commit --no-gpg-sign -m "feat(common): PendingStatus wire type + Default impl on PendingFlag"
```
---
## Task 4: Daemon — promote `has_pending`, add `get_pending_status`
**Files:**
- Modify: `daemon/src/state.rs`
**Step 1: Promote `has_pending` out of `#[cfg(test)]`**
At [daemon/src/state.rs:225-231](../../daemon/src/state.rs#L225-L231), remove the `#[cfg(test)]` gate and the doc comment about test-only use. The function stays the same body.
**Step 2: Add `get_pending_status`**
Append to the `impl AppState` block, near `clear_pending`:
```rust
pub async fn get_pending_status(&self, user: &str) -> Result<PendingStatus, StateError> {
use authforge_common::types::PendingStatus;
match self.pending.get(user)? {
Some(flag) => Ok(PendingStatus { present: true, flag }),
None => Ok(PendingStatus {
present: false,
flag: PendingFlag::default(),
}),
}
}
```
Add the `PendingStatus` import to the existing `use authforge_common::types::{...}` line.
**Step 3: Run the daemon test suite**
Run: `cargo test -p authforge-daemon`
Expected: previous count, 0 failures (the new method has no tests yet — Task 5 wires it through D-Bus and adds tests there).
**Step 4: Commit**
```bash
git add daemon/src/state.rs
git commit --no-gpg-sign -m "feat(daemon): AppState::get_pending_status returns wire-friendly bool+flag"
```
---
## Task 5: D-Bus `GetPendingStatus` + integration test
**Files:**
- Modify: `daemon/src/dbus.rs`
**Step 1: Add the D-Bus method**
Inside `impl AuthForge` (the `#[zbus::interface]` block), near `clear_pending_flag`:
```rust
async fn get_pending_status(
&self,
user: String,
) -> zbus::fdo::Result<authforge_common::types::PendingStatus> {
self.state
.get_pending_status(&user)
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
}
```
No polkit gate — this is a pure read; the daemon's existing pattern is gates on writes only.
Add `PendingStatus` to the `use authforge_common::types::{...}` import line at the top.
**Step 2: Add integration tests**
Append to the test module in `daemon/src/dbus.rs`, near `set_and_clear_pending_flag` at line 374:
```rust
#[tokio::test]
async fn pending_status_is_absent_for_fresh_user() {
use authforge_common::types::PendingStatus;
let (_srv, client, _state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let s: PendingStatus = p.call("GetPendingStatus", &("alice",)).await.unwrap();
assert!(!s.present);
assert!(s.flag.required_methods.is_empty());
}
#[tokio::test]
async fn pending_status_round_trips_set_flag() {
use authforge_common::types::{PendingFlag, PendingStatus};
let (_srv, client, _state, _tmp) = p2p_pair().await;
let p = proxy(&client).await;
let flag = PendingFlag {
required_methods: vec![Method::Fido2],
created_unix: 100,
deadline_unix: 200,
re_enroll: true,
};
let _: () = p.call("SetPendingFlag", &("alice", flag.clone())).await.unwrap();
let s: PendingStatus = p.call("GetPendingStatus", &("alice",)).await.unwrap();
assert!(s.present);
assert_eq!(s.flag, flag);
}
```
**Step 3: Run the daemon test suite**
Run: `cargo test -p authforge-daemon`
Expected: previous count + 2 PASS.
**Step 4: Clippy + fmt**
Run: `cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean.
**Step 5: Commit**
```bash
git add daemon/src/dbus.rs
git commit --no-gpg-sign -m "feat(daemon): GetPendingStatus D-Bus method + 2 integration tests"
```
---
## Task 6: GUI client — `Daemon::get_pending_status`
**Files:**
- Modify: `gui/src/bus.rs`
**Step 1: Add the wrapper**
Append to the `impl Daemon` block:
```rust
pub async fn get_pending_status(
&self,
user: &str,
) -> zbus::Result<authforge_common::types::PendingStatus> {
self.proxy.call("GetPendingStatus", &(user,)).await
}
```
Add `PendingStatus` to the `use authforge_common::types::{...}` import line if it isn't already drawn through the workspace path.
**Step 2: Build + clippy**
Run: `cargo build -p authforge-gui && cargo clippy -p authforge-gui --all-targets -- -D warnings`
Expected: clean.
**Step 3: Commit**
```bash
git add gui/src/bus.rs
git commit --no-gpg-sign -m "feat(gui): bus::Daemon::get_pending_status client wrapper"
```
---
## Prep Lane Acceptance Gate
Verify before merging back to main:
- [ ] `cargo build --workspace --release` clean.
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean.
- [ ] `cargo test --workspace` — daemon test count up by 2 (the new D-Bus tests); common test count up by 2 (the new wire-type tests); gui test count unchanged (4 from Phase 8).
- [ ] `cargo fmt --all -- --check` clean.
- [ ] `target/debug/authforge --first-run` exits 0 with the stub message (no display server needed).
Commit count target: **6 commits** (one per task). Merge with `--no-ff` so the lane is visible in `git log`.
---
## Risks / known unknowns
| Risk | Mitigation |
|---|---|
| Phase 9 plan's Task 1 already creates `AppContext` with the same shape — duplication. | Phase 9's Task 1 becomes a no-op once prep merges; Phase 9 plan's Task 1 step says "if AppContext already exists from prep, skip and proceed to Task 2." (Add a one-line note to the Phase 9 plan in this lane's final commit.) |
| `PendingFlag` already has a hand-written `Default` impl somewhere. | Check via `grep -rn "impl Default for PendingFlag" common/`. If yes, skip Task 3 Step 1's first sub-step. |
| `--first-run` flag conflicts with future GTK/clap-style argument parsing. | This is `std::env::args().any(...)` — the lightest possible parse. If future work introduces clap, the prep lane's flag-detection becomes a one-line move. |
| `connect_activate` closure now captures `first_run` via `move` — Rust borrow checker may push back on closure-trait selection (Fn vs FnOnce). | `bool` is `Copy`; the move is a copy. Will compile clean — flagged for completeness. |
---
## Execution Handoff
Plan complete. **Single lane, single agent.** Suggested execution: open a fresh session in a worktree using `superpowers:using-git-worktrees`, then drive task-by-task with `superpowers:executing-plans`. Do NOT dispatch subagents for this lane — the work is too small to be worth orchestration overhead.
After this lane merges to `main`, the three follow-on lanes (per the [coordinator doc](2026-04-27-parallel-fanout-9-10-11.md)) can dispatch in parallel.