# Task 1557 — Operator-on-behalf Preference Attribution 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:** A preference an operator states while working inside a client's sub-account attaches to that client's account owner, not to the operator.

**Architecture:** The decision lives in one place — `resolveProfileIdentity` in the memory MCP's `index.ts`. It gains an operator-on-behalf case: when the caller's `AdminUser` home account differs from the account the session is scoped to (a house operator inside a client's sub-account), the profile op attributes to the account's seeded owner. The decision is extracted into a pure classifier (`classifyProfileIdentity`) and a small DB helper (`resolveAdminHomeAccount`), both unit-tested; `index.ts` orchestrates the two and maps the result to the existing MCP reject envelope and bootstrap flags.

**Tech Stack:** TypeScript, Node ESM, Neo4j driver, Vitest. All work is inside `platform/plugins/memory/mcp/`.

## Global Constraints

- **Working directory for all builds/tests:** `platform/plugins/memory/mcp/`. Build with `npm run build`, test with `npm test` (vitest). The sibling `lib/*` packages must already be built (`dist/` present) — they are in this worktree.
- **Precision, no speculation:** implement exactly the four attribution cases in the spec (caller-is-admin-here, foreign-operator-with-owner, foreign-operator-no-owner, no-caller). No extra fallback paths.
- **Shipped skill markdown carries zero em-dashes** (`conversational-memory/SKILL.md`). Use commas or full stops.
- **The cross-account `[xacct] op=identity` log line stays byte-identical** to its current form; the operator-on-behalf case emits a distinct new line. PLUGIN.md documents both.
- **Committed tests:** the memory MCP has a committed vitest suite (`src/tools/__tests__/`). The new behaviour belongs in it, so the test files in this plan are committed, not discarded.
- **Session trailer on every commit:** append `Session: e53535e2-8c90-4e75-9936-6132ef8a86a0` and the `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` line.

---

### Task 1: Pure identity classifier

**Files:**
- Create: `platform/plugins/memory/mcp/src/lib/profile-identity.ts`
- Test: `platform/plugins/memory/mcp/src/tools/__tests__/profile-identity.test.ts`

**Interfaces:**
- Produces: `classifyProfileIdentity(input: { crossAccount: boolean; resolvedAccountId: string; callerUserId: string | undefined; callerHomeAccountId: string | null; ownerUserId: string | null }): ProfileAttribution` where `type ProfileAttribution = { kind: "caller"; userId: string } | { kind: "owner"; userId: string } | { kind: "reject"; reason: "no-caller" | "no-owner" }`.

- [ ] **Step 1: Write the failing test**

Create `platform/plugins/memory/mcp/src/tools/__tests__/profile-identity.test.ts`:

```ts
import { describe, it, expect } from "vitest";
import { classifyProfileIdentity } from "../../lib/profile-identity.js";

const SESSION_ACCT = "acct-session";
const HOME_ACCT = "acct-home";
const CALLER = "user-caller";
const OWNER = "user-owner";

describe("classifyProfileIdentity (Task 1557)", () => {
  it("cross-account with an owner attributes to the owner", () => {
    expect(
      classifyProfileIdentity({
        crossAccount: true,
        resolvedAccountId: SESSION_ACCT,
        callerUserId: CALLER,
        callerHomeAccountId: null,
        ownerUserId: OWNER,
      }),
    ).toEqual({ kind: "owner", userId: OWNER });
  });

  it("cross-account with no owner rejects no-owner", () => {
    expect(
      classifyProfileIdentity({
        crossAccount: true,
        resolvedAccountId: SESSION_ACCT,
        callerUserId: CALLER,
        callerHomeAccountId: null,
        ownerUserId: null,
      }),
    ).toEqual({ kind: "reject", reason: "no-owner" });
  });

  it("own-account with no caller userId rejects no-caller", () => {
    expect(
      classifyProfileIdentity({
        crossAccount: false,
        resolvedAccountId: SESSION_ACCT,
        callerUserId: undefined,
        callerHomeAccountId: null,
        ownerUserId: OWNER,
      }),
    ).toEqual({ kind: "reject", reason: "no-caller" });
  });

  it("own-account caller who is an admin of this account attributes to the caller", () => {
    expect(
      classifyProfileIdentity({
        crossAccount: false,
        resolvedAccountId: SESSION_ACCT,
        callerUserId: CALLER,
        callerHomeAccountId: SESSION_ACCT,
        ownerUserId: OWNER,
      }),
    ).toEqual({ kind: "caller", userId: CALLER });
  });

  it("own-account foreign operator (home differs) with an owner attributes to the owner", () => {
    expect(
      classifyProfileIdentity({
        crossAccount: false,
        resolvedAccountId: SESSION_ACCT,
        callerUserId: CALLER,
        callerHomeAccountId: HOME_ACCT,
        ownerUserId: OWNER,
      }),
    ).toEqual({ kind: "owner", userId: OWNER });
  });

  it("own-account foreign operator with no owner rejects no-owner", () => {
    expect(
      classifyProfileIdentity({
        crossAccount: false,
        resolvedAccountId: SESSION_ACCT,
        callerUserId: CALLER,
        callerHomeAccountId: HOME_ACCT,
        ownerUserId: null,
      }),
    ).toEqual({ kind: "reject", reason: "no-owner" });
  });

  it("own-account caller with no AdminUser node (home null) is treated as foreign", () => {
    expect(
      classifyProfileIdentity({
        crossAccount: false,
        resolvedAccountId: SESSION_ACCT,
        callerUserId: CALLER,
        callerHomeAccountId: null,
        ownerUserId: OWNER,
      }),
    ).toEqual({ kind: "owner", userId: OWNER });
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd platform/plugins/memory/mcp && npx vitest run src/tools/__tests__/profile-identity.test.ts`
Expected: FAIL — cannot resolve module `../../lib/profile-identity.js`.

- [ ] **Step 3: Write minimal implementation**

Create `platform/plugins/memory/mcp/src/lib/profile-identity.ts`:

```ts
/**
 * Task 1557 — decide which identity a profile op (profile-read/update/delete)
 * attributes to, given the already-resolved facts. Pure: no DB, no env.
 *
 * - Cross-account (a house session that set targetAccountId): attribute to the
 *   target account's owner; reject if the target has no owner on record.
 * - Own-account, caller is a registered admin of THIS account (their AdminUser
 *   home account equals the session account): attribute to the caller. This is
 *   the owner-is-speaker path and the genuine second-admin path.
 * - Own-account, caller is a FOREIGN operator (home account differs, or the
 *   caller has no AdminUser node in this graph — a house operator working inside
 *   a client's sub-account): attribute to the session account's owner; reject
 *   loudly if that account has no owner on record.
 */
export type ProfileAttribution =
  | { kind: "caller"; userId: string }
  | { kind: "owner"; userId: string }
  | { kind: "reject"; reason: "no-caller" | "no-owner" };

export function classifyProfileIdentity(input: {
  crossAccount: boolean;
  resolvedAccountId: string;
  callerUserId: string | undefined;
  callerHomeAccountId: string | null;
  ownerUserId: string | null;
}): ProfileAttribution {
  const { crossAccount, resolvedAccountId, callerUserId, callerHomeAccountId, ownerUserId } = input;
  if (crossAccount) {
    return ownerUserId
      ? { kind: "owner", userId: ownerUserId }
      : { kind: "reject", reason: "no-owner" };
  }
  if (!callerUserId) return { kind: "reject", reason: "no-caller" };
  if (callerHomeAccountId === resolvedAccountId) {
    return { kind: "caller", userId: callerUserId };
  }
  return ownerUserId
    ? { kind: "owner", userId: ownerUserId }
    : { kind: "reject", reason: "no-owner" };
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd platform/plugins/memory/mcp && npx vitest run src/tools/__tests__/profile-identity.test.ts`
Expected: PASS (7 tests).

- [ ] **Step 5: Commit**

```bash
git add platform/plugins/memory/mcp/src/lib/profile-identity.ts platform/plugins/memory/mcp/src/tools/__tests__/profile-identity.test.ts
git commit -m "feat(memory): pure profile-identity classifier for operator-on-behalf attribution (Task 1557)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Session: e53535e2-8c90-4e75-9936-6132ef8a86a0"
```

---

### Task 2: Caller home-account resolver

**Files:**
- Create: `platform/plugins/memory/mcp/src/lib/resolve-admin-home-account.ts`
- Test: `platform/plugins/memory/mcp/src/tools/__tests__/resolve-admin-home-account.test.ts`

**Interfaces:**
- Consumes: `getSession` from `../lib/neo4j.js` (existing).
- Produces: `resolveAdminHomeAccount(userId: string): Promise<string | null>` — the `accountId` stamped on the caller's single `:AdminUser {userId}` node, or null.

- [ ] **Step 1: Write the failing test**

Create `platform/plugins/memory/mcp/src/tools/__tests__/resolve-admin-home-account.test.ts`:

```ts
/**
 * Task 1557 — resolveAdminHomeAccount returns the accountId stamped on the
 * caller's AdminUser node (their home account), used to tell an operator working
 * inside a client's sub-account from an admin of the account being operated.
 */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { makeMockSession } from "./_helpers/emit-capture.js";

const CALLER = "11111111-2222-3333-4444-555555555555";
const HOUSE = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";

let mockSession: ReturnType<typeof makeMockSession>;
vi.mock("../../lib/neo4j.js", () => ({
  getSession: () => mockSession,
}));

import { resolveAdminHomeAccount } from "../../lib/resolve-admin-home-account.js";

describe("resolveAdminHomeAccount (Task 1557)", () => {
  beforeEach(() => {
    mockSession = makeMockSession();
  });

  it("returns the accountId stamped on the caller's AdminUser node", async () => {
    mockSession = makeMockSession([[{ accountId: HOUSE }]]);
    const result = await resolveAdminHomeAccount(CALLER);
    expect(result).toBe(HOUSE);
    const cypher = String(mockSession.run.mock.calls[0][0]);
    expect(cypher).toMatch(/MATCH \(au:AdminUser \{userId: \$userId\}\)/);
    expect(mockSession.run.mock.calls[0][1]).toMatchObject({ userId: CALLER });
    expect(mockSession.close).toHaveBeenCalled();
  });

  it("returns null when the caller has no AdminUser node", async () => {
    mockSession = makeMockSession([[]]);
    expect(await resolveAdminHomeAccount(CALLER)).toBeNull();
    expect(mockSession.close).toHaveBeenCalled();
  });

  it("returns null when the AdminUser node carries no accountId", async () => {
    mockSession = makeMockSession([[{ accountId: "" }]]);
    expect(await resolveAdminHomeAccount(CALLER)).toBeNull();
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd platform/plugins/memory/mcp && npx vitest run src/tools/__tests__/resolve-admin-home-account.test.ts`
Expected: FAIL — cannot resolve module `../../lib/resolve-admin-home-account.js`.

- [ ] **Step 3: Write minimal implementation**

Create `platform/plugins/memory/mcp/src/lib/resolve-admin-home-account.ts`:

```ts
import { getSession } from "./neo4j.js";

/**
 * Task 1557 — resolve the "home" account of an admin: the `accountId` stamped
 * on their `:AdminUser` node. AdminUser is MERGE-keyed on `userId` alone
 * (neo4j-store.ts), with `accountId` set ON CREATE, so each admin has exactly
 * one node whose `accountId` is the account they were first provisioned under.
 *
 * A profile op whose caller's home account differs from the account the session
 * is scoped to is a house operator working inside a client's sub-account — the
 * preference belongs to that client's owner, not the operator. Returns null when
 * the caller has no `:AdminUser` node or it carries no `accountId`.
 */
export async function resolveAdminHomeAccount(userId: string): Promise<string | null> {
  const session = getSession();
  try {
    const res = await session.run(
      `MATCH (au:AdminUser {userId: $userId})
       RETURN au.accountId AS accountId
       LIMIT 1`,
      { userId },
    );
    const accountId = res.records[0]?.get("accountId");
    return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
  } finally {
    await session.close();
  }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `cd platform/plugins/memory/mcp && npx vitest run src/tools/__tests__/resolve-admin-home-account.test.ts`
Expected: PASS (3 tests).

- [ ] **Step 5: Commit**

```bash
git add platform/plugins/memory/mcp/src/lib/resolve-admin-home-account.ts platform/plugins/memory/mcp/src/tools/__tests__/resolve-admin-home-account.test.ts
git commit -m "feat(memory): resolveAdminHomeAccount helper (Task 1557)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Session: e53535e2-8c90-4e75-9936-6132ef8a86a0"
```

---

### Task 3: Wire the classifier into resolveProfileIdentity and the handler bootstrap flags

**Files:**
- Modify: `platform/plugins/memory/mcp/src/index.ts` (imports; `resolveProfileIdentity` at lines 150-186; `profile-update` handler bootstrap flags at lines 2584-2585)
- Modify: `platform/plugins/memory/mcp/src/tools/__tests__/profile-update-personfields-bootstrap.test.ts:112-116` (structural assertion tracks the new wiring)

**Interfaces:**
- Consumes: `classifyProfileIdentity` (Task 1), `resolveAdminHomeAccount` (Task 2), `resolveOwnerUserId` (existing, `./lib/resolve-owner-userid.js`).
- Produces: `resolveProfileIdentity(...)` now returns `{ userId: string; bootstrap: boolean } | { reject: … }`. The `bootstrap` field is true exactly when the write attributes to a resolved owner (cross-account or operator-on-behalf); handlers pass it as `bootstrapProfile`/`bootstrapPerson`.

- [ ] **Step 1: Add the two imports**

In `platform/plugins/memory/mcp/src/index.ts`, immediately after the existing line 53 `import { resolveOwnerUserId } from "./lib/resolve-owner-userid.js";`, add:

```ts
import { resolveAdminHomeAccount } from "./lib/resolve-admin-home-account.js";
import { classifyProfileIdentity } from "./lib/profile-identity.js";
```

- [ ] **Step 2: Replace the IdentityResult type and resolveProfileIdentity body**

Replace the block at `platform/plugins/memory/mcp/src/index.ts:150-186` (from `type IdentityResult =` through the end of the `resolveProfileIdentity` function) with:

```ts
type IdentityResult =
  | { userId: string; bootstrap: boolean }
  | { reject: { content: { type: "text"; text: string }[]; isError: true } };
async function resolveProfileIdentity(
  toolName: string,
  crossAccount: boolean,
  resolvedAccountId: string,
  callerUserId: string | undefined,
): Promise<IdentityResult> {
  // Resolve the facts the pure classifier needs. On the own-account path the
  // account's owner is resolved only when the caller is NOT operating their
  // home account (a foreign house operator inside a client's sub-account), so
  // the common owner-is-speaker path costs one AdminUser lookup, not two.
  const callerHomeAccountId =
    !crossAccount && callerUserId ? await resolveAdminHomeAccount(callerUserId) : null;
  const needOwner =
    crossAccount || (!!callerUserId && callerHomeAccountId !== resolvedAccountId);
  const ownerUserId = needOwner ? await resolveOwnerUserId(resolvedAccountId) : null;

  const decision = classifyProfileIdentity({
    crossAccount,
    resolvedAccountId,
    callerUserId,
    callerHomeAccountId,
    ownerUserId,
  });

  if (decision.kind === "reject") {
    const text =
      decision.reason === "no-owner"
        ? `${toolName} failed: target account ${resolvedAccountId.slice(0, 8)}… has no owner AdminUser to attribute the profile to — its graph root is unprovisioned (see Task 1359 seedAccountGraphRoot).`
        : `${toolName} requires an authenticated admin session with userId`;
    return {
      reject: {
        content: [{ type: "text" as const, text }],
        isError: true as const,
      },
    };
  }

  if (decision.kind === "owner") {
    if (crossAccount) {
      process.stderr.write(
        `[xacct] op=identity tool=${toolName} target=${resolvedAccountId.slice(0, 8)} resolved-userId=${decision.userId.slice(0, 8)}\n`,
      );
    } else {
      process.stderr.write(
        `[xacct] op=identity tool=${toolName} mode=operator-on-behalf target=${resolvedAccountId.slice(0, 8)} caller=${(callerUserId ?? "").slice(0, 8)} resolved-userId=${decision.userId.slice(0, 8)}\n`,
      );
    }
    return { userId: decision.userId, bootstrap: true };
  }

  // decision.kind === "caller" — own-account owner-is-speaker / second admin.
  return { userId: decision.userId, bootstrap: false };
}
```

- [ ] **Step 3: Point the profile-update bootstrap flags at the resolved identity**

In `platform/plugins/memory/mcp/src/index.ts`, replace lines 2584-2585:

```ts
          bootstrapProfile: crossAccount,
          bootstrapPerson: crossAccount,
```

with:

```ts
          bootstrapProfile: ident.bootstrap,
          bootstrapPerson: ident.bootstrap,
```

(No change is needed in the `profile-read` or `profile-delete` handlers: they read only `ident.userId`, which the success shape still carries. The `if ("reject" in ident) return ident.reject;` guard is unchanged in all three.)

- [ ] **Step 4: Update the structural test to track the new wiring**

In `platform/plugins/memory/mcp/src/tools/__tests__/profile-update-personfields-bootstrap.test.ts`, replace lines 112-116:

```ts
  it("index.ts wires bootstrapPerson: crossAccount at the profile-update call site", () => {
    const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "../../index.ts");
    const src = readFileSync(SRC, "utf8");
    expect(src).toContain("bootstrapPerson: crossAccount");
  });
```

with:

```ts
  it("index.ts wires bootstrapPerson from the resolved identity at the profile-update call site", () => {
    const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "../../index.ts");
    const src = readFileSync(SRC, "utf8");
    expect(src).toContain("bootstrapPerson: ident.bootstrap");
  });
```

- [ ] **Step 5: Build and run the full suite**

Run: `cd platform/plugins/memory/mcp && npm run build && npm test`
Expected: build clean (no TypeScript errors); all tests PASS, including the new `profile-identity` and `resolve-admin-home-account` files and the updated `profile-update-personfields-bootstrap` structural test. Baseline before this task was 67 files / 477 tests passing; after Tasks 1-3 expect 69 files and 487 tests (477 + 7 classifier + 3 resolver), all green.

- [ ] **Step 6: Commit**

```bash
git add platform/plugins/memory/mcp/src/index.ts platform/plugins/memory/mcp/src/tools/__tests__/profile-update-personfields-bootstrap.test.ts
git commit -m "fix(memory): attribute preferences to the account owner when an operator works inside a client's sub-account (Task 1557)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Session: e53535e2-8c90-4e75-9936-6132ef8a86a0"
```

---

### Task 4: Teach the skill and document the model

**Files:**
- Modify: `platform/plugins/memory/skills/conversational-memory/SKILL.md` (add an operator-on-behalf section)
- Modify: `platform/plugins/memory/PLUGIN.md` (Conversational Memory section, after line 223)
- Modify: `platform/plugins/memory/mcp/src/tools/profile-update.ts:312-314` (refresh the now-stale bootstrap comment)

**Interfaces:** none (docs and comment only).

- [ ] **Step 1: Add the operator-on-behalf section to the skill**

In `platform/plugins/memory/skills/conversational-memory/SKILL.md`, insert this section immediately before the `## How to Store` heading (currently line 75). Zero em-dashes:

```markdown
## Operating a client's account on the owner's behalf

When you are a house operator working inside a client's sub-account, the preferences you capture there belong to that client, the account's owner on record, not to you. Attribution is automatic. A preference stored while you operate a client's account is written against that owner, and "what does this owner prefer?" reads the owner's list. You pass no target and no flag; the tool decides from who is driving the session.

Your own working preferences belong to your own session, not a client's. Store them when you are operating your own account.

If a client's account has no owner on record, a preference write is refused out loud rather than saved to your profile. That means the account's graph root was never seeded, so it cannot hold owner preferences until it is.
```

- [ ] **Step 2: Document the model in PLUGIN.md**

In `platform/plugins/memory/PLUGIN.md`, insert this paragraph immediately after line 223 (the end of the `## Conversational Memory` intro paragraph, before the `## UserProfile + Person Field Management` heading):

```markdown
When a house operator operates a client's sub-account, the session is scoped into that account (`ACCOUNT_ID` is the sub-account, `USER_ID` is the operator, no `HOUSE_ADMIN_SCOPE`). In that session `profile-read`/`profile-update`/`profile-delete` resolve to the account's **owner** `AdminUser`, not the operator: the operator has no `AdminUser` node in the sub-account subgraph, so `resolveProfileIdentity` sees the caller is not an admin of the account it is scoped to and attributes to the seeded owner — the same owner identity the cross-account `targetAccountId` path resolves (above), reached without `targetAccountId` because the re-scoped session cannot set it. An account with no seeded owner rejects the write loudly (`… has no owner AdminUser …`) rather than attributing it to the operator, and emits `[xacct] op=identity tool=profile-* mode=operator-on-behalf target=<sub8> caller=<op8> resolved-userId=<owner8>` on the owner-attributed path. The operator's own preferences live on their own (house) session.
```

- [ ] **Step 3: Refresh the stale bootstrap comment**

In `platform/plugins/memory/mcp/src/tools/profile-update.ts`, replace lines 312-314:

```ts
    // preference lands. Own-account writes never set bootstrapProfile (their
    // UserProfile is provisioned at PIN setup), so this is byte-identical for
    // them. Idempotent: a MERGE that matches an existing UserProfile is a no-op.
```

with:

```ts
    // preference lands. Own-account owner-is-speaker writes never set
    // bootstrapProfile (their UserProfile is provisioned at PIN setup); an
    // operator-on-behalf write (Task 1557) does set it, and this MERGE
    // bootstraps the client owner's UserProfile exactly as the cross-account
    // path does. Idempotent: a MERGE that matches an existing UserProfile is a no-op.
```

- [ ] **Step 4: Verify the build still compiles (comment-only source change)**

Run: `cd platform/plugins/memory/mcp && npm run build`
Expected: clean, no TypeScript errors.

- [ ] **Step 5: Commit**

```bash
git add platform/plugins/memory/skills/conversational-memory/SKILL.md platform/plugins/memory/PLUGIN.md platform/plugins/memory/mcp/src/tools/profile-update.ts
git commit -m "docs(memory): teach conversational-memory the operator-on-behalf owner attribution (Task 1557)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Session: e53535e2-8c90-4e75-9936-6132ef8a86a0"
```

---

## Self-Review

**Spec coverage:**
- "Attribute a foreign operator's preference to the account owner" → Task 1 (classifier `owner` branch) + Task 3 (wiring). ✓
- "Discriminator = caller not an admin of the scoped account, robust to multi-admin" → Task 2 (`resolveAdminHomeAccount`) + Task 1 (`callerHomeAccountId === resolvedAccountId` → caller). ✓
- "No owner → loud reject, never silent operator attribution" → Task 1 (`no-owner` reject) + Task 3 (reject envelope). ✓
- "Bootstrap the owner's profile on the operator-on-behalf path" → Task 3 (`bootstrap` from resolved identity) + Task 4 Step 3 (comment). ✓
- "Read side returns owner's preferences" → covered for free: `profile-read` uses `ident.userId` which now resolves to the owner (Task 3 note in Step 3). ✓
- "Own-account owner-is-speaker and second-admin unchanged" → Task 1 (`caller` branch) + tests in Task 1. ✓
- "Cross-account path unchanged, log line byte-identical" → Task 3 Step 2 (crossAccount branch keeps the exact existing line). ✓
- "Skill teaching" → Task 4 Step 1. "PLUGIN.md doc" → Task 4 Step 2. ✓
- "Tests: operator-on-behalf, own-account, second-admin, no-owner, cross-account" → Task 1 test (all classifier branches) + Task 2 test (home resolution) + existing `resolve-owner-userid.test.ts` and `cross-account.test.ts` remain green (verified in Task 3 Step 5). ✓

**Placeholder scan:** No TBD/TODO/"handle edge cases"; every code step carries complete code. ✓

**Type consistency:** `ProfileAttribution` kinds (`caller`/`owner`/`reject`) and the `classifyProfileIdentity` input keys are identical between Task 1's definition, Task 1's tests, and Task 3's call site. `IdentityResult` success shape `{ userId, bootstrap }` matches the handler reads (`ident.userId`, `ident.bootstrap`). `resolveAdminHomeAccount(userId): Promise<string | null>` matches its Task 3 call. ✓

**Out-of-scope confirmed untouched:** no change to `targetAccountId`/`resolveEffectiveAccountCore`, the sub-account picker/spawn path, owner seeding, or arbitrary-person attribution. A no-owner backfill remains a separate concern; if the on-device reproduce surfaces a rootless client account, file a `.tasks/NNN-*.md` rather than relaxing the reject.
