Feature manifest reference¶
A feature manifest is one file (feature.py) per feature directory
that declares the feature's whole surface for the codebase: settings
namespace, typed state slice, REST controllers, MCP tool contributions,
lifecycle hooks, boot-constructed (ghost-wired) symbols, and the
features it depends on. An AI agent reads this one file to learn what
a feature ships; new features add one without touching the centre.
The substrate lives at src/synthorg/_core/features.py
and is enforced by three convention gates:
check_feature_manifest.py,
check_no_implicit_state_attribute.py,
and check_feature_index_freshness.py.
The architecture decisions are recorded in
ADR-0007 and
ADR-0010.
What counts as a feature¶
A directory under src/synthorg/ is a feature exactly when one of its
*.py files (typically state.py, sometimes <name>_state.py such as
api/api_core_state.py) declares a class that inherits from
BaseFeatureStateSlice. The substrate's own definition of "feature"
is therefore "a directory with a typed state slice"; the
check_feature_manifest gate walks the tree under this rule.
A directory without a state slice is not a feature and does not need a
feature.py. The gate accepts that.
Manifest fields¶
# module-kind: feature
"""Charter feature manifest."""
from synthorg._core.features import (
FeatureManifest,
FeatureModule,
McpHandlerDescriptor,
)
from synthorg.api.controllers.charter import CharterController
from synthorg.meta.charter.state import CharterStateSlice
from synthorg.meta.mcp.domains.charter import CHARTER_TOOLS
from synthorg.settings.enums import SettingNamespace
FEATURE: FeatureModule = FeatureManifest(
name="charter",
settings_namespace=SettingNamespace.CHARTER,
state_slice=CharterStateSlice,
controllers=(CharterController,),
mcp_handlers=(
McpHandlerDescriptor(
domain="charter",
tool_names=tuple(tool.name for tool in CHARTER_TOOLS),
),
),
lifecycle_hooks=(),
ghost_wired_symbols=("CharterInterviewService", "CharterDispatcher"),
depends_on=(),
)
| Field | Type | Meaning |
|---|---|---|
name |
NotBlankStr |
Stable feature name. Must be unique across the tree. |
settings_namespace |
SettingNamespace \| None |
The SettingNamespace enum value the feature owns, or None when the feature has no operator-facing namespace. |
state_slice |
type[BaseFeatureStateSlice] \| None |
The feature's typed slice class; the empty slice composer constructs one of these at boot. |
controllers |
tuple[type[Controller], ...] |
Litestar controller classes the feature registers. |
mcp_handlers |
tuple[McpHandlerModule, ...] |
Frozen McpHandlerDescriptor instances declaring the feature's MCP domain + tool names. |
lifecycle_hooks |
tuple[LifecycleHook, ...] |
Named async startup hooks (current PR ships none directly through manifests; the field is forward-compatible for the Part-3 composition root). |
ghost_wired_symbols |
tuple[str, ...] |
Boot-constructed class / factory names the feature owns. Must match scripts/_ghost_wiring_manifest.txt exactly at the symbol level. |
depends_on |
tuple[str, ...] |
Names of other features this feature depends on. The resolver loads dependencies first. |
Header + LOC ceiling¶
Every feature.py carries # module-kind: feature on the first
non-blank line (the tier header the module-size budget gate reads).
The feature tier caps LOC at 100; a manifest larger than that is a
sign the feature is doing real work in the manifest, which is the
wrong place. Move logic into the feature's services / factory / state
modules and keep the manifest declarative.
Three convention gates¶
check_feature_manifest.py¶
For every slice-bearing directory, asserts:
- a sibling
feature.pyexists; - the first non-blank line is
# module-kind: feature; - LOC <= 100 (the
featuretier cap); - a module-level
FEATUREattribute is declared.
Deleting any feature.py fails the gate. Adding a new feature
without one fails the gate. There is no baseline (the contract is
absolute).
check_no_implicit_state_attribute.py¶
Parses src/synthorg/api/state.py for AppState.__slots__ and
asserts it equals the gate's APPROVED_SLOTS frozenset, which is now
empty: AppState is a thin __slots__ = () facade. The
cross-cutting mutable primitives a frozen slice cannot own live on
cohesive owner objects composed onto it (bridge_config /
per_op_limits / request_locks / ws_auth_limits); clock /
config / startup_time live in __dict__.
Declaring any direct slot on AppState fails the gate; the
contributor either:
- moves the new state into a feature state slice
(
BaseFeatureStateSlicesubclass), or - adds it to a primitive owner object (or composes a new one).
check_feature_index_freshness.py¶
Regenerates data/feature_index.json + data/codebase_map.json to a
scratch path and asserts the committed files match byte-for-byte
(ignoring the volatile generated_at timestamp). Missing files are
fail-closed. To fix a freshness failure:
uv run python scripts/generate_feature_index.py
git add data/feature_index.json data/codebase_map.json
Ghost-wiring symbol claims¶
scripts/check_no_ghost_wiring.py performs symbol-level parity
between scripts/_ghost_wiring_manifest.txt (state + operational
metadata) and the union of every feature.py's ghost_wired_symbols.
Every ENFORCED symbol in the manifest must be claimed by exactly one
feature; every claimed symbol must appear in the manifest. The
manifest stays the canonical place for the issue + note text (it is
operational, not architectural).
Claims follow the defining-module rule: a symbol's claim goes to the
feature that owns its definition, not the feature that constructs it
at boot. For example, build_coordinator is defined in
engine/coordination/factory.py and called from
workers.runtime_builder; the claim is on the engine manifest.
The parity check is AST-driven, so ghost_wired_symbols=(...) MUST
hold string literals, not module-level constants. Writing
ghost_wired_symbols=(MY_SYM,) (a Name node) silently drops the
claim from the gate's union; the gate would then incorrectly flag
the symbol as orphan. Always use string literals.
Discovering features at runtime¶
synthorg._core.features.discover_features() walks the filesystem for
feature.py modules, imports each, and returns them in dependency
order (independents sorted by name; cycles + duplicates raise
FeatureDependencyError). The result is cached at module level;
the index generator and the freshness gate pass force=True to
rebuild.
feature_directories() maps feature name -> repo-relative directory
(e.g. charter -> src/synthorg/meta/charter). The navigation-index
generator consumes this mapping to assemble per-feature surfaces and
to assign owning_feature in the codebase map.
Querying the index via MCP¶
The synthorg_meta_query_feature_map MCP tool returns the same
FeatureIndex shape carried in data/feature_index.json. Pass an
optional name argument to filter to a single feature:
The handler builds the index in-memory from discover_features() per
call (so the result reflects the running tree, not a possibly stale
artefact on disk).
Adding a new feature¶
- Create the directory under
src/synthorg/<feature_name>/. - Add
state.pyexposing a<Name>StateSlice(BaseFeatureStateSlice). - (Optional) Add controllers under
src/synthorg/api/controllers/. - (Optional) Add MCP domain + handler under
meta/mcp/domains/+meta/mcp/handlers/. - (Optional) Register a new
SettingNamespacevalue + addsrc/synthorg/settings/definitions/<name>.py. - Add
feature.pycarrying# module-kind: featureand theFEATUREmanifest with the correct fields. - Regenerate the navigation index:
uv run python scripts/generate_feature_index.py. - If your feature owns new boot-constructed symbols, add them to the
manifest's
ghost_wired_symbolsAND toscripts/_ghost_wiring_manifest.txt.
The three gates plus the ghost-wiring parity check guarantee that any missing step fails at pre-push.
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
missing feature.py (slice-bearing directory needs a feature manifest) |
New state slice without a manifest. | Add feature.py per the guide above. |
missing '# module-kind: feature' header on first non-blank line |
Module docstring or other content above the header. | Move the header to be the first non-blank, non-shebang line. |
LOC <n> exceeds feature-tier cap 100 |
Logic crept into the manifest. | Move logic out into the feature's services / factory modules; keep feature.py declarative. |
missing module-level FEATURE attribute |
Typo in the attribute name, or FEATURE set inside if __name__ == "__main__":. |
Assign FEATURE: FeatureModule = FeatureManifest(...) at module top level. |
data/feature_index.json is stale |
feature.py changed; index was not regenerated. |
Run uv run python scripts/generate_feature_index.py and commit. |
AppState.__slots__ must stay empty |
A slot was declared on the thin AppState facade instead of a slice or primitive owner. |
Move the field into a BaseFeatureStateSlice subclass, or onto a primitive owner object (bridge_config / per_op_limits / request_locks / ws_auth_limits). |
ghost-wiring parity: ENFORCED symbols missing from every feature.py |
New manifest entry without a feature claim. | Add the symbol to the owning feature's ghost_wired_symbols. |
ghost-wiring parity: symbols claimed by a feature.py but missing from manifest |
Feature claims a symbol that was removed from the central manifest. | Either restore the manifest entry or remove the feature's claim, depending on intent. |