# AgentNet v0.1.45 Email Invitation Onboarding Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Let an authorized administrator invite one exact verified work email into one preselected collaboration space using a single-use 24-hour link or QR code that opens a complete plain-language onboarding flow.

**Architecture:** Keep `InternalInvitationService` as the identity-binding authority and add an opaque public link layer whose token grants no authority by itself. The administrator preauthorizes an exact scope template and permission set. Redemption verifies the candidate’s OIDC identity, exact email/domain, harness proof of possession, passkey approval, current invitation state, and current policy before atomically enrolling the endpoint and issuing only the chosen collaboration scope.

**Tech Stack:** Python, Pydantic, Starlette console/approval apps, existing OIDC and WebAuthn services, `segno` for local QR generation, SQLite/PostgreSQL through `StoreBackend`, browser verification.

## Global Constraints

- Only an authenticated current administrator with positive invitation authority may issue, revoke, or reissue an invitation.
- The invitation binds one normalized verified email, one trust domain, one collaboration-scope template, one exact permission set, one-use maximum, and an expiry exactly 86,400 seconds after issuance.
- Link and QR contain only a high-entropy opaque token. Token possession grants no identity, authority, scope, or enrollment.
- Candidate email comes only from verified OIDC evidence; user-entered email is display/input data and cannot satisfy the binding.
- The enrolling harness proves possession of its newly generated key. It cannot self-approve with the same secret boundary.
- Passkey/OOB approval shows the exact transaction in plain language and binds its digest.
- Wrong email/domain, expired, revoked, consumed, replayed, stale-policy, changed-scope, changed-permission, ambiguous principal, or concurrent second redemption fails closed and does not leak the intended recipient.
- Successful redemption enters only the preselected space and permissions. It grants no filesystem, model, secret, data, execution, federation, or administrative authority.
- Normal browser copy avoids protocol terms. Technical details remain collapsed and authorization-gated.
- Affected IDs: `ID-001..009`, `AUTH-001..010`, `COM-001..011`, `UX-003..006`, `SEC-001..006`, `OPS-002..006`.

---

### Task 1: Add opaque invitation-link state and strict offer model

**Files:**
- Create: `src/agentnet/identity/invitation_links.py`
- Modify: `src/agentnet/storage/invitation_link_schema.py` (created by the lifecycle plan’s schema-v7 task)
- Modify: `src/agentnet/identity/invitations.py:112-321`
- Test: `tests/identity/test_invitation_links.py`

**Interfaces:**
- Consumes: current internal invitation transaction, collaboration-scope proposal schema, and `StoreBackend`.
- Produces:
```python
class InvitationOffer(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)
    schema_version: Literal["agentnet.invitation-offer.v1"]
    invitation_id: str
    invited_verified_email: str
    domain_id: str
    collaboration_scope_template: CollaborationScopeProposal
    permission_actions: tuple[str, ...]
    expires_at: int
    max_uses: Literal[1] = 1

class IssuedInvitationLink(BaseModel):
    invitation_id: str
    public_url: AnyHttpUrl
    qr_svg: str
    expires_at: int

class InvitationLinkService:
    def issue(self, *, actor: VerifiedActor, offer: InvitationOffer, authority: IssuanceAuthority) -> IssuedInvitationLink: ...
    def inspect_public(self, *, opaque_token: str) -> PublicInvitationSummary: ...
    def reserve_redemption(self, *, opaque_token: str, source_fingerprint: str) -> InvitationRedemptionReservation: ...
    def revoke(self, *, actor: VerifiedActor, invitation_id: str, expected_revision: int, authority: IssuanceAuthority) -> InvitationRecord: ...
```

- [ ] **Step 1: Write failing offer/link tests**

```python
def test_issue_hashes_token_and_sets_exact_24_hour_expiry(service, administrator, now):
    issued = service.issue(actor=administrator, offer=offer(expires_at=now + 86_400), authority=admin_authority())
    row = invitation_row(issued.invitation_id)
    assert row["token_hash"] != issued.public_url.path.rsplit("/", 1)[-1]
    assert row["expires_at"] == now + 86_400
    assert row["max_uses"] == 1


def test_public_inspection_is_non_enumerating(service):
    for token in ("missing-token", revoked_token(), expired_token(), consumed_token()):
        with pytest.raises(InvitationUnavailable, match="invitation is unavailable"):
            service.inspect_public(opaque_token=token)
```

- [ ] **Step 2: Verify tests fail**

```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/identity/test_invitation_links.py
```
Expected: FAIL because opaque link state is absent.

- [ ] **Step 3: Implement token hashing and canonical offer validation**

```python
def token_hash(token: str) -> str:
    return hashlib.sha256(token.encode("ascii")).hexdigest()

def issue_token() -> str:
    return secrets.token_urlsafe(32)
```

Store only the token hash, canonical offer bytes/digest, sponsor authority binding, state, revision, use count, expiry, failure window, and audit hash. Require permission actions to be a sorted unique subset of the scope template’s allowed actions. Require `expires_at == now + 86_400` at issuance.

- [ ] **Step 4: Verify link lifecycle and concurrent single-use fencing**

```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/identity/test_invitation_links.py
```
Expected: PASS including concurrent reserve, replay, revoke, expiry, reissue lineage, wrong scope digest, and rate-limit cases.

- [ ] **Step 5: Commit**

```bash
git add src/agentnet/identity/invitation_links.py src/agentnet/identity/invitations.py tests/identity/test_invitation_links.py
git commit -m "feat(identity): add opaque email invitation links"
```

### Task 2: Add administrator invitation creation and QR rendering

**Files:**
- Modify: `src/agentnet/console/models.py`
- Modify: `src/agentnet/console/mutations.py`
- Modify: `src/agentnet/console/http.py:70-639`
- Modify: `src/agentnet/console/render.py`
- Modify: `src/agentnet/console/static/console.js`
- Modify: `pyproject.toml`
- Modify: `uv.lock`
- Modify: `docs/BUILD_VS_REUSE.md`
- Test: `tests/console/test_invitation_creation.py`

**Interfaces:**
- Consumes: `InvitationLinkService.issue`, current administrator console session, current collaboration scopes.
- Produces authenticated pages `GET /invitations/new`, `POST /invitations`, and `GET /invitations/{id}` plus local SVG QR generation.

- [ ] **Step 1: Write failing console tests**

```python
def test_administrator_creates_email_bound_invitation(console_client, admin_session):
    response = console_client.post("/invitations", data={
        "email": "invitee@mellanni.example",
        "scope_id": "scope-1",
        "permissions": ["message.send", "message.read", "artifact.send", "artifact.download"],
    }, cookies=admin_session.cookies, headers=admin_session.csrf_headers)
    assert response.status_code == 303
    detail = console_client.get(response.headers["location"], cookies=admin_session.cookies)
    assert "Expires in 24 hours" in detail.text
    assert "<svg" in detail.text


def test_non_administrator_cannot_create_invitation(console_client, member_session):
    response = console_client.post("/invitations", data=VALID_FORM, cookies=member_session.cookies, headers=member_session.csrf_headers)
    assert response.status_code == 403
```

- [ ] **Step 2: Verify tests fail**

```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/console/test_invitation_creation.py
```
Expected: FAIL because invitation routes and QR rendering are absent.

- [ ] **Step 3: Add pinned QR dependency and safe SVG generation**

```toml
"segno==1.6.6",
```

```python
def invitation_qr_svg(public_url: str) -> str:
    code = segno.make(public_url, error="m")
    output = io.BytesIO()
    code.save(output, kind="svg", scale=4, border=4, xmldecl=False, svgns=True)
    return output.getvalue().decode("utf-8")
```

Record the maintained-component choice and license in `BUILD_VS_REUSE.md`. Render the SVG inline only after generating it from the server-issued HTTPS URL; apply existing protected headers and never load a third-party QR service.

- [ ] **Step 4: Implement plain-language administrator UI and verify**

The form labels are `Work email`, `Space`, `Can send messages`, `Can read messages`, `Can send files`, and `Can download files`. The result shows `Copy invitation link`, `Download QR code`, `Copy onboarding instructions`, expiry, revocation action, and no internal IDs in the default view.

Run:
```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/console/test_invitation_creation.py tests/console/test_session_security.py tests/console/test_mutations.py
```
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/agentnet/console pyproject.toml uv.lock docs/BUILD_VS_REUSE.md tests/console/test_invitation_creation.py
git commit -m "feat(console): create invitation links and QR codes"
```

### Task 3: Build the public full-prompt onboarding page

**Files:**
- Create: `src/agentnet/identity/onboarding_prompt.py`
- Modify: `src/agentnet/console/http.py`
- Modify: `src/agentnet/console/render.py`
- Test: `tests/console/test_public_invitation_page.py`

**Interfaces:**
- Consumes: `InvitationLinkService.inspect_public`.
- Produces public route `GET /join/{opaque_token}` with a complete current-version prompt and `Continue with work account` action.

- [ ] **Step 1: Write failing public-page tests**

```python
def test_invitation_page_contains_complete_copyable_prompt(public_client, invitation):
    response = public_client.get(invitation.path)
    assert response.status_code == 200
    assert "Install AgentNet without sudo" in response.text
    assert "You will be asked before your agent restarts" in response.text
    assert "You are joining" in response.text
    assert "Continue with work account" in response.text
    assert "OIDC" not in visible_text(response.text)


def test_unavailable_invitation_page_does_not_reveal_state(public_client, unavailable_path):
    response = public_client.get(unavailable_path)
    assert response.status_code == 410
    assert visible_text(response.text) == "This invitation is unavailable. Ask the sender for a new link."
```

- [ ] **Step 2: Verify tests fail**

```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/console/test_public_invitation_page.py
```
Expected: FAIL because the public prompt page is absent.

- [ ] **Step 3: Implement versioned prompt rendering**

```python
@dataclass(frozen=True, slots=True)
class OnboardingPrompt:
    package_version: str
    install_text: str
    flow_steps: tuple[str, ...]
    restart_text: str
    recovery_text: str
    copyable_text: str
```

Generate the prompt from package metadata and public invitation summary. It must instruct the agent to use the package-owned setup flow, preserve existing state, open only the public page, approve with the user’s passkey, and ask before restart. It must never include the token, internal IDs, local paths, receipts, secrets, or manual commands beyond the user-level package install command.

- [ ] **Step 4: Verify public headers and content**

```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/console/test_public_invitation_page.py tests/console/test_http.py tests/console/test_schema.py
```
Expected: PASS with no-store, CSP, frame denial, referrer policy, and non-enumerating errors.

- [ ] **Step 5: Commit**

```bash
git add src/agentnet/identity/onboarding_prompt.py src/agentnet/console/http.py src/agentnet/console/render.py tests/console/test_public_invitation_page.py
git commit -m "feat(onboarding): serve complete invitation prompt"
```

### Task 4: Redeem through OIDC, proof of possession, and passkey approval

**Files:**
- Modify: `src/agentnet/identity/invitation_oidc.py:56-898`
- Modify: `src/agentnet/identity/invitations.py:324-1349`
- Modify: `src/agentnet/approval/transaction_summary.py`
- Modify: `src/agentnet/approval/http.py`
- Modify: `src/agentnet/http_api.py`
- Test: `tests/identity/test_invitation_redemption.py`
- Test: `tests/approval/test_invitation_summary.py`

**Interfaces:**
- Consumes: opaque redemption reservation, verified OIDC result, candidate key proof, independent approval receipt, `InternalInvitationService`, `CollaborationScopeService`, and `EndpointLifecycleService`.
- Produces atomic `InvitationRedemption` with new/current principal, exact harness/credential, chosen scope ID, `restart_required`, and zero unrelated entitlements.

- [ ] **Step 1: Write failing acceptance and adversarial tests**

```python
def test_exact_email_redemption_enrolls_endpoint_and_only_offered_scope(flow):
    result = flow.redeem(oidc_email="invitee@mellanni.example", passkey_approved=True)
    assert result.scope_id == "scope-1"
    assert result.endpoint_state == "restart_required"
    assert result.positive_entitlements == (
        "message.send", "message.read", "artifact.send", "artifact.download"
    )


@pytest.mark.parametrize("mutation", ["wrong-email", "wrong-domain", "changed-scope", "changed-permissions", "stale-policy", "replay", "revoked", "expired"])
def test_redemption_mutation_fails_without_identity_or_scope(mutation, flow):
    before = flow.identity_and_scope_count()
    with pytest.raises((AuthenticationError, AuthorizationError, ConflictError)):
        flow.redeem_with_mutation(mutation)
    assert flow.identity_and_scope_count() == before
```

- [ ] **Step 2: Verify tests fail**

```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/identity/test_invitation_redemption.py tests/approval/test_invitation_summary.py
```
Expected: FAIL because link reservation, approval, enrollment, and scope issuance are not atomically composed.

- [ ] **Step 3: Bind the exact approval summary**

```python
summary = {
    "title": "Approve AgentNet invitation",
    "person": verified_email,
    "agent": candidate_display_name,
    "space": scope_display_name,
    "permissions": plain_language_permissions,
    "expires_at": offer.expires_at,
    "transaction_digest": canonical_redemption_digest,
}
```

The approval receipt must bind invitation digest, OIDC issuer/subject/email, candidate harness/key, scope template digest, permissions, policy revision, domain epoch, expiry, and one-use reservation. The final transaction rechecks all facts and creates identity/credential, consumes invitation, issues collaboration scope, registers endpoint lifecycle, and appends audit atomically.

- [ ] **Step 4: Verify complete redemption**

```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/identity/test_invitation_redemption.py tests/approval/test_invitation_summary.py tests/identity/test_internal_invitations.py tests/identity/test_internal_invitation_oidc.py
```
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add src/agentnet/identity src/agentnet/approval src/agentnet/http_api.py tests/identity tests/approval/test_invitation_summary.py
git commit -m "feat(onboarding): redeem invitations atomically"
```

### Task 5: Verify the browser journey and packaged prompt

**Files:**
- Create: `tests/integration/test_invitation_browser_journey.py`
- Modify: `npm/scripts/check-packed-package.mjs`
- Modify: `README.md`
- Modify: `docs/implementation-guide.md`

**Interfaces:**
- Consumes: administrator console, public invitation page, OIDC/passkey test providers, package setup, lifecycle coordinator.
- Produces one browser-driven flow from invitation creation through exact endpoint `restart_required` state.

- [ ] **Step 1: Add deterministic browser fixture and server smoke path**

```python
def test_admin_to_invitee_browser_journey(browser_flow):
    invitation = browser_flow.admin_creates_invitation("invitee@mellanni.example", scope="Project Atlas")
    page = browser_flow.open_public_link(invitation.url)
    page.continue_with_work_account(email="invitee@mellanni.example")
    page.approve_with_passkey()
    assert page.visible_status == "Restart your agent to enable AgentNet"
    assert browser_flow.scope_members("Project Atlas") == (browser_flow.candidate_harness_id,)
```

- [ ] **Step 2: Run HTTP tests, then drive the real page**

```bash
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q tests/integration/test_invitation_browser_journey.py
UV_CACHE_DIR=/tmp/uv-cache uv run agentnet console serve --config tests/fixtures/console-invitation.json
```

Use the browser tool to create the invitation, open the public link, complete the test OIDC/passkey flow, inspect visible copy, and confirm the terminal status. Stop the console process afterward.

- [ ] **Step 3: Verify packed prompt and dependency closure**

```bash
npm run check:package
npm run check:packed
```
Expected: PASS; the installed package renders the same current-version prompt and bundles no external QR calls.

- [ ] **Step 4: Document the normal journey**

Document only: administrator enters email/selects space/permissions, shares link or QR, invitee opens it, signs in with work account, approves with passkey, installs or updates AgentNet, then explicitly restarts their agent when asked.

- [ ] **Step 5: Commit**

```bash
git add tests/integration/test_invitation_browser_journey.py npm/scripts/check-packed-package.mjs README.md docs/implementation-guide.md
git commit -m "test(onboarding): prove email invitation journey"
```
