# Task 1902 account-owned entry declarations 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:** Every account-root entry the platform or a shipped skill creates is declared by the plugin that owns it, files included, so the standing `op=strays` line names only debris.

**Architecture:** `account-schema-owned-dirs.py` gains a second frontmatter key, `account-owned-files`, whose names union into the same `allowed-top-level` block as declared dirs. Nine names move onto six plugin manifests, and the seven file entries in the reconcile's `PROTECTED_TOP_LEVEL` constant are deleted, so the account's own `SCHEMA.md` is the only thing holding them.

**Tech Stack:** Python 3 (no deps), bash test harness with hand-rolled asserts, TypeScript with vitest, markdown frontmatter.

**Spec:** [`../specs/2026-07-26-task-1902-account-owned-entry-declarations-design.md`](../specs/2026-07-26-task-1902-account-owned-entry-declarations-design.md). Task: [`.tasks/pending/1902-account-owned-entry-declarations-are-incomplete.md`](../../../../.tasks/pending/1902-account-owned-entry-declarations-are-incomplete.md).

## Global Constraints

- Every path below is relative to `maxy-code/`. Run every command from `maxy-code/`.
- A declaration is one single-line JSON array in a PLUGIN.md frontmatter. The resolver's regex captures to end of line (`account-schema-owned-dirs.py:120`), so a multi-line array parses as truncated JSON and is silently skipped.
- A malformed declaration is skipped and every other declaration still merges. Never let one bad manifest abort the merge.
- The plugin-owned descriptive region renders a directory as `` - `name/` — description `` and a file as `` - `name` — description ``. The trailing slash is load-bearing: three parsers (`platform/lib/account-schema-regions/src/index.ts:65`, `platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs:42-43`) anchor on it to tell a bucket from anything else. A file must never gain one.
- Commit trailers on every commit:
  ```
  Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  Session: 1ff53443-54dd-48a5-9e70-5ef72fd19588
  ```

---

### Task 1: Resolver parses and merges `account-owned-files`

**Files:**
- Modify: `platform/scripts/lib/account-schema-owned-dirs.py:104-172` (`_parse_plugin_md`, `resolve`), `:286-377` (`merge`), `:380-391` (`reconcile`)
- Test: `platform/scripts/__tests__/account-schema-owned-dirs.test.sh`

**Interfaces:**
- Consumes: nothing from earlier tasks.
- Produces: `resolve(project_dir)` returns `[{"name": str, "kind": "dir" | "file", "description": str, "plugin": str}]` in declaration order, deduped on `name`, first declarer winning. Every later task reads `name` and `kind`; the old `dir` key is gone.

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

Append to `platform/scripts/__tests__/account-schema-owned-dirs.test.sh`, immediately before the final `echo "PASS=$PASS FAIL=$FAIL"` line:

```bash
# --- account-owned-files: a declared file joins the same allowed block ---
# The block is a flat list of names and already carries SCHEMA.md and
# account.json, so a file needs no second surface. A second plugin declares a
# malformed value: its key is skipped and the valid declaration still merges.
TF="$(mktemp -d)"
mkdir -p "$TF/platform/plugins/filer" "$TF/platform/plugins/broken" \
         "$TF/platform/config" "$TF/platform/templates/account-schema"
cp "$REAL_TEMPLATE" "$TF/platform/templates/account-schema/SCHEMA.md"
printf '{"installDir":"files-brand","shipsPremiumBundles":[]}\n' > "$TF/platform/config/brand.json"
printf -- '---\nname: filer\naccount-owned-files: [{"file": "x.json", "description": "Filer index."}]\n---\n' \
  > "$TF/platform/plugins/filer/PLUGIN.md"
printf -- '---\nname: broken\naccount-owned-files: [{"file": "y.json"\n---\n' \
  > "$TF/platform/plugins/broken/PLUGIN.md"
A_TF="$(mktemp -d)"; cp "$TF/platform/templates/account-schema/SCHEMA.md" "$A_TF/SCHEMA.md"
export PROJECT_DIR="$TF/platform"
OUT_TF="$(merge_owned_dirs_into_schema "$A_TF")"
ALLOWED_TF="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_TF/SCHEMA.md")"
assert_grep "x.json" "$ALLOWED_TF" "files-allowlist-has-x"
assert_nogrep "y.json" "$ALLOWED_TF" "files-malformed-skipped"
assert_grep "added=x.json" "$OUT_TF" "files-added-logged"
# A file renders without the trailing slash a dir gets; three parsers read that
# slash to tell a bucket from anything else.
assert_grep '- `x.json` — Filer index.' "$(cat "$A_TF/SCHEMA.md")" "files-region-no-slash"
assert_nogrep '- `x.json/`' "$(cat "$A_TF/SCHEMA.md")" "files-region-not-a-dir"
RC_TF="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_TF")"
assert_grep "ownedFile=x.json present=true" "$RC_TF" "files-reconcile-line"
B_TF="$(shasum "$A_TF/SCHEMA.md" | awk '{print $1}')"
merge_owned_dirs_into_schema "$A_TF" >/dev/null
assert_eq "$(shasum "$A_TF/SCHEMA.md" | awk '{print $1}')" "$B_TF" "files-idempotent"

# A dir declaration keeps its slash and its ownedDir= reconcile line.
TD="$(mktemp -d)"
mkdir -p "$TD/platform/plugins/dirred" "$TD/platform/config" "$TD/platform/templates/account-schema"
cp "$REAL_TEMPLATE" "$TD/platform/templates/account-schema/SCHEMA.md"
printf '{"installDir":"dirs-brand","shipsPremiumBundles":[]}\n' > "$TD/platform/config/brand.json"
printf -- '---\nname: dirred\naccount-owned-dirs: [{"dir": "widgets", "description": "Widget workspace."}]\n---\n' \
  > "$TD/platform/plugins/dirred/PLUGIN.md"
A_TD="$(mktemp -d)"; cp "$TD/platform/templates/account-schema/SCHEMA.md" "$A_TD/SCHEMA.md"
export PROJECT_DIR="$TD/platform"
merge_owned_dirs_into_schema "$A_TD" >/dev/null
assert_grep '- `widgets/` — Widget workspace.' "$(cat "$A_TD/SCHEMA.md")" "dirs-region-keeps-slash"
RC_TD="$(PROJECT_DIR="$PROJECT_DIR" python3 "$PLAT_SCRIPTS/lib/account-schema-owned-dirs.py" reconcile "$A_TD")"
assert_grep "ownedDir=widgets present=true" "$RC_TD" "dirs-reconcile-line"
```

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

Run: `bash platform/scripts/__tests__/account-schema-owned-dirs.test.sh`
Expected: FAIL, listing `files-allowlist-has-x`, `files-added-logged`, `files-region-no-slash`, `files-reconcile-line`. `dirs-*` cases pass already.

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

In `platform/scripts/lib/account-schema-owned-dirs.py`, replace the docstring's declaration example (lines 5-7) with both keys:

```python
A plugin declares owned top-level account entries in its PLUGIN.md frontmatter,
by kind:

    account-owned-dirs:  [{"dir": "quoting", "description": "..."}]
    account-owned-files: [{"file": "data-portal.json", "description": "..."}]
```

Replace the owned-parsing block of `_parse_plugin_md` (lines 105, 119-130) with:

```python
    """Return (name, [ {name, kind, description} ]) from a PLUGIN.md, or (None, []).
    Two frontmatter keys, one list: `account-owned-dirs` yields kind='dir',
    `account-owned-files` yields kind='file'. A key whose value is not a JSON
    array of objects is skipped whole; the other key and every other plugin
    still merge, because one hand-edited manifest must never empty the set."""
    owned = []
    for key, field, kind in (("account-owned-dirs", "dir", "dir"),
                             ("account-owned-files", "file", "file")):
        om = re.search(r"^" + key + r":\s*(.+)$", fm, re.MULTILINE)
        if not om:
            continue
        try:
            for e in json.loads(om.group(1).strip()):
                n = (e.get(field) or "").strip()
                if n:
                    owned.append({"name": n, "kind": kind,
                                  "description": (e.get("description") or "").strip()})
        except Exception:
            continue
    return name, owned
```

Replace `resolve` (lines 160-172) with:

```python
def resolve(project_dir):
    """Union of owned-entry declarations. First declarer of a name wins its
    owner label + description, whichever kind it declared."""
    seen = {}
    order = []
    for path in _plugin_md_paths(project_dir):
        name, owned = _parse_plugin_md(path)
        for e in owned:
            if e["name"] in seen:
                continue
            seen[e["name"]] = {"name": e["name"], "kind": e["kind"],
                               "description": e["description"], "plugin": name or "unknown"}
            order.append(e["name"])
    return [seen[n] for n in order]
```

In `merge`, replace the three `e["dir"]` reads. Line 307 becomes:

```python
        target = FIXED_SEED | {e["name"] for e in owned} | {b["dir"] for b in domain if _is_root(b["owner"])}
```

Lines 321-324 become:

```python
        for e in owned:
            if e["name"] not in present and e["name"] not in insert:
                insert.append(e["name"])
                added.append(e["name"])
```

Lines 343-345 (the region body) become:

```python
        for e in owned:
            desc = e["description"] or f"Owned by the {e['plugin']} plugin."
            head = f"{e['name']}/" if e["kind"] == "dir" else e["name"]
            region.append(f"- `{head}` — {desc}")
```

Line 348 becomes:

```python
    owned_dirs = {e["name"] for e in owned if e["kind"] == "dir"}
```

In `reconcile`, replace lines 389-391 with:

```python
    for e in owned:
        is_present = "true" if e["name"] in present else "false"
        label = "ownedDir" if e["kind"] == "dir" else "ownedFile"
        print(f"brand={brand} plugin={e['plugin']} {label}={e['name']} present={is_present}")
```

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

Run: `bash platform/scripts/__tests__/account-schema-owned-dirs.test.sh`
Expected: `FAIL=0`.

- [ ] **Step 5: Mutation check**

Delete the `("account-owned-files", "file", "file")` tuple from the loop in `_parse_plugin_md`, re-run the suite, and confirm the four `files-*` cases fail. Restore the tuple and confirm `FAIL=0` again.

- [ ] **Step 6: Commit**

```bash
git add platform/scripts/lib/account-schema-owned-dirs.py platform/scripts/__tests__/account-schema-owned-dirs.test.sh
git commit -m "feat(account-schema): declare account-owned files, not only dirs"
```

---

### Task 2: Pin that a declared file is not a bucket

**Files:**
- Test: `platform/ui/server/lib/__tests__/account-schema-regions.test.ts`

**Interfaces:**
- Consumes: the region line shape Task 1 produces (`` - `x.json` — desc ``).
- Produces: nothing. This task adds no production code; it pins existing behaviour that Task 1 now depends on.

The two region parsers already require a trailing slash inside the backticks (`account-schema-regions/src/index.ts:65`, `schema-exposed-dirs.mjs:42-43`), so a file line yields no bucket. That is correct and must stay correct: `pluginOwned` feeds the `/data` page grouping and the public data-portal exposure set, and a file leaking into either would put a control-plane file in front of a client.

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

Add to `platform/ui/server/lib/__tests__/account-schema-regions.test.ts`, inside the existing describe block for `parseSchemaRegions`:

```ts
  it('does not read a declared file as a plugin-owned bucket', () => {
    // Task 1902 — the plugin-owned region now carries file lines. `pluginOwned`
    // feeds the /data grouping and the public exposure set, so a file must not
    // enter it. The trailing slash inside the backticks is the discriminator.
    const schema = [
      '```allowed-top-level',
      'projects',
      'data-portal.json',
      '```',
      '',
      '<!-- plugin-owned-dirs:start -->',
      '- `pages/` — Cloudflare Pages deploy staging.',
      '- `data-portal.json` — the account data-portal index.',
      '<!-- plugin-owned-dirs:end -->',
    ].join('\n')
    const r = parseSchemaRegions(schema)
    expect(r.pluginOwned).toEqual(['pages'])
    expect(r.allowedTopLevel).toContain('data-portal.json')
  })
```

- [ ] **Step 2: Run the test**

Run: `cd platform/ui && npx vitest run server/lib/__tests__/account-schema-regions.test.ts`
Expected: PASS. This case pins current behaviour rather than driving new code, so it passes on first run.

- [ ] **Step 3: Mutation check**

In `platform/lib/account-schema-regions/src/index.ts:65`, change `DIR_LINE` to `/^-\s+`([^`]+?)\/?`/` so the slash becomes optional. Re-run the test and confirm it fails with `pluginOwned` holding `data-portal.json`. Revert the regex and confirm it passes.

- [ ] **Step 4: Commit**

```bash
git add platform/ui/server/lib/__tests__/account-schema-regions.test.ts
git commit -m "test(account-schema): pin that a declared file is not a plugin-owned bucket"
```

---

### Task 3: Declare the nine entries on their owning plugins

**Files:**
- Modify: `platform/plugins/business-assistant/PLUGIN.md`, `platform/plugins/cloudflare/PLUGIN.md`, `platform/plugins/scheduling/PLUGIN.md`, `platform/plugins/whatsapp/PLUGIN.md`, `platform/plugins/telegram/PLUGIN.md`, `platform/plugins/admin/PLUGIN.md`
- Test: `platform/scripts/__tests__/account-schema-owned-dirs.test.sh`

**Interfaces:**
- Consumes: `account-owned-files` parsing from Task 1.
- Produces: nine names in every account's merged `allowed-top-level` block: `e-sign`, `data-portal.json`, `calendar-availability.json`, `wa-channel-bindings.json`, `telegram-channel-bindings.json`, `webchat-channel-bindings.json`, `canonical-webchat-session.json`, `session-titles.json`, `agents-disabled.json`. Task 4 deletes the constant that holds seven of them today.

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

Append to `platform/scripts/__tests__/account-schema-owned-dirs.test.sh`, before the final `echo "PASS=$PASS FAIL=$FAIL"`:

```bash
# --- shipped declarations reach an account's allowed block ---
# Symlink the real plugin dirs so the case exercises the shipped PLUGIN.md
# frontmatter, the same way the Task 1891 pages/archive case does.
T_SHIP="$(make_tree '[]')"
for _p in business-assistant cloudflare scheduling whatsapp telegram admin; do
  ln -s "$REAL_PROJECT_DIR/plugins/$_p" "$T_SHIP/platform/plugins/$_p"
done
A_SHIP="$(make_account "$T_SHIP")"
export PROJECT_DIR="$T_SHIP/platform"
merge_owned_dirs_into_schema "$A_SHIP" >/dev/null
ALLOWED_SHIP="$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$A_SHIP/SCHEMA.md")"
for _n in e-sign data-portal.json calendar-availability.json wa-channel-bindings.json \
          telegram-channel-bindings.json webchat-channel-bindings.json \
          canonical-webchat-session.json session-titles.json agents-disabled.json; do
  assert_grep "$_n" "$ALLOWED_SHIP" "ship-allow-$_n"
done
```

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

Run: `bash platform/scripts/__tests__/account-schema-owned-dirs.test.sh`
Expected: FAIL, listing all nine `ship-allow-*` cases.

- [ ] **Step 3: Add the declarations**

Each is one new line inside the frontmatter. Insert each immediately before the anchor line named for that file.

`platform/plugins/business-assistant/PLUGIN.md`, before the `metadata:` line (line 4):

```
account-owned-dirs: [{"dir": "e-sign", "description": "Per-document e-sign workspace: the render-once base.pdf, its base.sha256, and the per-signer stamped copies. Written by the e-sign skill; its internal structure is skill-owned and recreated on the next document."}]
```

`platform/plugins/cloudflare/PLUGIN.md`, before the `tools: []` line (line 4):

```
account-owned-files: [{"file": "data-portal.json", "description": "The account's data-portal folder index, written by bin/portal-index-push.mjs on every push."}]
```

`platform/plugins/scheduling/PLUGIN.md`, before the `tools:` line (line 4):

```
account-owned-files: [{"file": "calendar-availability.json", "description": "The booking page's availability config, written by the publish-availability script and read by the live booking page."}]
```

`platform/plugins/whatsapp/PLUGIN.md`, before the `tools:` line (line 4):

```
account-owned-files: [{"file": "wa-channel-bindings.json", "description": "Routes this account's inbound WhatsApp messages. Written by the session manager's wa-channel-store."}]
```

`platform/plugins/telegram/PLUGIN.md`, before the `tools:` line (line 5):

```
account-owned-files: [{"file": "telegram-channel-bindings.json", "description": "Routes this account's inbound Telegram messages. Written by the session manager's telegram-channel-store."}]
```

`platform/plugins/admin/PLUGIN.md`, before the `tools:` line (line 5):

```
account-owned-files: [{"file": "webchat-channel-bindings.json", "description": "Routes this account's inbound admin webchat messages. Written by the session manager's webchat-channel-store."}, {"file": "canonical-webchat-session.json", "description": "Pins the account's canonical webchat session. Written by the admin UI server."}, {"file": "session-titles.json", "description": "Operator renames of sessions. Written by the session manager's title stores."}, {"file": "agents-disabled.json", "description": "Names the agents the operator switched off. Written by the admin agents route and seeded at provision."}]
```

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

Run: `bash platform/scripts/__tests__/account-schema-owned-dirs.test.sh`
Expected: `FAIL=0`.

- [ ] **Step 5: Verify each manifest still parses as one declaration**

Run:

```bash
PROJECT_DIR="$PWD/platform" python3 platform/scripts/lib/account-schema-owned-dirs.py resolve | python3 -m json.tool | grep -c '"name"'
```

Expected: `11`. The repo tree carries no `platform/config/brand.json` (it is written at bundle time), so `_read_brand` resolves no premium bundles and the set is exactly the nine new names plus `archive` (memory) and `pages` (storage-broker). A lower count means one manifest's JSON is truncated and was silently skipped.

- [ ] **Step 6: Commit**

```bash
git add platform/plugins/business-assistant/PLUGIN.md platform/plugins/cloudflare/PLUGIN.md platform/plugins/scheduling/PLUGIN.md platform/plugins/whatsapp/PLUGIN.md platform/plugins/telegram/PLUGIN.md platform/plugins/admin/PLUGIN.md platform/scripts/__tests__/account-schema-owned-dirs.test.sh
git commit -m "feat(plugins): declare the account-root entries each plugin owns"
```

---

### Task 4: Shrink `PROTECTED_TOP_LEVEL` to the account's own machinery

**Files:**
- Modify: `platform/services/claude-session-manager/src/account-dir-schema-reconcile.ts:37-75`
- Test: `platform/services/claude-session-manager/src/__tests__/account-dir-schema-reconcile.test.ts:36-49` (the `seedAccount` helper), `:193-215` and `:310-325` (the two belt tests)

**Interfaces:**
- Consumes: the nine declarations from Task 3, which reach an account through its `SCHEMA.md`.
- Produces: `PROTECTED_TOP_LEVEL` holding exactly `.git`, `.claude`, `.quarantine`, `SCHEMA.md`, `account.json`, `AGENTS.md`. `seedAccount(id, extraAllowed?)` gains an optional second parameter appending names to the seeded allowed block.

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

In `platform/services/claude-session-manager/src/__tests__/account-dir-schema-reconcile.test.ts`, replace the `seedAccount` signature and its schema write (lines 36-39) with:

```ts
function seedAccount(id: string, extraAllowed: string[] = []): string {
  const dir = join(root, id)
  mkdirSync(dir, { recursive: true })
  // Task 1902 — a reprovisioned account's allowed block carries the names its
  // plugins declare, so a test about strays seeds the schema the reconcile
  // actually reads rather than a constant that no longer holds them.
  const schema = extraAllowed.length
    ? SCHEMA.replace('\n```\n', `\n${extraAllowed.join('\n')}\n\`\`\`\n`)
    : SCHEMA
  writeFileSync(join(dir, 'SCHEMA.md'), schema)
```

Replace the body and comment of the test at line 193 (`never counts platform-written control-plane root files absent from the allowed block`) with:

```ts
  it('never counts control-plane root files the account schema declares', () => {
    // Each of these is declared by the plugin whose feature owns it (Task 1902),
    // so a reprovisioned account carries them in its allowed block and the
    // reconcile reads them from there. Before the declarations existed a
    // hand-maintained constant held them, extended once per casualty:
    // calendar-availability.json was Task 1877, after quarantining it froze a
    // live booking page. A genuine operator stray alongside them is still named.
    const controlFiles = [
      'calendar-availability.json',
      'wa-channel-bindings.json',
      'webchat-channel-bindings.json',
      'telegram-channel-bindings.json',
      'canonical-webchat-session.json',
      'session-titles.json',
    ]
    const dir = seedAccount('acct-control-files', controlFiles)
    for (const f of controlFiles) writeFileSync(join(dir, f), '{}')
    mkdirSync(join(dir, 'workers')) // a genuine operator stray
    const c = reconcileAccount(dir)
    expect(c.strayTopLevel).toBe(1)
    expect(c.strayNames).toEqual(['workers'])
    for (const f of controlFiles) expect(existsSync(join(dir, f))).toBe(true)
  })

  it('counts a control-plane file as a stray when the schema has not been re-merged', () => {
    // The stated consequence of retiring the constant (Task 1902): an account
    // whose SCHEMA.md predates the declarations reports these files. setup-account.sh
    // runs the all-accounts merge on every install, so the window is one upgrade
    // wide, and the reconcile's ownedFile= lines name any account it has not reached.
    const dir = seedAccount('acct-stale-schema')
    writeFileSync(join(dir, 'session-titles.json'), '{}')
    const c = reconcileAccount(dir)
    expect(c.strayNames).toEqual(['session-titles.json'])
  })
```

Replace the seed line of the test at line 315 (`does not count the disabled store itself as a stray`) with:

```ts
    const acct = seedAccount('has-store', ['agents-disabled.json'])
```

and replace the comment above that test with:

```ts
  // The store is a platform-written account-root file declared by the admin
  // plugin (Task 1902), so a reprovisioned account carries it in its allowed
  // block. Without the declaration every account with a disabled agent reports
  // a permanent false stray on the line whose whole point is to name only what
  // an operator must act on.
```

- [ ] **Step 2: Run tests to verify the new one fails**

Run: `cd platform/services/claude-session-manager && npx vitest run src/__tests__/account-dir-schema-reconcile.test.ts`
Expected: FAIL on `counts a control-plane file as a stray when the schema has not been re-merged`, with `strayNames` empty, because `PROTECTED_TOP_LEVEL` still holds `session-titles.json`. The two rewritten tests pass already, since a declared name in the allowed block is honoured whether or not the constant also holds it.

- [ ] **Step 3: Shrink the constant**

In `platform/services/claude-session-manager/src/account-dir-schema-reconcile.ts`, replace the `PROTECTED_TOP_LEVEL` doc comment and set (lines 37-75) with:

```ts
/** The account's own machinery, which the reconcile NEVER counts as a stray
 *  whatever that account's SCHEMA.md allowed set says. provision-account-dir.sh
 *  makes the account dir a git root (`.git`) and writes its hook-enforcement
 *  settings into `.claude/`; the account's identity lives in account.json;
 *  AGENTS.md and SCHEMA.md are refreshed on every provision. An account carrying
 *  a stale, hand-edited or older-template schema must not be reported every run
 *  for machinery it is required to have.
 *
 *  Feature files at the account root are NOT here. Each is declared by the plugin
 *  whose feature owns it (Task 1902, `account-owned-files` in PLUGIN.md
 *  frontmatter), unioned into the account's allowed block by
 *  platform/scripts/lib/account-schema-owned-dirs.py, and read from there. The
 *  constant used to hold seven of them and was extended once per casualty
 *  (calendar-availability.json was Task 1877, agents-disabled.json Task 1996),
 *  which is the growth this replaces. setup-account.sh runs the all-accounts
 *  merge on every install, so a declaration reaches existing accounts, and the
 *  reconcile's ownedFile= lines name any account it has not reached. */
const PROTECTED_TOP_LEVEL = new Set([
  '.git',
  '.claude',
  QUARANTINE_DIR,
  'SCHEMA.md',
  'account.json',
  'AGENTS.md',
])
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd platform/services/claude-session-manager && npx vitest run`
Expected: all files pass. If another suite fails naming one of the seven files, that suite seeds an account schema without the declaration and needs the same `extraAllowed` treatment.

- [ ] **Step 5: Mutation check**

Add `'session-titles.json'` back to `PROTECTED_TOP_LEVEL`, re-run, and confirm `counts a control-plane file as a stray when the schema has not been re-merged` fails. Remove it and confirm the suite passes.

- [ ] **Step 6: Commit**

```bash
git add platform/services/claude-session-manager/src/account-dir-schema-reconcile.ts platform/services/claude-session-manager/src/__tests__/account-dir-schema-reconcile.test.ts
git commit -m "refactor(fs-reconcile): let declarations hold the control-plane root files"
```

---

### Task 5: Sync the doc that describes the merge

**Files:**
- Modify: `.docs/data-portal-folder-index.md:76-80`

**Interfaces:**
- Consumes: the merge behaviour from Tasks 1 and 3.
- Produces: nothing consumed by code.

`.docs/data-portal-folder-index.md` is the authoritative description of how the allowed set is built. It says the set is `fixed-seed ∪ plugin-owned ∪ ontology-buckets` and describes plugin-owned as dirs.

- [ ] **Step 1: Edit the doc**

Replace the first paragraph of the section `## The allowed set is reconciled, not appended` (the sentence beginning `Each `merge()` rebuilds`) with:

```markdown
Each `merge()` rebuilds the `allowed-top-level` list to `fixed-seed ∪
plugin-owned ∪ ontology-buckets`, so it tracks the current vertical ontology
instead of growing forever. Plugin-owned covers both kinds: a plugin declares a
directory with `account-owned-dirs` and a file with `account-owned-files`
(Task 1902), and both land as bare names in the same flat list, which already
carries `SCHEMA.md` and `account.json`. Only the descriptive region tells them
apart, by the trailing slash a directory gets and a file does not, which is what
keeps a declared file out of the `/data` page grouping and the public exposure
set. The fixed seed is the base template names plus `.quarantine`; it is
prune-protection only and never adds a missing entry.
```

- [ ] **Step 2: Verify no other doc claims dirs only**

Run: `/usr/bin/grep -rn "account-owned-dirs" .docs platform/plugins/docs README.md`
Expected: hits only in `.docs/data-portal-folder-index.md` (the sentence just written) and in spec or plan files under `platform/docs/superpowers/`, which are historical records and stay as written.

- [ ] **Step 3: Commit**

```bash
git add .docs/data-portal-folder-index.md
git commit -m "docs: the allowed set unions declared files as well as dirs"
```

---

## Verification (whole plan)

- [ ] `bash platform/scripts/__tests__/account-schema-owned-dirs.test.sh` reports `FAIL=0`.
- [ ] `cd platform/services/claude-session-manager && npx vitest run` passes.
- [ ] `cd platform/ui && npx vitest run server/lib/__tests__/account-schema-regions.test.ts server/lib/__tests__/account-root-groups-parity.test.ts` passes.
- [ ] `cd platform/services/claude-session-manager && npx tsc --noEmit` reports no errors.
- [ ] Both mutation checks (Task 1 Step 5, Task 4 Step 5) fail as described and are reverted.

## Out of scope

Reprovisioning the laptop and the two device checks that need it: task
[`2027-reprovision-laptop-accounts-after-1902-declarations.md`](../../../../.tasks/pending/2027-reprovision-laptop-accounts-after-1902-declarations.md).
Declaring `clients`, `workers`, `specs`, `templates`, `sites-staging` or `repo`,
which no shipped component writes. Relocating agent work product, which is Task
1894.
