# Layering audit — substrate vs. catalog/woocode

Origin: 2026-05-08. Last revalidated: 2026-05-18.

The rule (`AGENTS.md`):

> Core must stay catalog-agnostic and client-agnostic. … User-visible behavior
> belongs in woocode catalogs. Verbs such as `look`, `examine`, `who`, `join`,
> `help`, `say`, `take`, `drop`, and command parser conventions are
> catalog/superstructure behavior, even when they currently need a native
> helper because the DSL or VM cannot express part of the mechanism.

This file lists places where substrate code (`src/core/`, `src/client/`,
`src/worker/`, `src/server/`) still knows about user-visible commands,
specific in-world classes, or particular catalogs. Numbering kept stable
across revisions; new items get new numbers, retired items are crossed
out rather than renumbered.

Conventions: `(done)`, `(in progress)`, `(deferred)`, blank = open.
File:line references are best-effort against the head at revalidation.

---

## Tier 1 — pure superstructure (legacy LambdaCore surfaces)

### 1. Player surface  (partial)

`world.ts:9275–9441` — `playerJoin`, `connectedPlayerRefs`,
`playerSessionStats`, `matchPlayerForCommand`, `obviousCommandVerbs`,
`obviousCommandSyntaxes`, `formatCommandSyntax`, `formatVerbNameForExamine`.

Status:
- `:look_self`, `:huh`, `:inventory`, `:home`, `:who_all`, `:ways`,
  `:examine_detailed`, `:tell`, `:tell_lines`, `:help` are now
  `sourceVerb` on `$player` (or `$actor`/`$root`) per
  `spec/semantics/bootstrap.md §B2.7`. Bodies are interpreted
  woocode rather than TS.
- Still native, with TS handlers in `world.ts`:
  - `:join_player` → `playerJoin` (uses `matchPlayerForCommand` which
    iterates every object looking for `$player` descendants by name
    or alias — a global enumeration; the cross-host primitive needs
    to land before this can go woocode).
  - `playerSessionStats`, `sessionMetadataForActor` (consumed by
    `:who_all`; the `is_connected`, `idle_seconds`, `session_meta`
    builtins are the woocode-facing surface, so the TS code is just
    plumbing — fine to keep).
  - `obviousCommandVerbs` / `formatCommandSyntax` — feed
    `examine_detailed`'s "obvious commands" block. This walks local
    ancestry and reads `arg_spec.command` maps. Should be a generic
    builtin (`obvious_command_verbs(target, actor?)`) that the
    catalog/bootstrap verb calls, rather than a typed method.

Next: pull `obviousCommandVerbs`/`formatCommandSyntax` behind a
`obvious_command_verbs` substrate builtin (catalog-agnostic, returns
verb metadata); make `:join_player` a sourceVerb once the cross-host
player lookup primitive is exposed. Keep `playerSessionStats` as
internal plumbing.

### 2. Help DB directive interpreter

`world.ts` around 5589/5623 — `*pass*`, `*forward*`, `*objectdoc*`,
`*verbdoc*` directives are still interpreted by the `help_db_get_topic`
native handler (registered at 8886).

Status: unchanged. `catalogs/help/` exists and `$player:help` is now
woocode that calls into the DB, but the directive interpreter is still
TS. Move into `$help_db:get_topic` and friends as ordinary woocode;
strip the native at the end.

### 3. Room/look composition  (mostly done)

`composeRoomLook`, `spaceLookSelf`, `roomWho`, `lookEntryFor`,
`defaultLookSelf`, `chatPresent` — these have been removed or reduced
to internal helpers.

- `:look_self` on `$root`/`$actor`/`$player` is sourceVerb.
- `$thing:look` is sourceVerb.
- `titleForLook` (`world.ts:9443`) survives as a cross-host helper
  that avoids the host-queue-re-entrancy deadlock by reading the
  `name` property directly when the item lives on a remote host.
  Acceptable as a substrate primitive; remove when host-queue
  re-entrancy or durable awaiting-call parking lands.
- `chatPresent` / `chatPresentAsync` (`world.ts:8891–`) — still
  filter contents by `$player` ancestry. Used for presence
  scrubbing. Should be a generic `presence_subscribers(space)`
  primitive on the host bridge, not an `inheritsFrom("$player")`
  check baked into core.

---

## Tier 2 — `$match` (the spec says woocode; the bodies say TS)

### 4. Command parser body

`spec/semantics/match.md` is "implemented". `catalogs/chat/manifest.json`
declares `$match` with verbs `match_object`, `match_verb`,
`match_command_verb`, `plan_command`, `parse_command`. **Every body is
a stub (`return $failed_match;` / `return null;`) and the `implementation`
block points at a native handler** registered in `world.ts`.

The real implementations are still in `world.ts` (~9613–9950):
`parseCommandMap`, `parseTokens`, prep table, `commandPatternMatches`,
`commandPrepMatches`, `commandSlotMatches`, `commandArgsFrom`,
`resolveCommandPlan`, `commandVerbCandidates`.

Status: unchanged. Highest-leverage substrate cleanup. Push the bodies
into `catalogs/chat`; keep only the `command_verb_candidates` cross-host
lookup as a host-bridge primitive (it already is one — `hostBridge.commandVerbCandidates`
at `world.ts:226`).

### 5. Speech-prefix sugar

`lowerSpeechPrefixPlan` (`world.ts:9539–9601`). Still completely
hardcoded: `/me`, `:`, `]`, `|`, `<`, `"`, `;`, `;;`, `` ` ``, `/tell`,
`[style]`. Each prefix maps to a verb name (`emote`, `pose`, `quote`,
`self`, `say`, `eval`, `say_to`, `tell`, `say_as`).

Status: unchanged. The cleanest fix is a `$system.speech_prefixes` map
(or a chat-catalog property) plus `$match:plan_command` consulting it,
with the substrate exposing only an enqueueing primitive. The current
behavior is *also* hardcoded in `notes/<not migrated>` rather than
spec'd anywhere — the conversion needs a spec note in
`spec/semantics/match.md` first.

---

## Tier 3 — surfaces already named like catalogs

### 6. Builder / Programmer / Editor  (in progress)

`world.ts:3994–4660` — `builderCreateObject`, `builderChparent`,
`programmerInstallVerb`, `programmerEval`, `programmerListVerb`,
`programmerResolveVerb`, `editorInvoke`, `editorDryRun`, `editorSave`,
`editorPause`, `editorAbort`, plus `assertBuilderActor` and
`assertProgrammerActor`.

Progress since 2026-05-08:
- `builderSetProperty`, `builderInspect`, `builderSearch`,
  `programmerSetVerbInfo`, `programmerSetPropertyInfo`,
  `programmerTrace`, `programmerInspect`, `programmerSearch` — all
  removed. Catalog code now reaches the substrate via
  `authoring_inspect`, `authoring_search`, `verb_info`, `verb_code`,
  `property_info`, `add_property`, `delete_property`, plus `SET_PROP`
  bytecode. See the BUILTIN_NAMES tombstone in `tiny-vm.ts`.
- `programmerInstallVerb` and `programmerListVerb` were demoted from
  catalog-callable builtins. They survive as internal helpers for the
  editor session machinery (`editorInvoke`/`editorDryRun`/`editorSave`).
- Surface methods now take a `surfaceClass: ObjRef` arg — an explicit
  admission that authorization comes from the catalog class
  ($builder/$programmer/$editor), not from substrate identity.

Remaining: editor session machinery (~150 lines) is still substrate
code. The editor *protocol* (open/dry-run/save/pause/abort) feels like
a catalog concern but the session state is tightly coupled to verb
install. Decide whether `$editor` owns the session as a real in-world
object before moving further.

### 7. Guest lifecycle

`world.ts:7173, 7289, 7300, 7320, 7453, 8254, 8517–8542` —
`rebuildGuestPool`, `resetGuestOnDisconnect`, `guestInventoryFallback`,
`returnGuest`, `allocateGuest`, plus the `guest_on_disfunc` /
`return_guest` natives.

Status: unchanged. Branches on `$guest`/`$player` parentage, hardcodes
`guest_N` IDs (also at `bootstrap.ts:1211`). Should be a guest catalog
that owns the pool and the disfunc handler. Aligns with the rule that
core must not depend on bundled-catalog objects.

---

## Tier 4 — symbolic / cheap

### 8. Default title/look_self  ✅ done

`look_self` is sourceVerb on `$root`/`$actor`/`$player`; `title` is
sourceVerb on `$root`; `describe`/`set_description` are sourceVerb.
`THING_LOOK_SOURCE` is sourceVerb.

### 9. thing_look / thing_moveto / default_title  (partial)

- `thing_look` → done (sourceVerb).
- `default_title` → done (sourceVerb on `$root`).
- `thing_moveto` → still native (`bootstrap.ts:1173`). The body is
  literally `return moveto(this, target);` — trivially expressible as
  sourceVerb. The reason it's native is the `moveto(this, ...)`
  re-entry marker in `world.ts:movetoChecked` (~3894), which lets the
  default chain fall through when a `:moveto` verb calls back. Confirm
  the sourceVerb path goes through the same marker before flipping.

### 10. Reserved-class list

`world.ts:4298–4313` — `assertNotReservedForRecycle` carries the
literal set `["$system", "$nowhere", "$root", "$actor", "$player",
"$wiz", "$sequenced_log", "$space", "$thing"]`. Status: unchanged.
Should be `$system.reserved_classes` data, walked at install time so
bundled catalogs can extend it.

---

## Tier 5 — client (new since 2026-05-08)

The browser client (`src/client/main.ts`, `src/client/framework.ts`)
has accumulated catalog-specific knowledge that violates the
client-agnostic rule. Some of this was always there; the framework
work in `c169972` (2026-05-10) moved per-catalog observation reducers
into catalog UI modules, but client-side tab/route knowledge stayed
behind.

### 11. Hardcoded tool-tab definitions

`src/client/main.ts:198–260` — `TOOL_TAB_DEFINITIONS` enumerates
dubspace / pinboard / tasks / outliner tabs with hardcoded
`classRef`, `frameComponent`, `viewAliases`, `mount`/`enter`/`leave`
callbacks. Each tab additionally has its own `bind*`/`enter*`/
`leave*` functions wired by name (~2736, 2742, 2766, 4077, 4089, 4095).

Catalog manifests already declare `frames`, `components`,
`observation_handlers`, `chat_formatters`. They do **not** declare
tabs. The client core picks a closed set of catalogs and wires them
explicitly. Adding a new tool tab requires editing core client code.

Next: extend the manifest UI block with a `tabs` declaration
(label, route alias, frame id, subject class, enter/leave verb names);
have `installBundledCatalogUi` register tabs the same way it registers
observation handlers and chat formatters. The `bind*`/`enter*`/`leave*`
functions become handlers the catalog UI module exports.

### 12. Hardcoded observation predicates

`src/client/main.ts:2889–2944` —
- `isDubspaceObservation`,
- `isPinboardObservation`,
- `isOutlinerObservation`,
- `isPinboardViewportObservation`,
- `pinboardObservationNeedsNotesRefresh`.

Each enumerates specific observation `type` strings. `isChatObservation`
was already migrated to `ui.chatFormatters.isChatType` (registry-driven).
The remaining predicates are the same pattern: they drive
catalog-specific routing in the main observation dispatch (~2150–2200)
and the snapshot pipeline (~590).

Next: replace each with a registry on `ObservationRegistry` (or a
dedicated catalog-observation router) so the catalog UI module
declares which types belong to it. The `pinboard_viewport` predicate
and `pinboardObservationNeedsNotesRefresh` are both pinboard-internal —
those can move into the pinboard UI module as soon as the registry
shape supports "tell me when these obs arrive."

### 13. Catalog-specific reducers in core observation handlers

`src/client/framework.ts:925–1022` — `registerCoreObservationHandlers`
still owns:
- `note_edited` / `note_writers_changed` → patch canonical
  `obj.props.{text,writers}`. Belongs to a `$note` catalog UI module
  (catalog exists at `catalogs/note/ui/note-chat.ts` but only
  registers chat formatters — extend it to own these reducers).
- `block_data` → patches `obj.props.{name}` on a block. Belongs to
  `catalogs/block/ui/block.ts`.
- `control_changed` / `gesture_progress` → patches
  `obj.props.{name}` on a dubspace control target. Belongs to
  `catalogs/dubspace/ui/dubspace-workspace.ts` (the rest of dubspace's
  reducers already live there).

`taken` / `dropped` / `property_changed` / `value_changed` are
legitimately substrate-level (any `$thing` can be taken/dropped, any
property can change). Keep those in core.

---

## Tier 6 — sub-substrate role checks  (new since 2026-05-08)

44 `inheritsFrom("$...")` calls in `world.ts`. Most are substrate-
level roles (`$root`, `$thing`, `$container`, `$actor`, `$prog`,
`$builder`, `$wiz`, `$system`) — those are appropriate. The
exceptions:

### 14. `inheritsFrom("$block")` in actor_focus

`world.ts:8772` — `actor_focus` rejects "focusing another actor"
unless the target inherits from `$block`. That's catalog (block)
knowledge inside a core native. The right shape is a
`can_be_focused_by(actor)` verb dispatched on the target; the native
just checks the verb result.

### 15. `inheritsFrom("$guest")` in session reset

`world.ts:7278, 7454` (`maybeReapSession`, `returnGuest`) — entire
guest reset path branches on `$guest`. Moves with item 7 once
$guest catalog exists.

### 16. `Guest N` display name pattern

`world.ts:8263` and `bootstrap.ts:1211` — both mint guest objects
with `name: \`Guest ${i}\``. Moves with item 7.

---

## Priority for the next pass

By bang-for-buck (lines moved + future-proofing):

1. **#4 — $match bodies.** Biggest single block (~400 lines in
   `world.ts`), spec already says "ordinary verbs", catalog is
   waiting. The hardest part (cross-host `command_verb_candidates`)
   is already a host-bridge primitive.
2. **#11 — TOOL_TAB_DEFINITIONS manifest-driven.** Unblocks adding
   new tool tabs from catalogs without editing core client. Pairs
   well with #12 and #13.
3. **#7 — Guest catalog.** Knocks out items 7, 15, 16 simultaneously.
   Modest lines, but removes the only remaining "core depends on a
   bundled-catalog identity" cluster.
4. **#2 — Help DB directive interpreter.** Smallest of the Tier 1
   items, ~150 lines, mechanical.
5. **#5 — Speech prefixes.** Needs a spec note first
   (`spec/semantics/match.md`), then a `$system.speech_prefixes` map.
6. **#13 — Catalog reducers in core handlers.** Small, mechanical;
   pull `note_edited`/`note_writers_changed` into note catalog,
   `block_data` into block catalog, `control_changed`/`gesture_progress`
   into dubspace.

Lower priority:
- **#1 player surface remnants** — `obvious_command_verbs` builtin
  is the path; `:join_player` waits on a cross-host player lookup.
- **#6 builder/programmer/editor session machinery** — needs a
  decision on whether `$editor` is a real catalog object before
  more code can move.
- **#10 reserved-class list** — five-minute change, no behavioral
  win.

Deferred:
- **#3 cross-host title fallback** — leave until host-queue
  re-entrancy lands.
- **#9 thing_moveto sourceVerb** — leave until the re-entry marker
  is confirmed compatible with sourceVerb dispatch.
