# Task 1557 — preferences an operator states inside a client's sub-account attach to the owner, not the operator

**Date:** 2026-07-11
**Status:** Approved design, pending plan
**Scope:** `platform/plugins/memory/mcp/src/` (identity resolution), `platform/plugins/memory/skills/conversational-memory/SKILL.md`, `platform/plugins/memory/PLUGIN.md`, tests.

## The problem, in one line

A house operator working inside a client's sub-account states a preference on the client's behalf. It attaches to the operator, not the client's owner. The operator wants a way to say "this belongs to them, not me."

## What the evidence established (measure-first, now measured)

The attribution is decided in `resolveProfileIdentity` (`platform/plugins/memory/mcp/src/index.ts:153`). Two facts, both confirmed at the code layer, fix the shape of the solution:

1. **Operating a sub-account re-scopes the whole session into it.** The sub-account picker POSTs `/api/admin/session/switch-account`, which calls `createAdminSession(target.accountId, …, operatorUserId)` (`platform/ui/server/routes/admin/session.ts:341-371`). The rc-spawn that follows boots the memory MCP with `ACCOUNT_ID` = the **sub-account**, `USER_ID` = the **house operator**, and **no** `HOUSE_ADMIN_SCOPE` (`buildRcChildEnv`, and `houseAdminScopeFor` returns null for a non-house account). So the preference write is an ordinary own-account write, keyed to the operator's `USER_ID`, landing on `:UserProfile{accountId:sub, userId:operator}` — never the owner.

2. **The tool-layer `targetAccountId` lever is inert in this session.** `resolveEffectiveAccountCore` rejects any `targetAccountId` with `cross-account-denied` when `houseScope` is null (`platform/lib/account-scope/src/index.ts:49`). A sub-account operator session has no house scope, so the agent cannot set `targetAccountId`. **Teaching the skill to pass `targetAccountId` would do nothing** — it would be rejected. This is the branch the task pre-authorised: MCP `src/` changes "only if the operator-surface `targetAccountId` wiring proves absent — measure first." It is absent.

3. **The owner is a distinct, single, resolvable identity.** Each sub-account is seeded with exactly one `:AdminUser{accountId, role:'owner'}` carrying a **fresh per-account `userId`** (`platform/plugins/admin/mcp/src/tools/account-lifecycle.ts:256,279`), deliberately never the operator's. `resolveOwnerUserId(accountId)` already resolves it (`platform/plugins/memory/mcp/src/lib/resolve-owner-userid.ts`).

4. **`AdminUser` nodes are keyed on `userId` alone; `accountId` is a home-account stamp.** `neo4j-store.ts:627-630` MERGEs `:AdminUser {userId}` and sets `accountId` only `ON CREATE`. Each admin therefore has one global `AdminUser` node whose `accountId` is their home account. This makes "is the caller an admin of the account they are operating?" answerable with one query, with the home account fixed at the node's first creation.

## The fix

A preference can only ever attach to an account's owner — that is the only "designated person" the memory model supports, confirmed with the operator. So "attribute this to the designated person" resolves to "attribute this to the owner of the account being operated." The switch that decides it lives in one place: `resolveProfileIdentity`.

### Behaviour

In `resolveProfileIdentity`, the own-account branch (`crossAccount === false`) gains an operator-on-behalf case:

- Resolve the caller's home account: the `accountId` on their `:AdminUser {userId: callerUserId}` node.
- **Caller is operating their home account** (home `accountId` == the session's resolved `accountId`): attribute to `callerUserId`. Unchanged — this is the owner-is-speaker path and the genuine second-admin path.
- **Caller is a foreign operator** (home `accountId` != the session's resolved `accountId`, i.e. a house operator working inside a client's sub-account): resolve the session account's owner via `resolveOwnerUserId`.
  - Owner exists → attribute the preference to the **owner**, and bootstrap the owner's `UserProfile`/`Person` the same way the cross-account path already does. Emit `[xacct] op=identity tool=… mode=operator-on-behalf target=<acct8> caller=<caller8> resolved-userId=<owner8>`.
  - No owner on record → **loud reject** (identical message class to the cross-account no-owner rejection). Never a silent fall-through to the operator's own profile.
- Caller `userId` absent → existing "requires an authenticated admin session with userId" reject, unchanged.

The cross-account branch (`crossAccount === true`, a house session that set `targetAccountId`) is unchanged.

### Discriminator — why it is correct and regression-safe

The condition "caller's `AdminUser.accountId` != the session's `accountId`" is equivalent to "no `:AdminUser {accountId: <session>, userId: <caller>}` exists," because there is exactly one `AdminUser` node per `userId`. Case-by-case:

| Caller | Session account | Home `accountId` | Result |
|---|---|---|---|
| Owner (home = own account) | own | own | attribute self — unchanged |
| Second admin of an account | that account | that account | attribute self — no regression |
| House operator | a client sub-account | house | attribute the sub-account **owner** — the fix |
| House operator | the house account | house | attribute self — unchanged |

A sub-account subgraph never contains an `AdminUser` node for the operator (their node carries `accountId=house`), so the foreign-operator branch fires for exactly the reported case and no other.

### Bootstrap generalisation

Today the handlers set `bootstrapProfile`/`bootstrapPerson` `= crossAccount`. After the fix, a foreign-operator own-account write also attributes to an owner whose `UserProfile` may not yet exist, so the bootstrap flags must be true whenever the resolved identity is a **foreign owner**, not only when `crossAccount`. `resolveProfileIdentity` will return whether the attribution is to a foreign owner, and the three handlers derive the bootstrap flags from that rather than from `crossAccount` directly. This keeps a single meaning: "we resolved to an owner who is not the caller → bootstrap their profile if absent."

### Skill

`conversational-memory/SKILL.md` currently says "the owner" throughout and assumes the person conversing **is** the owner. It gains the operator-on-behalf case: when the session is a house operator working inside a client's account, preferences captured there belong to that client (the account's owner), not to the operator; the operator's own preferences live on their own (house) session. "What does this owner prefer?" reads the owner's list. The skill also states the loud refusal when the client account has no owner on record, pointing at the rootless-account remedy. No new tool argument is introduced — attribution is decided by who is driving the session, deterministically, so the skill describes the model rather than instructing a per-call lever.

### Docs

`PLUGIN.md`'s preference section records the operator/owner attribution model: in a client sub-account session, preference reads and writes resolve to the account's owner, and a no-owner account rejects loudly. This sits alongside the existing cross-account `targetAccountId` note (lines 189-193), which documents the house-session path; the two paths resolve to the same owner identity.

## What success looks like

- A preference an operator states while operating a client's sub-account is stored as a `:Preference` attributed to that sub-account's **owner** `userId`, not the operator's.
- "What does the owner prefer?" on the sub-account returns the owner's preferences, not the operator's.
- A sub-account with no seeded owner rejects the write out loud, never silently attributing to the operator.
- The own-account owner-is-speaker path and the genuine second-admin path are byte-for-byte unchanged.

## Testing

Unit tests over `resolveProfileIdentity` / the profile handlers:

- **Operator-on-behalf write** — caller whose `AdminUser.accountId` != the session account resolves the session account's owner `userId`; the resolved identity is the owner and the bootstrap flag is set.
- **Own-account owner-is-speaker** — caller whose `AdminUser.accountId` == the session account keys to the caller `userId`; bootstrap flag is off. Unchanged.
- **Second admin of the account** — caller with a home `AdminUser.accountId` equal to the session account attributes to self, proving multi-admin accounts do not mis-redirect.
- **Foreign operator, no owner** — session account has no owner `AdminUser`; the write is rejected loudly, never caller-attributed.
- **Cross-account (`targetAccountId`) path** — unchanged: still resolves the target owner, still rejects a no-owner target.

The attribution is already logged (`[xacct] op=identity … resolved-userId=`, `[preference-write] op=… accountId=`), so on-device the before/after is visible from the journal without new instrumentation. The runtime reproduce on the SiteDesk device is a confirmation of the already-established code path, not a design input; this Mac cannot read that device's session log, so the design rests on the code evidence above.

## Out of scope

- The cross-account `targetAccountId` / `resolveOwnerUserId` mechanism (Task 1461) and owner seeding (Task 1359) — consumed, not re-implemented.
- How the sub-account picker switches/re-scopes sessions (Task 1311) — untouched; the fix needs no spawn-env change.
- Attribution to an arbitrary named individual who is not an account owner — a much larger storage/schema change, explicitly excluded.
- Backfill of pre-1359 sub-accounts created without an owner on record — a separate one-time seed, never a gate relaxation. If needed, filed as its own task.
- Non-preference profile fields and the `personFields` owner-`Person` bootstrap beyond their sharing the same resolved-owner identity path.
