docs(agent-core-dev): rename Core scope to App and drop Turn tier

- replace `Core` with `App` across the skill docs and dep-graph.mjs
- drop the `Turn` scope, collapsing the four-tier tree to three (App/Session/Agent)
- update examples, anti-patterns, banned entity-service names, and createCoreScope -> createAppScope
This commit is contained in:
haozhe.yang 2026-07-01 11:18:46 +08:00
parent cd47ef4aff
commit 1745ea074a
14 changed files with 42 additions and 50 deletions

View file

@ -39,7 +39,7 @@ End-to-end procedures that span the stages. Reach for these before reading the s
- Topic: [Edge exposure — `resource:action` + WS events](edge-exposure.md) — which Services are exposed over `/api/v2` (per-scope action map) and which events stream over WS; what to wrap in a facade.
- [Stage 3 — Implement](implement.md): the standard Service recipe and the DI building blocks — interface + identity, constructor injection, scoped registration, `Disposable`, eager vs delayed, `invokeFunction`, `createInstance`, child scopes, and the cycle-refactor playbook.
- Topic: [Service authoring](service-authoring.md) — file layout, naming, contract vs impl contents, interface style, constructor/field conventions, events, multi-Service domains, comment rules.
- Topic: [Config](config.md) — the section-registry model, Core vs Session split, owning a config section, the TOML format, and the env overlay.
- Topic: [Config](config.md) — the section-registry model, App vs Session split, owning a config section, the TOML format, and the env overlay.
- Topic: [Errors](errors.md) — co-located `XxxError`, the central code registry, wire serialization, boundary translation.
- Topic: [Flags](flags.md) — `FLAG_DEFINITIONS`, `IFlagService.enabled(id)`, the `[experimental]` config section, resolution precedence.
- Topic: [Permission](permission.md) — composable chain-of-responsibility kernel, policy registry + composer, `modes`/`agentTypes` metadata, `resolveExecution`/`accesses`.
@ -61,7 +61,7 @@ Invariants that hold across every stage. Each is expanded in the stage file note
4. Parent scope never depends on child scope — short-lived may inject long-lived, never the reverse. (orient.md)
5. No cyclic dependencies — refactor (extract a third Service / use an event / re-scope); do not break the cycle with `Delayed`. (design.md, implement.md)
6. `ServicesAccessor` is valid only during `invokeFunction` — never stash it for async use. (implement.md)
7. Scope follows state identity — no `Map<sessionId, …>` at `Core` to fake per-session state. (design.md)
7. Scope follows state identity — no `Map<sessionId, …>` at `App` to fake per-session state. (design.md)
8. Foundational layers never know upstream ones; business code never depends on the edge layer (`gateway`/`rpc`). (design.md)
9. Throw coded errors; register codes centrally; branch on `code` across the wire, never `instanceof`. (errors.md)
10. Gate unreleased behavior behind a `FLAG_DEFINITIONS` flag; no ad-hoc env toggles. (flags.md)

View file

@ -6,7 +6,7 @@ Use this when the task is "move feature X from v1 to v2", "port `IXxxService` to
## The one-paragraph mental model
v1 is a **VSCode-style singleton container**: services self-register with `registerSingleton`, resolve as singleton-per-container, and have no explicit lifetime tier — so a single `ISessionService` / `IToolService` tends to accumulate global, per-session, and per-agent state in one class. v2 is a **DI × Scope tree**: every service binds to one of `Core` / `Session` / `Agent` / `Turn`, and a domain with state at several lifetimes is split into several Services. Porting is therefore **not** a file copy — it is "find each lifetime of state hiding in the v1 class, give each its own v2 Service at the right scope, then re-wire the dependencies".
v1 is a **VSCode-style singleton container**: services self-register with `registerSingleton`, resolve as singleton-per-container, and have no explicit lifetime tier — so a single `ISessionService` / `IToolService` tends to accumulate global, per-session, and per-agent state in one class. v2 is a **DI × Scope tree**: every service binds to one of `App` / `Session` / `Agent`, and a domain with state at several lifetimes is split into several Services. Porting is therefore **not** a file copy — it is "find each lifetime of state hiding in the v1 class, give each its own v2 Service at the right scope, then re-wire the dependencies".
## v1 → v2 at a glance
@ -14,7 +14,7 @@ v1 is a **VSCode-style singleton container**: services self-register with `regis
|---|---|---|
| Registration | `registerSingleton(IX, X, InstantiationType.Delayed)` | `registerScopedService(LifecycleScope.X, IX, X, InstantiationType.Delayed, 'domain')` |
| DI import | `from '../../di'` | `from '#/_base/di/scope'` / `'#/_base/di/instantiation'` / `'#/_base/di/extensions'` / `'#/_base/di/lifecycle'` |
| Lifetime | implicit singleton-per-container | explicit `LifecycleScope` (Core/Session/Agent/Turn) — see orient.md |
| Lifetime | implicit singleton-per-container | explicit `LifecycleScope` (App/Session/Agent) — see orient.md |
| Domain granularity | coarse (`session`, `tool`, `loop`) | fine, split by scope + responsibility |
| Test import | `from '@moonshot-ai/agent-core/di/test'` | `from '#/_base/di/test'` |
| Resolve SUT in tests | `ix.createInstance(Impl)` (common) | `ix.get(IX)` by interface — see test.md |
@ -40,7 +40,7 @@ Actions:
- Locate the v1 entry: contract (`<domain>/<domain>.ts`) + impl (`<domain>/<domain>Service.ts`), plus any helpers under the same folder.
- Inventory three things from the impl:
- **State** — every field / `Map` / cache the class holds. For each, note its *identity* (global? keyed by `sessionId`? by `agentId`? by `turnId`?).
- **State** — every field / `Map` / cache the class holds. For each, note its *identity* (global? keyed by `sessionId`? by `agentId`?).
- **Behavior** — every public method; group them by which state they touch.
- **Dependencies** — every `@IFoo` constructor injection and every cross-domain relative import (`from '../<other>/...'`).
- Note the v1 registration line (`registerSingleton(...)`) and any `services.set(IX, ...)` overrides at bootstrap (these reveal runtime static args or prebuilt instances the port must preserve).
@ -53,13 +53,13 @@ Do not start splitting yet — an accurate inventory prevents the common mistake
Method — for each piece of state from the inventory, ask:
1. **What is it keyed by?** nothing → a global unit; `sessionId` → a per-session unit; `agentId` → a per-agent unit; `turnId` → a per-turn unit.
2. **When should it die?** with the process / the session / the agent / the turn. State that must outlive its neighbors is a different unit.
1. **What is it keyed by?** nothing → a global unit; `sessionId` → a per-session unit; `agentId` → a per-agent unit.
2. **When should it die?** with the process / the session / the agent. State that must outlive its neighbors is a different unit.
3. **Which methods touch only this state?** they travel with the unit.
Worked example — v1 `ISessionService` (one class, ~600 lines) holds:
- a global index of all sessions → **global** unit → v2 `sessionStore` (`ISessionStore`, Core);
- a global index of all sessions → **global** unit → v2 `sessionStore` (`ISessionStore`, App);
- this session's metadata → **per-session** unit → v2 `sessionMetaStore` (`ISessionMetaStore`, Session);
- this session's activity / status → **per-session** unit → v2 `session-activity`;
- this session's context projection → **per-session** unit → v2 `session-context`;
@ -118,8 +118,8 @@ When the table says "verify", or when v1 and v2 have diverged, **read the v2 `sr
For each semantic unit, fix its `LifecycleScope` from the identity you found in step 2. Follow design.md §2 verbatim:
- global → `Core`; per `sessionId``Session`; per `agentId``Agent`; per `turnId``Turn`.
- Stateless unit → default to `Core`, pulled down only by a shorter-lived dependency.
- global → `App`; per `sessionId``Session`; per `agentId``Agent`.
- Stateless unit → default to `App`, pulled down only by a shorter-lived dependency.
- Self-check: "when this scope is disposed, should this state disappear with it?"
This is the decision v1 never had to make — get it right before writing any v2 code, because the scope is fixed at registration and changing it later ripples through every consumer.
@ -129,17 +129,17 @@ This is the decision v1 never had to make — get it right before writing any v2
Decide the Service shape per unit, following design.md §3:
- A unit that owns **one instance's** state → a single per-instance Service (`ISessionXxx` / `IAgentXxx`).
- A unit that owns a **global view plus per-instance** state → split into a `Core` registry/factory (`XxxStore` / `XxxRegistry` / `XxxCatalog`) **and** a per-instance Service. The `Core` half creates or locates the per-instance half.
- A unit that owns a **global view plus per-instance** state → split into an `App` registry/factory (`XxxStore` / `XxxRegistry` / `XxxCatalog`) **and** a per-instance Service. The `App` half creates or locates the per-instance half.
- Do not pre-split a unit that has state at only one lifetime.
Most consumers inject the per-instance Service; inject the `Core` factory only for genuine cross-instance management.
Most consumers inject the per-instance Service; inject the `App` factory only for genuine cross-instance management.
### 6. Direct dependencies
Re-wire the dependencies you inventoried in step 1, now across the new v2 Services. Follow design.md §4§5:
- **Calling style** — need a result / I orchestrate → direct call (`@IX` injection); stating a fact → event; ordered participation that may veto → hook.
- **Scope direction** — a Service may inject only its own scope or an ancestor. If a `Core` Service needs something from a `Session` Service, the dependency is backwards: re-scope or invert into an event.
- **Scope direction** — a Service may inject only its own scope or an ancestor. If an `App` Service needs something from a `Session` Service, the dependency is backwards: re-scope or invert into an event.
- **Domain direction** — foundational layers must not know upstream ones. A cycle means a v1 relative import is now pointing the wrong way; extract a third Service or invert the notification into an event.
- **Durable facts** — state changes that must be recorded / replayed / projected across agents go on the wire (`wireRecord`), not a direct call alone.
@ -188,7 +188,7 @@ import { KimiError, type ErrorCode } from '#/_base/errors';
Red lines:
- Do not copy a v1 file and "fix imports". Re-split first (steps 26); a straight copy carries v1's implicit-singleton assumptions into v2 and creates the `Map<sessionId, …>`-at-`Core` anti-pattern.
- Do not copy a v1 file and "fix imports". Re-split first (steps 26); a straight copy carries v1's implicit-singleton assumptions into v2 and creates the `Map<sessionId, …>`-at-`App` anti-pattern.
- Do not leave v1 relative imports (`from '../x/...'`) in v2 — use the `#/...` alias and respect the domain layers.
- Do not preserve a v1 behavior just because it exists; if the split reveals it was a workaround for the missing scope tree, drop it.
@ -218,7 +218,7 @@ const svc = ix.get(IXxxService);
Before submitting a port:
- [ ] Every piece of v1 state landed in a v2 Service whose scope matches its identity (no `Map<sessionId, …>` at `Core`).
- [ ] Every piece of v1 state landed in a v2 Service whose scope matches its identity (no `Map<sessionId, …>` at `App`).
- [ ] Each v1 dependency now points in the right scope and domain direction; `lint:domain` passes.
- [ ] Registrations use `registerScopedService` with an explicit scope and domain name; no `registerSingleton` remains.
- [ ] Imports use the `#/...` alias; no v1 relative (`../../di`, `../../errors`) imports remain.

View file

@ -1,6 +1,6 @@
# Topic — Config
How the `config` domain works and how a domain owns its configuration section. Covers the section-registry model, the Core vs Session split, the TOML on-disk format, and the recipe for adding or migrating a config section.
How the `config` domain works and how a domain owns its configuration section. Covers the section-registry model, the App vs Session split, the TOML on-disk format, and the recipe for adding or migrating a config section.
The `config` domain is a thin registry + loader: it does **not** know the shape of any individual section. Each domain owns the schema (and, where needed, the TOML transform) for the config it consumes, registers the section into `IConfigRegistry`, and reads it through `IConfigService`. There is no whole-config object passed around.
@ -96,7 +96,7 @@ A domain that owns a section keeps the schema in its own `configSection.ts` (e.g
## Scope
- `IConfigRegistry` / `IConfigService`**Core** scope, process-global. One registry of sections; one loader reading `~/.kimi-code/config.toml` (path from `IBootstrapService.configPath`).
- `IConfigRegistry` / `IConfigService`**App** scope, process-global. One registry of sections; one loader reading `~/.kimi-code/config.toml` (path from `IBootstrapService.configPath`).
All config reads go through `IConfigService` (global config). Per-session runtime state (active model, thinking level, etc.) lives in the owning Session-scoped service (e.g. `IProfileService`), not in `config`.
@ -253,7 +253,7 @@ When `KIMI_MODEL_NAME` is set, the `provider` domain's `kimiModelEnvOverlay` (`s
- `config` is **L2**. Domains that own sections import `config` (for `IConfigRegistry` / `IConfigService`) and must be at L2 or higher; lower layers need an entry in `ALLOWED_EXCEPTIONS` (e.g. `kosong>config`, `kosong>provider`).
- Cross-domain type sharing for a config type may need an exception too (e.g. `plugin>mcp` for `McpServerConfig`). Prefer importing the type from the owning domain over re-declaring it.
- `IConfigRegistry` / `IConfigService` are **Core**. Agent / Turn scope services may inject Core services via ancestor lookup.
- `IConfigRegistry` / `IConfigService` are **App**. Agent scope services may inject App services via ancestor lookup.
- `config` never imports a higher domain and holds no section schemas of its own; if a section needs a type from another domain, that schema lives in that domain.
## Red lines (this topic)

View file

@ -22,7 +22,6 @@ A Service = a bundle of **state** + a set of **behaviors**, bound to a **lifetim
| `App` | none (single global instance) | the process |
| `Session` | `sessionId` | one session |
| `Agent` | `agentId` | one agent |
| `Turn` | `turnId` | one turn |
### Decision tree
@ -36,7 +35,6 @@ A Service = a bundle of **state** + a set of **behaviors**, bound to a **lifetim
- one global instance → **`App`**
- one per session → **`Session`**
- one per agent → **`Agent`**
- one per turn → **`Turn`**
- a mix (a global registry *and* per-instance state) → **split it** (see §3).
**Q3 (stateless). What is the shortest-lived dependency it must inject?**
@ -45,7 +43,7 @@ A stateless Service is pulled *down* by its shortest-lived dependency: if it inj
### The core anti-pattern (a litmus test)
> **Do not store per-session state in a `Map<sessionId, …>` inside a `App` Service.**
> **Do not store per-session state in a `Map<sessionId, …>` inside an `App` Service.**
This is the tell-tale sign of "should have been `Session`-scoped but was parked at `App`". Consequences: nobody cleans the entry up when the session ends (leak); every consumer threads `sessionId` around (loss of type safety); it cannot inject `Session`/`Agent`-scoped collaborators.
@ -187,8 +185,7 @@ domain: `<name>` (owning scope: <Scope>)
├─ exposes (interfaces I provide, by scope)
│ ├─ App : <IXxxRegistry><role>
│ ├─ Session : <ISessionXxx><role>
│ ├─ Agent : <IAgentXxx><role>
│ └─ Turn : — — (none)
│ └─ Agent : <IAgentXxx><role>
└─ depends (what I inject) tag = calling style
└─ <DepDomain> @<Scope> direct/event/hook — <what for>
```
@ -235,8 +232,7 @@ domain: `session` (owning scope: Session)
├─ exposes (interfaces I provide, by scope)
│ ├─ App : — — (no global session state here)
│ ├─ Session : ISessionService — this session's operations + child-agent set
│ ├─ Agent : — — (per-agent state lives in agent-lifecycle)
│ └─ Turn : — — (no per-turn session state)
│ └─ Agent : — — (per-agent state lives in agent-lifecycle)
└─ depends (what I inject)
├─ session-context @Session direct — reads its own identity
├─ agent-lifecycle @Session direct — drives child-agent lifecycle
@ -270,7 +266,7 @@ For a multi-scope split, the `exposes` block fills more than one scope — see t
## Red lines (this stage)
- Scope is not a domain; ownership follows write authority and invariants, not read consumption.
- Do not create `I{Scope}EntityService` bundles (`IAgentEntityService`, `ISessionEntityService`, `ITurnEntityService`) that re-merge multiple domains.
- Do not create `I{Scope}EntityService` bundles (`IAgentEntityService`, `ISessionEntityService`) that re-merge multiple domains.
- No `Map<sessionId, …>` at `App` to fake per-session state.
- Scope follows state identity; stateless Services are pulled down by their shortest-lived dependency, otherwise default to `App`.
- Do not pre-split a domain that has state at only one lifetime.

View file

@ -12,10 +12,8 @@ A Service registered at `LifecycleScope.Session` or `LifecycleScope.Agent` is **
| Term | Meaning |
|---|---|
| **Scope** | Lifetime / visibility tier. Current code registers Services at `Core`, `Session`, or `Agent`. |
| **Scope** | Lifetime / visibility tier. Current code registers Services at `App`, `Session`, or `Agent`. |
| **Domain** | A cohesive business responsibility with its own model, invariants, and write authority. |
> If a future branch adds a shorter-lived scope such as `Turn`, treat it as another lifetime tier, not as a data owner. The ownership rules below do not change.
| **Entity** | Data with identity and lifecycle, usually suitable for `get/list/create/update/delete` semantics. |
| **Aggregate** | A consistency boundary: the owner that enforces invariants over a cluster of data. |
| **Read model / projection** | Derived data built for queries; it may be shaped like a domain, but it is not the write authority. |

View file

@ -1,6 +1,6 @@
# Topic — Flags
Experimental feature-flag gating for agent-core-v2 — a App-scope `IFlagService` resolver plus a writable `IFlagRegistry` catalog that domains contribute their flags to, backed by the `[experimental]` config section.
Experimental feature-flag gating for agent-core-v2 — an App-scope `IFlagService` resolver plus a writable `IFlagRegistry` catalog that domains contribute their flags to, backed by the `[experimental]` config section.
Gate not-yet-public features behind `IFlagService.enabled(id)`, per the repository hard rule that unreleased behavior must be flag-gated. v1 was a process-global `FlagResolver` singleton over a central `FLAG_DEFINITIONS` array; v2 is a scoped DI service whose flag definitions are registered **decentrally** by each owning domain — there is no central catalog to edit.
@ -85,7 +85,7 @@ export * from './flag';
## Consume a flag
Inject `IFlagService` and gate on it. It is resolvable from any scope (Core ancestor):
Inject `IFlagService` and gate on it. It is resolvable from any scope (App ancestor):
```ts
constructor(@IFlagService private readonly flags: IFlagService) {}
@ -97,7 +97,7 @@ if (!this.flags.enabled('micro_compaction')) return;
- Domain `flag` is registered at **L3**. It imports only `config` (L2) downward.
- It cannot live in `_base` (L0): registering/reading the config section requires importing `config`, and L0 must not import L2.
- Scope: `IFlagRegistry` and `IFlagService` are both `Core`. Env + config are process-global inputs, so there is no per-session/agent state. Flag definitions are contributed at **import time** (top-level `registerFlagDefinition` calls), so they are queued before any scope is created and drained when `FlagRegistryService` is first instantiated — before `IFlagService` is first resolved.
- Scope: `IFlagRegistry` and `IFlagService` are both `App`. Env + config are process-global inputs, so there is no per-session/agent state. Flag definitions are contributed at **import time** (top-level `registerFlagDefinition` calls), so they are queued before any scope is created and drained when `FlagRegistryService` is first instantiated — before `IFlagService` is first resolved.
- Tests build `FlagService` + `FlagRegistryService` directly with a real `ConfigRegistry`/`ConfigService` and an injected env map, then `register` the flags they exercise.
## Red lines (this topic)
@ -106,4 +106,4 @@ if (!this.flags.enabled('micro_compaction')) return;
- Contribute each flag from the **owning domain's** `flag.ts` (`src/<domain>/flag.ts`) via a top-level `registerFlagDefinition` call; there is no central catalog to edit. The directory names the domain, so the file is just `flag.ts`.
- `env` must start with `KIMI_CODE_EXPERIMENTAL_`, be unique, and not equal `KIMI_CODE_EXPERIMENTAL_FLAG`; `id` must not be `flag`.
- `FlagId` is `string` (decentralized registration) — do not reintroduce a central `FLAG_DEFINITIONS` array or a derived literal union.
- `flag` lives at L3 and `Core` scope — never in `_base`, never per-session.
- `flag` lives at L3 and `App` scope — never in `_base`, never per-session.

View file

@ -203,7 +203,7 @@ export class ScopeRegistry implements IScopeRegistry {
Key points:
- `getScopedServiceDescriptors(scope)` returns every descriptor registered at that tier; load them into a `ServiceCollection`.
- `instantiation.createChild(collection)` builds a child container whose parent pointer is the current container — so the child resolves upward to `Core` services (the visibility rule).
- `instantiation.createChild(collection)` builds a child container whose parent pointer is the current container — so the child resolves upward to `App` services (the visibility rule).
- Expose the child to the outside by wrapping it in a `ServicesAccessor` via `invokeFunction` (§6).
> Higher-level code usually calls `Scope.createChild(kind, id)` (it does the "filter descriptors + build child" for you). Drop to the manual `ServiceCollection` form only when you need explicit control.
@ -218,7 +218,7 @@ If A needs B while being created and B needs A while being created, the containe
### Why cycles are disallowed
- Scope layering makes normal dependencies a DAG (Turn → Agent → Session → Core, resolving upward); a cycle is almost always a design smell.
- Scope layering makes normal dependencies a DAG (Agent → Session → App, resolving upward); a cycle is almost always a design smell.
- "Making the cycle happen to work" turns construction order into an implicit contract — hard to debug.
v2's stance: **the dependency graph must be acyclic.**

View file

@ -12,7 +12,7 @@ When writing business code you declare three things; the container handles the r
Classes talk only to interfaces and never care how an implementation is constructed.
## The four `LifecycleScope` tiers
## The three `LifecycleScope` tiers
Lifetimes form a tree, from longest to shortest:
@ -20,7 +20,6 @@ Lifetimes form a tree, from longest to shortest:
App (0) process-wide, single global instance
└── Session (1) one session
└── Agent (2) one agent
└── Turn (3) one turn of conversation
```
```ts
@ -28,7 +27,6 @@ export enum LifecycleScope {
App = 0,
Session = 1,
Agent = 2,
Turn = 3,
}
```
@ -40,7 +38,7 @@ export enum LifecycleScope {
A child scope sees its ancestors; a parent never sees its children. Resolution walks *up* the tree:
- ✅ A `Turn` service injects a `Session` or `App` service (found upward).
- ✅ An `Agent` service injects a `Session` or `App` service (found upward).
- ❌ An `App` service injects a `Session` service (the parent does not look down, and the child may not exist yet).
> **Short-lived may inject long-lived; never the reverse.** The tree structure enforces this — it is not a matter of discipline.

View file

@ -94,7 +94,7 @@ this.policies = registry.list()
Key points:
- `modes` / `agentTypes` are **declarations** — they lift the `if (mode !== 'yolo') return` out of `YoloModeApprove` into metadata.
- `factory`, not `instance`: a node may depend on agent-scoped services (mode, rules) and must be instantiated in the Agent scope — symmetric to `IToolDefinitionRegistry` (Core) storing factories and `IToolService` (Agent) instantiating tools.
- `factory`, not `instance`: a node may depend on agent-scoped services (mode, rules) and must be instantiated in the Agent scope — symmetric to `IToolDefinitionRegistry` (App) storing factories and `IToolService` (Agent) instantiating tools.
- **Different `(agent, mode)` produce differently-shaped chains** — under yolo the ask/fallback phases are physically filtered out.
### 5.3 Two contribution paths

View file

@ -74,7 +74,7 @@ Reach for a LegacyService when **any** hold:
- The v1 endpoint carries state the v2 domain deliberately dropped (e.g. a FIFO queue, a `prompt_id`, idempotent `abort`/`steer`, auto-start-next).
- The v1 method returns a handle/stream that v2 wraps differently, and the v1 clients expect the old envelope shape.
- Matching v1 would force a `Map<sessionId, …>`-at-`Core` anti-pattern or a scope/domain-direction violation into the native Service (see [align.md](align.md) red lines).
- Matching v1 would force a `Map<sessionId, …>`-at-`App` anti-pattern or a scope/domain-direction violation into the native Service (see [align.md](align.md) red lines).
- The native Service's error set / return type would have to grow v1-only branches.
Do **not** put v1 quirks into the native v2 Service "to keep the route simple". That is the conflict this rule exists to prevent: the native Service serves the v2 architecture; the LegacyService serves the wire contract.

View file

@ -36,7 +36,7 @@ The package entry `src/index.ts` re-exports each domain barrel so that importing
The scope prefix makes a service's lifetime readable from its name. App services carry **no** prefix (App is the default, longest-lived tier); Session and Agent services always carry `Session` / `Agent`. The prefix applies to the interface, the class, and therefore the file names.
> Do **not** use the scope prefix to re-merge domains by lifetime. `IAgentEntityService`, `IAgentDataService`, `ISessionEntityService`, and `ITurnEntityService` are still banned — the prefix marks lifetime, the rest of the name must still be the real owning domain (`IBackgroundTaskEntityService`, `ISessionMetadata`, `IPermissionRulesService`). See [domain-boundaries.md](domain-boundaries.md).
> Do **not** use the scope prefix to re-merge domains by lifetime. `IAgentEntityService`, `IAgentDataService`, and `ISessionEntityService` are still banned — the prefix marks lifetime, the rest of the name must still be the real owning domain (`IBackgroundTaskEntityService`, `ISessionMetadata`, `IPermissionRulesService`). See [domain-boundaries.md](domain-boundaries.md).
### File names
@ -256,7 +256,7 @@ Inject `@IEventService` and `publish(...)`; `subscribe(...)` returns an `IDispos
A domain may define several Services. How to organize them:
- **Same scope, tightly coupled** → one contract file, possibly one impl file with several classes and several `registerScopedService(...)` calls (e.g. `logService.ts` registers both `ILogWriterService` and `ILogService`).
- **Different scopes** → separate impl files named after the Service (`logService.ts` for Core `ILogService`, `sessionLogService.ts` for Session `ISessionLogService`); one shared contract file (`log.ts`).
- **Different scopes** → separate impl files named after the Service (`logService.ts` for App `ILogService`, `sessionLogService.ts` for Session `ISessionLogService`); one shared contract file (`log.ts`).
- **Split by responsibility** — even within one scope, prefer a separate impl file when a class is large or independently testable.
The contract file still holds **all** of the domain's interfaces and decorators in one place so consumers import the domain's surface from `./<domain>`.
@ -332,7 +332,7 @@ export * from './greet/index';
- One folder per domain, kebab-case; contract `<domain>.ts`, impl `<domain>Service.ts`, barrel `index.ts`.
- `IXxxService` / `XxxService` naming; decorator string is lowerCamelCase, globally unique, and stable.
- Name Services by owning domain, never by scope (`IAgentEntityService`, `ISessionEntityService`, `ITurnEntityService`).
- Name Services by owning domain, never by scope (`IAgentEntityService`, `ISessionEntityService`).
- `_serviceBrand` only on interfaces used as a DI token — never on base interfaces or plain models.
- Sync methods return concrete types, async return `Promise<T>`; do not `Promise`-wrap sync work.
- `createInstance` objects put static parameters before service parameters; scoped services put `@IX` parameters first (static params need defaults).

View file

@ -2,7 +2,7 @@
Telemetry infrastructure for agent-core-v2: how business services emit events, how context propagates, and how events reach a destination through appenders.
Telemetry is a **layer-1 root** domain (alongside `log`): pure `Core` scope, stateless, no business-domain dependencies. It is a thin facade — enrichment, batching, and transport belong to the appenders, not to this layer.
Telemetry is a **layer-1 root** domain (alongside `log`): pure `App` scope, stateless, no business-domain dependencies. It is a thin facade — enrichment, batching, and transport belong to the appenders, not to this layer.
## Where things live
@ -62,8 +62,8 @@ Built-in appenders:
Appenders are added after the App scope exists, by resolving the service and calling `addAppender`:
```ts
const core = createCoreScope();
const telemetry = core.accessor.get(ITelemetryService);
const app = createAppScope();
const telemetry = app.accessor.get(ITelemetryService);
telemetry.addAppender(new ConsoleAppender({ prefix: '[dev]' })); // dev echo
telemetry.addAppender(new CloudAppender({ // production
@ -85,7 +85,7 @@ telemetry.addAppender(new CloudAppender({ // production
## Red lines (this topic)
- Business services depend only on `ITelemetryService` — never import an appender class.
- Telemetry is layer-1 root: do not inject any business-domain service into it, and do not move it off `Core`.
- Telemetry is layer-1 root: do not inject any business-domain service into it, and do not move it off `App`.
- Appenders are plain `ITelemetryAppender` objects, not DI Services — register them with `addAppender`, never via `registerScopedService`.
- `track` is fire-and-forget and must not throw; appender `track` must be synchronous — buffer and send asynchronously via `flush` / `shutdown`.
- Await `telemetry.shutdown()` before process exit when a buffering appender is registered.

View file

@ -25,7 +25,7 @@ If the change is user-facing and ships through the CLI, generate a changeset wit
Walk the stages you touched and confirm:
- **Design** — scope follows state identity; no `Map<sessionId, …>` at `Core`; dependency arrows do not make a foundational layer know an upstream one; no cycle was routed around.
- **Design** — scope follows state identity; no `Map<sessionId, …>` at `App`; dependency arrows do not make a foundational layer know an upstream one; no cycle was routed around.
- **Implement** — no `new` on `@IService`-carrying classes; `@IX` on constructor params only (service params after static params); interface + impl carry `_serviceBrand`; decorator names unique; coded errors only; flags for unreleased behavior.
- **Test** — SUT resolved by interface; stubs under `test/`; scope tests re-register after `_clearScopedRegistryForTests()`; teardown through one `DisposableStore`.
- **Files** — header comments describe role + scope only; registration runs from the impl file's top level; the new domain is exported from `src/index.ts`.

View file

@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC_ROOT = join(__dirname, '..', 'src');
const SCOPE_OF = ['Core', 'Session', 'Agent', 'Turn'];
const SCOPE_OF = ['App', 'Session', 'Agent'];
function walk(dir) {
const out = [];