# Task 1902 — every account-root entry is declared by the component that owns it

Design agreed 2026-07-26. Source task: `.tasks/pending/1902-account-owned-entry-declarations-are-incomplete.md`.

## The problem this removes

`account-schema-owned-dirs.py` unions each plugin's `account-owned-dirs`
declaration into the account's `SCHEMA.md` `allowed-top-level` block on provision,
and the standing reconcile
(`platform/services/claude-session-manager/src/account-dir-schema-reconcile.ts`)
names every root entry outside that block on the `op=strays` line. Three plugins
declare anything today: `storage-broker/PLUGIN.md:4` (`pages`),
`memory/PLUGIN.md:4` (`archive`), and
`premium-plugins/sitedesk/plugins/sitedesk-job/PLUGIN.md:34` (`quoting`).

Two classes of entry are outside that mechanism.

Platform-written control-plane files at the account root are kept out of the stray
count by `PROTECTED_TOP_LEVEL`, a hand-maintained constant in the reconcile that
has been extended once per casualty (`calendar-availability.json` was Task 1877,
`agents-disabled.json` was Task 1996). It holds seven such files. The mechanism
that should own them covers directories only, so no plugin can declare a file, and
`data-portal.json` was never added to either surface and reports as a stray on
every run.

Directories an agent created at an account root are in no declaration and have no
writer in the shipped tree. Since Task 1899 removed the move, each costs one
`op=strays` name per five-minute cycle rather than a broken feature. The cost is a
permanently noisy signal an operator learns to ignore, which is how the next real
stray gets missed.

## Evidence

Grep over `platform/plugins`, `platform/scripts`, `platform/services`,
`platform/templates` and `premium-plugins` for an account-root write of each
undeclared name:

- `e-sign/` has one writer: `platform/plugins/business-assistant/skills/e-sign/SKILL.md:452`
  (`mkdir -p "${ACCOUNT_DIR}/e-sign/<DOC_REF>"`), plus the persisted base and the
  per-signer stamped copies alongside it.
- `clients`, `workers`, `specs`, `templates`, `sites-staging` and `repo` have no
  writer. By the source task's own rule an entry with no creator in the tree is
  agent work product, recorded as such rather than declared.

Writers for the seven protected files and for `data-portal.json`:

| Root file | Writer | Declared on |
|---|---|---|
| `calendar-availability.json` | `plugins/scheduling/mcp/src/scripts/publish-availability.ts` | scheduling |
| `data-portal.json` | `plugins/cloudflare/bin/portal-index-push.mjs` | cloudflare |
| `wa-channel-bindings.json` | `services/claude-session-manager/src/wa-channel-store.ts` | admin |
| `telegram-channel-bindings.json` | `services/claude-session-manager/src/telegram-channel-store.ts` | admin |
| `webchat-channel-bindings.json` | `services/claude-session-manager/src/webchat-channel-store.ts` | admin |
| `canonical-webchat-session.json` | `ui/server/canonical-webchat-override.ts` | admin |
| `session-titles.json` | `services/claude-session-manager/src/account-title-stores.ts` | admin |
| `agents-disabled.json` | `ui/server/routes/admin/agents.ts` | admin |

Six of the eight are written by a shared platform service or the UI server, not by
the plugin they are declared on. The session manager and the UI server carry no
manifest the resolver reads, so their files are declared on `admin`, and the rule
is reach rather than naming: **a declaration is only present on a brand whose
payload carries the declaring plugin.** `brand.json` `plugins.excluded` drops the
directory outright (`packages/create-maxy-code/scripts/bundle.js`), and
`realagent-code` and `property-administrators` both exclude `telegram` while the
session manager still constructs `TelegramChannelStore`
(`services/claude-session-manager/src/index.ts`). Declaring
`telegram-channel-bindings.json` on the telegram plugin would therefore leave
those two brands reporting it as a stray forever, which is the failure this task
exists to remove. `admin` is excluded by no brand.

The three remaining declarations sit on the plugin whose own code writes them
(`scheduling`, `cloudflare`, `business-assistant`). That is self-consistent: when
such a plugin is excluded, its writer is excluded with it and the file is never
created, so the declaration is present exactly when the file can be.

`setup-account.sh:69` runs `reconcile_all_accounts_owned_dirs "$ACCOUNTS_DIR"` on
every install, and that sweep calls `merge_owned_dirs_into_schema` for every
account carrying an `account.json`. A declaration therefore reaches every existing
account on the next install, not only accounts provisioned after it lands.

## Units

### 1. `platform/scripts/lib/account-schema-owned-dirs.py`

`_parse_plugin_md` gains a second frontmatter key, `account-owned-files`, parsed
with the same regex-then-`json.loads` shape as `account-owned-dirs` and keyed
`file` instead of `dir`:

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

A declaration that fails to parse is skipped and the remaining declarations still
merge, matching how the dirs key already behaves (`except Exception: pass`).

`resolve` returns one list carrying both kinds, each entry gaining a `kind` field
of `dir` or `file`, so a single ordered union feeds the merge. Dirs keep their
existing position ahead of files from the same manifest.

`merge` unions declared file names into the same `allowed-top-level` block as
dirs. The block is already a flat list of names that carries `SCHEMA.md`,
`account.json` and `AGENTS.md`, and `parseAllowedTopLevel` in the reconcile
compares it against `readdirSync` entries without distinguishing files from
directories, so no consumer changes. The prune rule is unchanged: an entry the
resolver no longer targets is dropped when empty or absent and kept-and-logged
when it holds content, and `_dir_holds_files` already treats a non-empty regular
file as content.

The plugin-owned descriptive region renders a file without the trailing slash a
directory gets:

```
- `pages/` — Cloudflare Pages deploy staging for the account's public sites.
- `data-portal.json` — the account's data-portal index, written by portal-index-push.
```

`reconcile` prints `ownedFile=<name> present=<true|false>`, the file twin of the
existing `ownedDir=` line, so a declaration that has not reached an account is
visible per name.

### 2. Declarations

One frontmatter line added per plugin, in the shape the resolver parses:

- `plugins/business-assistant/PLUGIN.md` — `account-owned-dirs` gains `e-sign`.
- `plugins/cloudflare/PLUGIN.md` — `account-owned-files` gains `data-portal.json`.
- `plugins/scheduling/PLUGIN.md` — `calendar-availability.json`.
- `plugins/admin/PLUGIN.md` — `wa-channel-bindings.json`,
  `telegram-channel-bindings.json`, `webchat-channel-bindings.json`,
  `canonical-webchat-session.json`, `session-titles.json`, `agents-disabled.json`.

The resolver reads every `$PROJECT_DIR/plugins/*/PLUGIN.md` present in the
installed tree, with only premium bundles gated by `shipsPremiumBundles`. A brand
that excludes a plugin has no manifest to read, which is why the six
service-written files sit on `admin`: every brand then allows all nine names,
which is what `PROTECTED_TOP_LEVEL` did for seven of them.

### 3. `platform/services/claude-session-manager/src/account-dir-schema-reconcile.ts`

`PROTECTED_TOP_LEVEL` drops the seven file entries and keeps `.git`, `.claude`,
`.quarantine`, `SCHEMA.md`, `account.json` and `AGENTS.md`. Its comment is
rewritten to state the new division: the constant holds the account's own
machinery, which exists whatever the schema says, and every feature file is held
by the declaration that owns it.

The behavioural consequence, stated rather than hidden: those seven files are now
kept out of the stray count by the account's `SCHEMA.md`, not by a constant. An
account whose schema was never re-merged reports them. The sweep in
`setup-account.sh` closes that window on the install that ships this change, and
the `ownedFile=` reconcile line names any account it has not reached.

### 4. Tests

`platform/scripts/__tests__/account-schema-owned-dirs.test.sh` gains, on a
synthetic tree in the style of the existing shape-b case:

- a plugin declaring `account-owned-files: [{"file": "x.json", ...}]` puts `x.json`
  in the merged `allowed-top-level` block, describes it in the plugin-owned region
  without a trailing slash, and reports `ownedFile=x.json present=true` from
  `reconcile`;
- a plugin whose `account-owned-files` value is malformed JSON is skipped while a
  sibling plugin's valid declaration still merges;
- the merge stays byte-identical on a second run with a file declaration present.

**Mutation check.** Drop the files branch from the resolver and the new cases must
fail. Revert.

`platform/services/claude-session-manager/src/__tests__/account-dir-schema-reconcile.test.ts`
carries two tests that prove the code belt: `never counts platform-written
control-plane root files absent from the allowed block` and `does not count the
disabled store itself as a stray`. Both seed an account whose `SCHEMA.md` lists no
such file, which after this change is a stale account rather than a current one.
They are rewritten to seed the schema a reprovisioned account carries, listing the
declared file names, and then assert the same outcome: the control-plane files are
absent from `strayNames` and a genuine operator stray alongside them is still
counted and named. That is the surface the reconcile reads, so the rewritten tests
prove the delivered behaviour rather than the retired constant.

### 5. The region parsers and the doc

Three readers parse the plugin-owned region and each anchors on the trailing
slash inside the backticks: `platform/lib/account-schema-regions/src/index.ts:65`
(shared by the `/data` page grouping and the reconcile's parity audit) and
`platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs:42-43` (the public
data-portal exposure set, which keeps its own copy under a parity test). A file
line therefore yields no bucket in any of them, which is the required behaviour
and needs no code change. It is pinned by a case in
`platform/ui/server/lib/__tests__/account-schema-regions.test.ts`, because a
future edit relaxing that regex would put a control-plane file in front of a
client.

`.docs/data-portal-folder-index.md` is the authoritative description of how the
allowed set is built and currently describes plugin-owned as directories. It
gains the second kind and the trailing-slash rule.

## Scope boundaries

### In scope

Units 1 to 5.

### Out of scope

- Reprovisioning the laptop's accounts, and the two device checks that depend on
  it (`op=strays` naming only recorded work product, and the negative check that
  removing one declaration makes its entry reappear). Both need this change
  published and installed, and publish awaits an explicit operator command. Task
  `2027-reprovision-laptop-accounts-after-1902-declarations.md`.
- Declaring `clients`, `workers`, `specs`, `templates`, `sites-staging` or `repo`.
  No shipped component writes them, so declaring them would make the allowed list
  a record of what happens to exist rather than of what is owned. They continue to
  appear in `op=strays`, which is the correct report for debris.
- Relocating agent work product into schema buckets. Task 1894 covers the glsmith
  case.
- Teaching `fs-schema-guard.sh` to tell a declared file from an operator bucket.
  The guard reads the same `allowed-top-level` fence as an allow-list for
  `Write`/`Edit`/`NotebookEdit`, so declaring these eight files makes them
  agent-writable where they were blocked before. Measured: the same envelope
  returns exit 2 against the pre-merge schema and exit 0 against the merged one.
  The fence already permits `account.json`, `SCHEMA.md`, `secrets/` and
  `.claude/`, so it is a layout guard rather than a trust boundary, and this
  widens it rather than breaches it. Task
  `2028-fs-schema-guard-cannot-tell-a-declared-file-from-an-operator-bucket.md`.
- The reconcile's report-only posture. Task 1899 settled it.

## Observability

The `op=strays` line from Task 1899 is the instrument. A declared entry never
appears in it, so anything that does is either debris or an undeclared owner, and
the two are separated by asking what created it. The `ownedDir=` and `ownedFile=`
lines from `reconcile` are the second instrument: they name, per account, a
declaration that has not yet reached that account's `SCHEMA.md`.
