# Content types: how a service finds things it was never told about

2026-08-07. Follows `2026-08-07-the-application-model.md`, which established
System as the core and left this as the load-bearing piece.

The job: let a service act on everything a member can create, without naming
any application that creates it.

---

## Why the dependency list was misleading

Six modules declared `forum` and genuinely touched it. Reading what the touches
*are* splits them in two, and the halves want different answers:

| | Modules | What the coupling is |
|---|---|---|
| **Code** | api (4 imports), moderation (5) | They import `Modules\Forum` classes — events, `EditPolicy` |
| **Schema** | search, widget, page, realtime (0 imports) | They query `topics`, `forums` and `posts` directly |

The second group never mentions forum *code* at all. Twenty queries against
tables another module owns, which is a dependency nothing in the manifest was
describing accurately and nothing in the type system was checking.

Schema coupling is the one worth dissolving first: it is the reason Downloads
and Pages are invisible to search, and it is dissolved by inverting who asks.

## The inversion

Today search knows how to find topics. Tomorrow **a type knows how to find
itself**, and search asks everything that registered.

```php
$types->register('topic', [
    'label' => 'Topics',
    'find'  => fn (string $q, int $limit) => [ ...['title','url','meta','excerpt'] ],
]);
```

`find` is the whole contract. A type that cannot be searched omits it and is
simply never asked. Search stops importing anything, stops naming `forum`, and
gains Pages and Downloads the moment those modules register — which is the
difference between a refactor and a feature.

**The shape was already there.** `SearchController::quick()` normalises every
result to `{type, title, url, meta, excerpt}` before returning JSON. The
registry adopts that shape rather than inventing one, so the header search
becomes type-agnostic by deleting code rather than adding it.

## Scope, deliberately

**Migrated: the quick search.** It is the site-wide one, its contract already
matched, and it is where "why can't I find my download" actually bites.

**Not migrated: the full results page.** Its filters are a forum dropdown and
an author box, its template renders topic and post rows, and its SQL is
`MATCH … AGAINST` over two FULLTEXT indexes with forum scoping. Making that
generic means designing faceted search across heterogeneous types — a real
piece of work with real UI decisions, and doing it badly to hit a dependency
count would be worse than leaving it honest. `search` keeps its `forum`
dependency until then, and the manifest says something true.

**Not touched: api and moderation.** They import forum classes because they act
on topics specifically. Moderation locking a *page* is not a missing
abstraction, it is an undesigned feature. When Downloads wants moderating, the
registry grows a `moderate` capability the same way it grew `find`.

🚨 The temptation here is to build every capability at once — moderate, index,
react, follow, stream — because they are easy to imagine. Each one designed
without a second caller is a guess. `find` has three callers on day one.

## What a type is

Only what a service needs to be useful about something it has never heard of:

- **key** — `topic`, `page`, `download`. Stable; it goes in URLs and JSON.
- **label** — for people.
- **find** — optional. Given a query and a limit, returns normalised rows.

No table name, no columns, no ORM. A type that stores things somewhere unusual
— a remote index, a flat file — satisfies this as easily as one backed by a
table, which a schema-shaped registry would have prevented.

## Ordering

Registration happens in `boot()`, like `PersonalData`. Every module registers
before any of them is asked, and the registry is created in System's
`register()` so it exists first. Results come back in registration order, so
the order applications are declared is the order a person sees them — Forums
first, because that is what most sites are.
