ADR-0007: Feature-manifest substrate + composition-root strategy¶
Status¶
Accepted. Substrate landed in EPIC #2046 PR 2 (issue #2048); per-feature manifests + enforcement gates landed in issue #2149 (this PR).
Context¶
SynthOrg's pre-#2048 composition root was a 2313-line api/state.py
god-object plus a 2152-line api/app.py that wired every service. Adding
a feature required touching both centres; the side-effect was that the
"locality" the project espoused at the feature level evaporated as soon
as the wiring met the centre.
ADR-0006 (the tiered module-size policy) gated NEW central growth but could not, by itself, undo the centre. What the codebase needed was an actual seam where a feature could declare its whole surface: settings namespace, state slice, REST controllers, MCP tool contributions, lifecycle hooks, boot-constructed ("ghost-wired") symbols, and the features it depends on. The seam had to satisfy four constraints:
- One file per feature. An AI agent (or human) opens the feature's
feature.pyand learns everything about the feature. - Typed. The substrate is Pydantic; a typo in a manifest field fails at import, not at runtime. State slices are frozen so a hot-reload composes a new slice and swaps atomically.
- Discoverable. Boot walks the filesystem for
feature.pyfiles, imports each, resolves the dependency graph, and composes the slices. No central registry. - Enforced. Adding a new feature without a
feature.pyfails at pre-push. GrowingAppState.__slots__past the approved set fails. The AI-navigation indexdata/feature_index.jsonstays in sync with the manifests.
Decision¶
The substrate (shipped in #2048)¶
src/synthorg/_core/features.py exposes:
BaseFeatureStateSlice: frozen Pydantic base for per-feature state slices (frozen=True, extra="forbid", arbitrary_types_allowed=True).FeatureModule(Protocol) +FeatureManifest(frozen concrete model). Manifest fields:name,settings_namespace,state_slice,controllers,mcp_handlers,lifecycle_hooks,ghost_wired_symbols,depends_on.McpHandlerDescriptor: frozen descriptor a feature attaches undermcp_handlersto declare its MCP domain + tool names.LifecycleHook,McpHandlerModule: read-only Protocols a feature satisfies structurally.discover_features(): filesystem walk + import + dependency-order resolution. Memo-cached at module level; tests passforce=Trueto rebuild.feature_directories(): maps feature name -> repo-relative directory. Consumed by the navigation-index generator.resolve_feature_order(): pure topological sort with deterministic tiebreak (lexicographic). Cycles, duplicates, and unknown dependencies raiseFeatureDependencyError.
src/synthorg/api/state_slices.py ships AppStateSliceMixin with the
typed slice store (slice, set_slice, swap_slice, wire,
set_field_once, wire_if_field_absent, swap_field_returning_previous),
preserving the once-only / if-absent / hot-replace contracts the legacy
seams provided. compose_feature_slices(app_state) composes an empty
slice for every discovered feature at boot.
Per-feature manifests (shipped in #2149)¶
Every directory under src/synthorg/ whose state.py (or peer
*_state.py for the api-core case) declares a BaseFeatureStateSlice
subclass carries a sibling feature.py exposing a module-level
FEATURE: FeatureModule. The live tree carries 32 feature manifests,
most under top-level packages (a2a, api_core, approval, backup,
budget, client, communication, coordination, docs, engine,
facades, hr, integrations, knowledge, memory, meta,
notifications, observability, ontology, organization,
persistence, providers, research, runtime, security,
settings, telemetry, tools) and the rest nested under those
parents (meta/charter, meta/toolsmith, engine/cockpit,
engine/workspace).
Each feature.py carries # module-kind: feature on the first
non-blank line so the module-size budget gate caps it at 100 LOC.
Three new enforcement gates (shipped in #2149)¶
scripts/check_feature_manifest.pywalkssrc/synthorg/**/state.pyforBaseFeatureStateSlicesubclasses; every such directory must ship afeature.pycarrying# module-kind: feature, LOC <= 100, and a module-levelFEATUREattribute.scripts/check_no_implicit_state_attribute.pyassertsAppState.__slots__equals the gate'sAPPROVED_SLOTSfrozenset, now drained to empty (#2299):AppStateis 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), andclock/config/startup_timelive in__dict__. New state must move into a feature state slice or a primitive owner.scripts/check_feature_index_freshness.pyregeneratesdata/feature_index.json+data/codebase_map.jsonto a scratch path and asserts the committed artefacts match byte-for-byte (ignoring the volatilegenerated_atfield of the index).
Ghost-wiring parity (extended in #2149)¶
scripts/check_no_ghost_wiring.py gained a parity pass: every
ENFORCED symbol in scripts/_ghost_wiring_manifest.txt must be
claimed by exactly one feature's ghost_wired_symbols tuple, and
every claimed symbol must appear in the manifest. The manifest file
stays hand-curated for operational metadata (issue + note); manifests
own the symbol-level inventory.
Consequences¶
Positive¶
- Adding a new feature is one directory:
state.py,feature.py, controllers, MCP descriptor, settings definitions. Zero edits toapi/state.pyorapi/app.py. Locality > centrality. - AI agents read ONE
feature.pyto learn a feature's whole surface, reducing the search space from "grep the whole tree" to "read the manifest then drill in". - Three mechanical gates lock the substrate in. A new feature without a
feature.pyfails at pre-push; a newAppStateslot fails at pre-push; a stale index fails at pre-push.
Negative¶
- 32 new files (one per feature). Discoverability win, larger PR footprint.
- The ghost-wiring symbol/feature mapping is operator-curated; renaming
a symbol requires updating both the central manifest and the owning
feature's
ghost_wired_symbols. The gate catches the divergence but cannot auto-fix. - The freshness gate runs the index generator at pre-push (one extra ~10 s during pre-push). Acceptable for the locality win.
Neutral¶
- The cross-cutting mutable primitives a frozen slice cannot own
(request locks, bridge configs, WS timeouts) no longer sit as bare
AppStateslots: #2299 decomposed them into cohesive owner objects (bridge_config/per_op_limits/request_locks/ws_auth_limits), leavingAppStatea thin__slots__ = ()facade whose only__dict__identity isclock/config/startup_timeplus the background-task sets and shutdown event. - Boot still warms
synthorg.api.appbefore walkingfeature.pymodules (the latentcore.agentimport cycle resolves through that order). The generator + freshness gate include the warmup; the ghost-wiring gate avoids it by walking AST instead of importing.
Alternatives considered¶
One central registry instead of per-feature manifests¶
A src/synthorg/_core/feature_registry.py listing every feature in
one file. Rejected: the registry becomes the new central god-object;
adding a feature still requires editing the centre.
YAML / TOML manifests instead of Python¶
A feature.yaml next to each feature. Rejected: loses Pydantic type
safety (e.g. state_slice: type[BaseFeatureStateSlice]), loses
structural typing for FeatureModule, requires a parser that
duplicates what import feature already does. The 100-LOC cap keeps
the Python file readable.
Auto-discovered manifests via class-decorators¶
@feature(...) on the state-slice class itself. Rejected: hides the
feature surface inside the slice file; loses the "one file per
feature" property.
Hand-curated allowlist of feature directories in the gate¶
Instead of AST-discovering slice-bearing directories. Rejected: adding a feature requires editing the gate; the substrate's own definition (a directory with a typed state slice) is the contract; the gate consults that contract.