Files
claudemesh/.artifacts/specs/2026-04-10-cli-v2-pass2-facade-pattern.md
Alejandro Gutiérrez ee12510ef1
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
refactor: rename cli-v2 → cli, archive legacy cli, plus broker-side grants + auto-migrate
- apps/cli/ is now the canonical CLI (was apps/cli-v2/).
- apps/cli/ legacy v0 archived as branch 'legacy-cli-archive' and tag
  'cli-v0-legacy-final' before deletion; git history preserves it too.
- .github/workflows/release-cli.yml paths updated.
- pnpm-lock.yaml regenerated.

Broker-side peer-grant enforcement (spec: 2026-04-15-per-peer-capabilities):
- 0020_peer-grants.sql adds peer_grants jsonb + GIN index on mesh.member.
- handleSend in broker fetches recipient grant maps once per send, drops
  messages silently when sender lacks the required capability.
- POST /cli/mesh/:slug/grants to update from CLI; broker_messages_dropped_by_grant_total metric.
- CLI grant/revoke/block now mirror to broker via syncToBroker.

Auto-migrate on broker startup:
- apps/broker/src/migrate.ts runs drizzle migrate with pg_advisory_lock
  before the HTTP server binds. Exits non-zero on failure so Coolify
  healthcheck fails closed.
- Dockerfile copies packages/db/migrations into /app/migrations.
- postgres 3.4.5 added as direct broker dep.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:44:52 +01:00

1491 lines
60 KiB
Markdown

# claudemesh-cli v2 Pass 2 — Facade Pattern (referenced by Pass 1)
> ⚠️ **This document is a special case: it applies to BOTH Pass 1 and Pass 2.**
>
> The facade pattern is the main architectural improvement of v2 Pass 1 — it's the scalability + code distribution win you asked for. Pass 1 implements the full facade structure described here.
>
> However, some concrete examples in this document reference services that only exist in Pass 2 (`services/store`, `services/broker/sync-daemon`, etc.). When an example mentions a Pass 2-only service, treat the pattern as authoritative and substitute the Pass 1 service names.
>
> **Pass 1 services** (use the facade pattern here):
> auth, mesh, invite, broker, api, crypto, config, state (last-used cache only, not mesh state), device, clipboard, spawn, telemetry, health, update, i18n, lifecycle, logger.
>
> **Pass 2 services** (deferred):
> store (local SQLite source of truth), broker/sync-daemon (outbox/inbox), broker/peer-crypto (extended per-mesh long-term keys), anything in the shared-infrastructure spec.
>
> For the Pass 1 implementation target that lists exactly which services ship in Pass 1, see **`2026-04-11-cli-v2-pass1.md`**.
**Status:** Boundary canonical (applies to both Pass 1 and Pass 2)
**Created:** 2026-04-10
**Consolidated:** 2026-04-10 (post-reviews, no appendices)
**Companion to:** `2026-04-10-cli-v2-final-vision.md` (§3.2 defers to this document)
**Purpose:** Single source of truth for the UI↔services boundary. Specifies how facades work, what they contain, and how the ESLint + dependency-cruiser config enforces them. When a developer asks "can I import X from Y?", the answer is in here.
---
## Table of contents
1. The problem
2. The principle
3. Facade contract
4. Import policy (the hard rules)
5. Example facades (TypeScript, verified)
6. Directory structure
7. ESLint boundaries configuration
8. dependency-cruiser configuration
9. Type-only imports, dynamic imports, and re-exports
10. Testing facades and contract drift
11. What facades never expose
12. Async streams and cancellation
13. Errors and validation
14. FAQ
---
## 1. The problem
Without a facade, UI components end up importing whatever files happen to exist in a service folder:
```ts
// ui/screens/AuthScreen.tsx — bad
import { deviceCodeLogin } from '@/services/auth/device-code';
import { writeTokenFile } from '@/services/auth/token-store';
import { apiClient } from '@/services/api/client';
```
Three problems:
1. `AuthScreen` couples to specific implementation files — rename one and the UI breaks
2. `AuthScreen` has access to low-level operations (`writeTokenFile`) it should never call directly
3. `AuthScreen` has the raw `apiClient` and can make any API call anywhere
The fix is **one narrow door per service**, enforced by tooling, not naming conventions. UI and non-service consumers import from `services/<feature>/facade.ts` only. Every other file in the service is private.
## 2. The principle
> **A facade is a narrow, Promise-returning, plain-data interface that hides every implementation detail of a service. Consumers orchestrate business logic through facades. Services implement the logic behind them.**
Consequences we actively want:
- UI components are trivially testable with mock facades (no SQLite, no network, no filesystem)
- Services can be refactored freely without touching any consumer code
- The "what can UI do" surface is auditable by reading every `facade.ts` file
- Circular imports between UI and services become structurally impossible
- Boundary drift is a lint failure at CI, not a code review issue
## 3. Facade contract
### 3.1 What a facade MUST be
1. **A single `facade.ts` file per service**, at `services/<feature>/facade.ts`. Not a folder, not a subdirectory, not multiple files.
2. **Named exports only** — no default export, no namespace re-export
3. **Each export is either an async function or a pure data constant** (never a class, never a factory, never a singleton handle)
4. **Every parameter is a plain object** (`{ ... }`) — never a class instance, never a service handle
5. **Every return type is a plain object** constructed inline — never a pass-through of a service response
6. **Every input is Zod-validated** at the facade boundary before touching the service
7. **Every output is Zod-validated** before returning to the caller — the facade literally builds a new object and runs `.parse()` on it
8. **Errors are typed** — facades throw instances of domain-specific error classes from `services/<feature>/errors.ts`, never raw strings, never `ZodError` from input validation
### 3.2 What a facade MUST NOT do
1. **Never return a class instance** (even one defined in the same service)
2. **Never expose filesystem paths, URLs, or tokens** in return values — not even masked ones
3. **Never expose a database handle, HTTP client, or socket** — these are service-internal
4. **Never take a callback** for state — async/await only; for progress streams use async iterators with `AbortSignal`
5. **Never depend on React, Ink, or any UI library** — facades are framework-agnostic
6. **Never import from `ui/`, `cli/`, `commands/`, `mcp/`, or `entrypoints/`**
7. **Never use globals** — every dependency is injected at service boot time
8. **Never `export *` from another file** — every symbol is explicitly named (prevents accidental internal leakage)
9. **Never pass a service response through `...spread`** to the return object — every field is picked by name
### 3.3 Facade lifecycle
Facades themselves are stateless. They're free functions that call into the service. The service holds state (database connection, HTTP client, lifecycle); the facade is the stateless adapter.
```ts
// services/auth/index.ts — service boot (called once from entrypoints)
import { createAuthService } from './implementation';
import { getTokenStore } from './token-store';
import { getApiClient } from '@/services/api';
let instance: AuthService | null = null;
export function getAuthService(): AuthService {
if (!instance) {
instance = createAuthService({
tokenStore: getTokenStore(),
apiClient: getApiClient(),
});
}
return instance;
}
// NOTE: no `export * from './device-code'`, no `export { ... } from './internal'`.
// Only `getAuthService` is public via index.ts, and only services/* can import it.
```
```ts
// services/auth/facade.ts
import { z } from 'zod';
import { getAuthService } from './index';
import { InvalidTokenError, DeviceCodeTimeoutError, AuthNetworkError } from './errors';
// ...schemas and facade functions below
```
UI never imports `getAuthService` — only the facade.
## 4. Import policy (the hard rules)
This is the stack of allow-lists. Every rule is enforced by ESLint boundaries + dependency-cruiser. Any violation fails CI.
| Consumer | May import from |
|---|---|
| `entrypoints/` | `cli/`, `commands/`, `ui/`, `mcp/`, `service-facade/`, `utils/`, `types/`, `constants/`, `locales/`, `templates/`, `migrations/` |
| `commands/` | `cli/`, `ui/`, **`service-facade/` only**, `utils/`, `types/`, `constants/`, `locales/` |
| `ui/` | `ui/`, **`service-facade/` only**, `utils/`, `types/`, `constants/`, `locales/` |
| `cli/` (non-Ink I/O) | **`service-facade/` only**, `utils/`, `types/`, `constants/`, `locales/` |
| `mcp/` | **`service-facade/` only**, `templates/`, `utils/`, `types/`, `constants/`, `locales/` |
| `service-facade` (a service's own facade) | its own `service-internal`, other services' `service-facade`, `utils/`, `types/`, `constants/`, `locales/` |
| `service-internal` (service implementation files) | its own `service-internal`, its own `service-facade` (rare), other services' `service-facade`, `templates/`, `utils/`, `types/`, `constants/`, `locales/` |
| `service-index` (factory barrel) | its own `service-internal`, `utils/`, `types/`, `constants/`, `locales/` |
| `service-test` | its own `service-internal`, its own `service-facade`, any `service-facade` (for integration), `utils/`, `types/`, `constants/`, `templates/` |
| `templates/` | `utils/`, `types/`, `constants/` |
| `locales/` | `types/` |
| `utils/` | `types/` |
| `constants/` | (nothing) |
| `types/` | (nothing, except other `types/`) |
| `migrations/` | own `service-index`, `utils/`, `types/`, `constants/` |
**Key tightenings from review:**
- **`commands` uses facades only.** Commands go through facades like UI does. If a command needs deeper access, the facade is missing a function — extend the facade, don't bypass it.
- **`entrypoints` no longer gets `service-internal` access.** Entrypoints call `commands`, which call facades.
- **`mcp` now uses facades only** (no `service-index`). MCP tool handlers are not magic — they go through the same narrow interfaces as commands. If a tool needs cross-service composition beyond what a single facade exposes, it composes multiple facades.
- **`service-facade` can import from other services' `service-facade`** (cross-service facade composition). Service-to-service calls go facade→facade, not through `index.ts`. This removes the `service-index` cross-service path entirely.
- **`service-index` is downgraded** to a factory barrel that only exposes `getXxxService()` and is imported only by `entrypoints/cli.ts` for DI wiring and by its own service's internals. No other layer imports `service-index`.
- **`migrations/` no longer gets `service-internal` access.** Migrations work against `service-index` (its own service only, through the factory) and the raw database connection. Deep data surgery happens through typed helpers in the service's internals, not directly.
**The only consumers that touch `service-internal` are**:
- The service itself (its own folder)
- The service's tests
- The service's `facade.ts` and `index.ts` files (by definition)
## 5. Example facades (TypeScript, verified)
### 5.1 Auth facade — with input AND output validation
```ts
// services/auth/facade.ts
import { z } from 'zod';
import { getAuthService } from './index';
import {
InvalidTokenError,
DeviceCodeTimeoutError,
AuthNetworkError,
toDomainError,
} from './errors';
// ---- Input schemas ----
const LoginWithTokenInputSchema = z.object({
token: z.string().regex(/^cm_(session|pat)_[a-z0-9]{32,}$/, 'malformed token'),
});
// ---- Output schemas ----
const UserSchema = z.object({
id: z.string(),
display_name: z.string(),
email: z.string().email(),
}).strict();
const LoginResultSchema = z.object({
user: UserSchema,
}).strict();
const WhoAmIResultSchema = z.object({
signed_in: z.boolean(),
user: UserSchema.nullable(),
token_source: z.enum(['device_code', 'pat', 'env']).nullable(),
}).strict();
const LogoutResultSchema = z.object({
server_revoked: z.boolean(),
}).strict();
// ---- Exported types ----
export type LoginResult = z.infer<typeof LoginResultSchema>;
export type WhoAmIResult = z.infer<typeof WhoAmIResultSchema>;
export type LogoutResult = z.infer<typeof LogoutResultSchema>;
// ---- Facade functions ----
/**
* Start the device-code flow. Opens browser, polls until user approves or denies.
* @throws DeviceCodeTimeoutError if the user doesn't respond in 10 minutes
* @throws AuthNetworkError if claudemesh.com is unreachable
*/
export async function loginWithDeviceCode(): Promise<LoginResult> {
try {
const result = await getAuthService().startDeviceCodeFlow();
// Build output explicitly — no spread, no pass-through
return LoginResultSchema.parse({
user: {
id: result.user.id,
display_name: result.user.display_name,
email: result.user.email,
},
});
} catch (err) {
throw toDomainError(err);
}
}
/**
* Login using a PAT or session token from explicit input.
* @throws InvalidTokenError if the token format is wrong
* @throws AuthNetworkError if the server rejects or is unreachable
*/
export async function loginWithToken(input: unknown): Promise<LoginResult> {
// Validate input BEFORE touching the service
const parsed = LoginWithTokenInputSchema.safeParse(input);
if (!parsed.success) {
throw new InvalidTokenError('malformed token format');
}
try {
const result = await getAuthService().loginWithToken(parsed.data.token);
return LoginResultSchema.parse({
user: {
id: result.user.id,
display_name: result.user.display_name,
email: result.user.email,
},
});
} catch (err) {
throw toDomainError(err);
}
}
/**
* Check current auth state. Never throws.
*/
export async function whoAmI(): Promise<WhoAmIResult> {
try {
const state = await getAuthService().getCurrentState();
return WhoAmIResultSchema.parse({
signed_in: state.signed_in,
user: state.user
? {
id: state.user.id,
display_name: state.user.display_name,
email: state.user.email,
}
: null,
token_source: state.token_source,
});
} catch {
return WhoAmIResultSchema.parse({
signed_in: false,
user: null,
token_source: null,
});
}
}
/**
* Revoke the current session server-side and clear local credentials.
* Best-effort on server; always clears local.
*/
export async function logout(): Promise<LogoutResult> {
try {
const result = await getAuthService().logout();
return LogoutResultSchema.parse({
server_revoked: result.server_revoked,
});
} catch {
return LogoutResultSchema.parse({ server_revoked: false });
}
}
```
**Key properties verified in this example**:
- `.strict()` on every schema prevents extra fields from passing through (eliminates the "class instance with matching fields" bypass)
- Input is validated BEFORE the service call
- Output is built explicitly, field by field — no spread, no pass-through
- Every branch catches errors and maps via `toDomainError`
- Zod errors never escape the facade — they're mapped to domain errors
### 5.2 Error mapping helper — preserves cause + logs unmapped bugs
```ts
// services/auth/errors.ts
import { ZodError } from 'zod';
import { logger } from '@/services/logger/facade'; // structured logger
export class InvalidTokenError extends Error {
readonly code = 'AUTH_INVALID_TOKEN';
constructor(message: string, options?: { cause?: unknown }) {
super(message);
if (options?.cause) this.cause = options.cause;
}
}
export class DeviceCodeTimeoutError extends Error {
readonly code = 'AUTH_DEVICE_CODE_TIMEOUT';
constructor(cause?: unknown) {
super('device code flow timed out');
if (cause) this.cause = cause;
}
}
export class AuthNetworkError extends Error {
readonly code = 'AUTH_NETWORK';
constructor(cause?: unknown) {
super('auth network error');
if (cause) this.cause = cause;
}
}
/**
* Unmapped errors are real bugs. They land in the UnmappedError class and
* get logged with full stack for telemetry, so they don't disappear into
* generic AuthNetworkError (which would hide root causes).
*/
export class UnmappedError extends Error {
readonly code = 'UNMAPPED';
constructor(cause: unknown) {
super('unmapped internal error');
this.cause = cause;
}
}
/**
* Map any thrown value into a typed domain error.
*
* Contract:
* - Domain errors pass through unchanged
* - ZodError → InvalidTokenError with original cause attached
* - Node network errors (ENOTFOUND, ECONNREFUSED, etc.) → AuthNetworkError
* - EVERYTHING ELSE → UnmappedError (explicitly logged as a bug)
*
* Unmapped errors are logged at ERROR level with the full stack trace so
* they surface in telemetry instead of being silently categorized as
* network errors. This fixes the observability gap where a null pointer
* bug would appear as "network error" in logs.
*/
export function toDomainError(err: unknown): Error {
// Domain errors pass through unchanged
if (err instanceof InvalidTokenError) return err;
if (err instanceof DeviceCodeTimeoutError) return err;
if (err instanceof AuthNetworkError) return err;
if (err instanceof UnmappedError) return err;
// Zod validation failures → typed input error, preserving the original for logs
if (err instanceof ZodError) {
const mapped = new InvalidTokenError('schema validation failed', { cause: err });
logger.warn('facade: zod validation failed', { errors: err.errors, mapped: mapped.code });
return mapped;
}
// Node network errors → AuthNetworkError, preserving the original
if (err && typeof err === 'object' && 'code' in err) {
const code = (err as { code: unknown }).code;
if (
code === 'ENOTFOUND' ||
code === 'ECONNREFUSED' ||
code === 'ETIMEDOUT' ||
code === 'ECONNRESET' ||
code === 'EAI_AGAIN'
) {
return new AuthNetworkError(err);
}
}
// Anything else is a bug. Log it with full context and return an UnmappedError.
// This prevents programmer bugs from being silently miscategorized.
logger.error('facade: unmapped error (likely a bug)', {
error: err instanceof Error ? { message: err.message, stack: err.stack, name: err.name } : err,
facade: 'auth',
});
return new UnmappedError(err);
}
```
**Key change from v1**: the previous implementation collapsed everything unknown into `AuthNetworkError`. A null pointer exception in the service would surface to the UI as "network error" and telemetry would never flag it as a bug. The new `UnmappedError` class catches these with explicit logging at ERROR level, so real bugs show up in logs and can be tracked by telemetry.
Every service's `errors.ts` follows this pattern: domain errors + `UnmappedError` + `toDomainError` helper that logs unmapped cases.
### 5.3 Mesh facade (summary form)
```ts
// services/mesh/facade.ts
import { z } from 'zod';
import { getMeshService } from './index';
import { MeshNotFoundError, SlugCollisionError, PermissionDeniedError, toDomainError } from './errors';
const MeshSummarySchema = z.object({
slug: z.string(),
name: z.string(),
kind: z.enum(['personal', 'shared_owner', 'shared_guest']),
peer_count: z.number().int().nonnegative(),
peers_online: z.number().int().nonnegative(),
last_used_at: z.number().int().nullable(),
}).strict();
const MeshListResultSchema = z.object({
meshes: z.array(MeshSummarySchema),
last_used_slug: z.string().nullable(),
}).strict();
const PublishMeshResultSchema = z.object({
slug: z.string(),
invite_url: z.string().url(),
}).strict();
export type MeshSummary = z.infer<typeof MeshSummarySchema>;
export type MeshListResult = z.infer<typeof MeshListResultSchema>;
export type PublishMeshResult = z.infer<typeof PublishMeshResultSchema>;
export async function createMesh(input: unknown): Promise<MeshSummary> {
const parsed = z.object({
name: z.string().min(1).max(128),
slug: z.string().regex(/^[a-z0-9-]+$/).optional(),
}).safeParse(input);
if (!parsed.success) throw new MeshNotFoundError('invalid create input');
try {
const r = await getMeshService().create(parsed.data);
return MeshSummarySchema.parse({
slug: r.slug, name: r.name, kind: r.kind,
peer_count: r.peer_count, peers_online: r.peers_online,
last_used_at: r.last_used_at,
});
} catch (err) { throw toDomainError(err); }
}
export async function listMeshes(): Promise<MeshListResult> {
try {
const r = await getMeshService().list();
return MeshListResultSchema.parse({
meshes: r.meshes.map(m => ({
slug: m.slug, name: m.name, kind: m.kind,
peer_count: m.peer_count, peers_online: m.peers_online,
last_used_at: m.last_used_at,
})),
last_used_slug: r.last_used_slug,
});
} catch (err) { throw toDomainError(err); }
}
export async function publishMesh(input: unknown): Promise<PublishMeshResult> {
const parsed = z.object({ slug: z.string() }).safeParse(input);
if (!parsed.success) throw new MeshNotFoundError('invalid publish input');
try {
const r = await getMeshService().publish(parsed.data);
return PublishMeshResultSchema.parse({ slug: r.slug, invite_url: r.invite_url });
} catch (err) { throw toDomainError(err); }
}
// ...joinMeshByInvite, renameMesh, leaveMesh, resolveLaunchTarget follow same pattern
```
### 5.4 Clipboard facade (even trivial services get a facade)
```ts
// services/clipboard/facade.ts
import { z } from 'zod';
import { getClipboardService } from './index';
const DetectInviteResultSchema = z.object({
has_invite: z.boolean(),
mesh_slug: z.string().nullable(),
url: z.string().url().nullable(),
}).strict();
export type DetectInviteResult = z.infer<typeof DetectInviteResultSchema>;
/**
* Never returns raw clipboard content — only the detected invite metadata.
* Prevents arbitrary clipboard content from flowing into UI state (privacy).
*/
export async function detectInviteInClipboard(): Promise<DetectInviteResult> {
try {
const r = await getClipboardService().detectInvite();
return DetectInviteResultSchema.parse({
has_invite: r.has_invite,
mesh_slug: r.mesh_slug ?? null,
url: r.url ?? null,
});
} catch {
return DetectInviteResultSchema.parse({
has_invite: false,
mesh_slug: null,
url: null,
});
}
}
```
Yes, this is more code than `getClipboardService().detectInvite()` would be. That's the point: the facade prevents UI from ever learning that `clipboardService` exists, and prevents the implementation from leaking raw clipboard content through the boundary.
## 6. Directory structure
```
apps/cli-v2/src/services/auth/
├── client.ts # private — HTTP calls to /api/auth/cli/*
├── device-code.ts # private — device-code flow orchestration
├── pat.ts # private — PAT parsing and validation
├── token-store.ts # private — ~/.claudemesh/auth.json R/W
├── refresh.ts # private — silent re-auth
├── implementation.ts # private — assembles the service from parts
├── schemas.ts # private — internal Zod schemas
├── errors.ts # private — domain error classes + toDomainError
├── types.ts # private — internal types
├── index.ts # PUBLIC (for other services) — exports getAuthService()
├── facade.ts # PUBLIC (for ui/commands/cli/mcp) — narrow facade
├── facade.test.ts # facade contract test (verifies no tokens leak)
└── auth.test.ts # internal unit tests
```
**Rules for this tree**:
- `index.ts` contains only **named exports of the service factory/getter**. It must not `export *`, must not re-export internal files, must not expose types for internal implementation details.
- `facade.ts` is a single file. It is never a folder. It never has siblings named `facade.*.ts`.
- Internal files (`client.ts`, `device-code.ts`, etc.) can import each other freely within the service.
- Cross-service access is through each service's `index.ts`, not through internals.
- Nested folders inside a service (e.g. `services/mesh/subsystem/`) are allowed, but every file in them is classified as `service-internal` regardless of depth.
## 7. ESLint boundaries configuration
This is the enforced config. It has been reviewed for all the bypass paths found in the initial draft: shallow globs, nested files, test overlap, re-exports, dynamic imports, and type-only imports.
```js
// apps/cli-v2/.eslintrc.cjs
module.exports = {
plugins: ['boundaries', 'claudemesh-custom'], // claudemesh-custom is an in-repo ESLint plugin
settings: {
'boundaries/elements': [
// Entry points — process entry
{ type: 'entrypoints', pattern: 'src/entrypoints/*.{ts,tsx,mts,cts}' },
// Top-level layers
{ type: 'cli', pattern: 'src/cli/**/*.{ts,tsx,mts,cts}' },
{ type: 'commands', pattern: 'src/commands/**/*.{ts,tsx,mts,cts}' },
{ type: 'ui', pattern: 'src/ui/**/*.{ts,tsx,mts,cts}' },
{ type: 'mcp', pattern: 'src/mcp/**/*.{ts,tsx,mts,cts}' },
// Service layers — order matters: test > facade > index > internal
// Test pattern MUST come first so *.test.ts files classify as service-test,
// not service-internal.
// Facade pattern MUST cover both facade.ts (single file) AND facade/*.ts
// (folder form) to prevent the facade-as-folder bypass.
{ type: 'service-test', pattern: 'src/services/*/**/*.test.{ts,tsx,mts,cts}' },
{ type: 'service-facade', pattern: [
'src/services/*/facade.{ts,tsx,mts,cts}',
'src/services/*/facade/**/*.{ts,tsx,mts,cts}', // fallback if someone uses facade/ folder
]},
{ type: 'service-index', pattern: 'src/services/*/index.{ts,tsx,mts,cts}' },
{ type: 'service-internal', pattern: 'src/services/*/**/*.{ts,tsx,mts,cts}' },
// Pure / data layers
{ type: 'templates', pattern: 'src/templates/**/*.{ts,tsx,mts,cts}' },
{ type: 'locales', pattern: 'src/locales/**/*.{ts,tsx,mts,cts}' },
{ type: 'utils', pattern: 'src/utils/**/*.{ts,tsx,mts,cts}' },
{ type: 'types', pattern: 'src/types/**/*.{ts,tsx,mts,cts}' },
{ type: 'constants', pattern: 'src/constants/**/*.{ts,tsx,mts,cts}' },
{ type: 'migrations', pattern: 'src/migrations/**/*.{ts,tsx,mts,cts}' },
],
'boundaries/include': ['src/**/*.{ts,tsx,mts,cts}'],
},
rules: {
// Hard boundary rule — facades-only for all consumer layers
'boundaries/element-types': ['error', {
default: 'disallow',
rules: [
// entrypoints compose the application; they need service-index for DI wiring
// but never touch service-internal.
{ from: 'entrypoints', allow: [
'cli', 'commands', 'ui', 'mcp',
'service-facade', 'service-index',
'templates', 'locales', 'utils', 'types', 'constants', 'migrations',
] },
// UI: facades only.
{ from: 'ui', allow: [
'ui', 'service-facade',
'locales', 'utils', 'types', 'constants',
] },
// Commands: facades only (no service-index, no service-internal).
{ from: 'commands', allow: [
'cli', 'ui', 'service-facade',
'locales', 'utils', 'types', 'constants',
] },
// CLI (non-Ink I/O plumbing): facades only.
{ from: 'cli', allow: [
'service-facade',
'locales', 'utils', 'types', 'constants',
] },
// MCP: facades only (TIGHTENED — no longer gets service-index).
// Cross-service composition happens by importing from other services' facades.
{ from: 'mcp', allow: [
'service-facade',
'templates', 'locales', 'utils', 'types', 'constants',
] },
// A service's facade can use its own internals + OTHER services' facades
// (cross-service facade composition). No longer uses service-index for
// cross-service calls.
{ from: 'service-facade', allow: [
'service-internal', 'service-facade',
'locales', 'utils', 'types', 'constants',
] },
// A service's internals can freely use each other + other services' facades.
{ from: 'service-internal', allow: [
'service-internal', 'service-facade',
'templates', 'locales', 'utils', 'types', 'constants',
] },
// A service's index.ts is a factory barrel. It imports its own internals
// and exposes getXxxService(). It does NOT re-export anything else.
// Other services do not import from this — use service-facade for cross-
// service calls.
{ from: 'service-index', allow: [
'service-internal',
'locales', 'utils', 'types', 'constants',
] },
// Tests can import their own service freely; may also import OTHER services'
// facades for integration tests (not their internals).
{ from: 'service-test', allow: [
'service-internal', 'service-facade', 'service-index',
'service-test',
'templates', 'locales', 'utils', 'types', 'constants',
] },
// Pure layers
{ from: 'templates', allow: ['utils', 'types', 'constants'] },
{ from: 'locales', allow: ['types'] },
{ from: 'utils', allow: ['types'] },
{ from: 'constants', allow: [] },
{ from: 'types', allow: ['types'] },
// Migrations: only their own service's index (for the DI factory) and
// internal (for deep data surgery). No cross-service internals.
{ from: 'migrations', allow: [
'service-index', 'service-internal',
'utils', 'types', 'constants',
] },
],
}],
// Ban `export *` globally — closes the bulk re-export loophole.
'no-restricted-syntax': [
'error',
{
selector: "ExportAllDeclaration",
message: "`export *` is forbidden. Use named exports.",
},
],
// Custom in-repo rule: ban named re-exports from `./internal` paths in
// service index.ts files. Only `getXxxService` getters can be exported.
'claudemesh-custom/no-index-reexport-internal': 'error',
// Custom in-repo rule: ban `import type` and value imports from internal
// service files across layer boundaries. Complements boundaries plugin
// which by default doesn't distinguish value vs type imports.
'claudemesh-custom/type-imports-count-as-edges': 'error',
// Custom in-repo rule: use ts-morph AST to find all dynamic `import()`
// calls with non-literal arguments targeting the services/ path. Blocks
// `await import(var)` as well as string literals.
'claudemesh-custom/no-dynamic-service-imports': 'error',
// Invert no-restricted-imports to an allowlist: from consumer layers,
// block EVERYTHING under services/*/ except facade.*.
'no-restricted-imports': [
'error',
{
patterns: [
{
group: [
// Block direct imports to any internal service file from consumer layers
'**/services/*/!(facade)**',
'**/services/*/!(facade).*',
// Block imports from index.ts unless explicitly from entrypoints
'**/services/*/index',
'**/services/*/index.*',
],
message: 'Import from services/<name>/facade.ts only. These files are internal.',
},
],
},
],
},
overrides: [
// Service internals, facades, and tests bypass the blocklist (they need each other)
{
files: [
'src/services/*/**',
],
rules: {
'no-restricted-imports': 'off',
},
},
// Entrypoints can import service-index for DI wiring
{
files: ['src/entrypoints/*'],
rules: {
'no-restricted-imports': ['error', {
patterns: [
{ group: ['**/services/*/!(facade|index)**'], message: 'Entrypoints use facade or index only.' },
],
}],
},
},
// Tests can mock internals
{
files: ['tests/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': 'off',
},
},
],
};
```
### Custom in-repo ESLint rules
The three `claudemesh-custom/*` rules above are implemented in `tools/eslint-plugin-claudemesh/`:
#### `no-index-reexport-internal`
Parses each `services/*/index.ts` file and rejects any `ExportNamedDeclaration` that references a local file (starting with `./`) unless the exported symbol is a factory getter matching the pattern `get*Service`.
```ts
// services/auth/index.ts — ALLOWED
export { getAuthService } from './implementation';
// services/auth/index.ts — REJECTED
export { deviceCodeLogin } from './device-code'; // leaks internal
export { tokenStore } from './token-store'; // leaks internal
```
This closes the named-re-export loophole that `no-restricted-syntax: ExportAllDeclaration` alone didn't catch.
#### `type-imports-count-as-edges`
By default, `eslint-plugin-boundaries` may treat `import type { Foo } from '...'` as a free pass because TypeScript erases type-only imports at compile time. But type imports create source-level coupling — renaming an internal type breaks UI code that imported it. This rule marks type imports as full dependency edges for boundary enforcement.
Implementation: a simple AST walker that reports any `ImportDeclaration` with `importKind === 'type'` where the source path crosses a layer boundary, just like a value import would.
#### `no-dynamic-service-imports`
Uses `ts-morph` or the TypeScript compiler API to walk every `ImportExpression` (dynamic `import()` call) in the source tree. Rejects any call whose argument is:
- Not a string literal
- A string literal matching `services/*/[^f]` (anything other than `facade.*`)
- A template literal
- A function call returning a string
```ts
// REJECTED — non-literal argument
const p = 'services/auth/client';
await import(p);
// REJECTED — template literal
await import(`services/${name}/client`);
// REJECTED — string literal pointing to internal file
await import('@/services/auth/client');
// ALLOWED — string literal pointing to facade
await import('@/services/auth/facade');
```
The rule runs as part of the ESLint lint pass. No separate build-time scanner is needed — everything is enforced at CI via ESLint.
### Key fixes from review (second round)
- **Pattern order is verified** by a classification test that asserts `services/auth/facade.ts``service-facade`, `services/auth/client.ts``service-internal`, `services/auth/foo.test.ts``service-test` before the rules run.
- **`facade/` folder bypass closed**: the `service-facade` pattern is an array of two globs, the second catches `services/*/facade/**/*.ts`.
- **`commands`, `mcp` no longer reach `service-index`**: both use facades only. MCP cross-service composition goes through other services' facades.
- **`service-facade` imports other services' `service-facade`, not `service-index`**: this makes `service-index` a pure DI factory consumed only by entrypoints.
- **Named re-export loophole closed** via `claudemesh-custom/no-index-reexport-internal`.
- **Dynamic import loophole closed** via `claudemesh-custom/no-dynamic-service-imports` using AST walking, not regex.
- **Type-only imports count as edges** via `claudemesh-custom/type-imports-count-as-edges`.
- **`no-restricted-imports` inverted to allowlist**: consumer layers are blocked from importing ANY file under `services/*/` except `facade.*`. Overrides for entrypoints (which can also use index) and service-internal files (which can import each other).
- **Migrations` tightened** to only their own service (not cross-service internals).
## 8. dependency-cruiser configuration
Belt-and-suspenders folder-level rules for anything the ESLint config might miss.
```js
// apps/cli-v2/dependency-cruiser.config.js
module.exports = {
forbidden: [
{
name: 'ui-only-facades',
comment: 'UI may only import from services/<name>/facade.ts, never internals or index.',
severity: 'error',
from: { path: '^src/ui' },
to: {
path: '^src/services/[^/]+/',
pathNot: '^src/services/[^/]+/facade\\.(ts|tsx|mts|cts)$',
},
},
{
name: 'commands-only-facades',
comment: 'Commands may only import from services/<name>/facade.ts.',
severity: 'error',
from: { path: '^src/commands' },
to: {
path: '^src/services/[^/]+/',
pathNot: '^src/services/[^/]+/facade\\.(ts|tsx|mts|cts)$',
},
},
{
name: 'cli-only-facades',
comment: 'CLI I/O layer may only import from services/<name>/facade.ts.',
severity: 'error',
from: { path: '^src/cli' },
to: {
path: '^src/services/[^/]+/',
pathNot: '^src/services/[^/]+/facade\\.(ts|tsx|mts|cts)$',
},
},
{
name: 'mcp-no-cross-internal',
comment: 'MCP tools may cross services via index.ts, not via internals.',
severity: 'error',
from: { path: '^src/mcp' },
to: {
path: '^src/services/[^/]+/',
pathNot: '^src/services/[^/]+/(facade|index)\\.(ts|tsx|mts|cts)$',
},
},
{
name: 'cli-no-ui',
comment: 'Non-Ink I/O plumbing must not depend on Ink.',
severity: 'error',
from: { path: '^src/cli' },
to: { path: '^src/ui' },
},
{
name: 'services-no-ui',
comment: 'Services must not depend on Ink or commands.',
severity: 'error',
from: { path: '^src/services' },
to: { path: '^src/(ui|commands|cli)' },
},
{
name: 'utils-pure',
comment: 'Utils must not import from effectful layers.',
severity: 'error',
from: { path: '^src/utils' },
to: { path: '^src/(services|ui|commands|cli|mcp|entrypoints)' },
},
{
name: 'types-pure',
comment: 'Types must not import from anything except other types.',
severity: 'error',
from: { path: '^src/types' },
to: { pathNot: '^src/types' },
},
{
name: 'no-circular',
comment: 'No circular dependencies anywhere.',
severity: 'error',
from: {},
to: { circular: true },
},
],
options: {
tsPreCompilationDeps: true,
tsConfig: { fileName: './tsconfig.json' },
includeOnly: '^src',
},
};
```
## 9. Type-only imports, dynamic imports, and re-exports
### 9.1 Type-only imports
Type-only imports (`import type { X } from '...'`) **do count as dependency edges** for boundary purposes. The rationale: a type import creates coupling. If the internal file's types change, the UI breaks. That's exactly what facades exist to prevent.
ESLint boundaries treats `import type` as equivalent to `import` for classification purposes. dependency-cruiser is configured with `tsPreCompilationDeps: true` which includes type-only edges.
### 9.2 Dynamic imports — AST-based enforcement
`await import('computed-path')` cannot be statically analyzed with regex. Variable arguments (`const p = ...; await import(p)`) and template literals (`` await import(`services/${name}/client`) ``) would escape any regex-based check.
**Enforcement**: the custom ESLint rule `claudemesh-custom/no-dynamic-service-imports` uses the TypeScript compiler API (via `ts-morph` or `@typescript-eslint/parser`) to walk every `CallExpression` whose callee is the `import` keyword (the `ImportExpression` node type).
```ts
// tools/eslint-plugin-claudemesh/rules/no-dynamic-service-imports.ts
import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
export const noDynamicServiceImports: TSESLint.RuleModule<'illegalDynamicImport', []> = {
meta: {
type: 'problem',
messages: {
illegalDynamicImport:
'Dynamic import of a service path is forbidden. Use the facade directly: {{hint}}',
},
schema: [],
},
defaultOptions: [],
create(context) {
return {
ImportExpression(node: TSESTree.ImportExpression) {
const arg = node.source;
// Case 1: non-literal argument (variable, function call, template with expressions)
if (arg.type !== 'Literal' || typeof arg.value !== 'string') {
// Check if this file is inside src/services/ — those are allowed
// to use dynamic imports freely within their own service
const filename = context.filename;
if (filename.includes('/src/services/')) return;
context.report({
node,
messageId: 'illegalDynamicImport',
data: { hint: 'dynamic import arguments must be string literals' },
});
return;
}
// Case 2: literal pointing to a service internal
const path = arg.value;
const match = path.match(/services\/([^/]+)\/(.+)$/);
if (!match) return;
const [, serviceName, rest] = match;
// Allow only imports of the facade or types
if (rest === 'facade' || rest.startsWith('facade.') || rest.startsWith('facade/')) return;
if (rest === 'types' || rest.startsWith('types.')) return;
context.report({
node,
messageId: 'illegalDynamicImport',
data: {
hint: `use '@/services/${serviceName}/facade' instead`,
},
});
},
};
},
};
```
This catches:
- `await import('services/auth/client')` — literal pointing to internal
- `await import(computedVar)` — non-literal argument from outside the service
- `` await import(`services/${x}/y`) `` — template literal with expression
Consumers inside `src/services/` can still use dynamic imports freely within their own service (lazy loading, plugin patterns). The rule only fires when a non-service file tries to reach into a service folder dynamically.
**No regex in build.ts** — the entire enforcement is via ESLint's CI pass, using TypeScript's AST.
### 9.3 Re-exports — named re-exports also banned
`export *` is banned project-wide via `no-restricted-syntax: ExportAllDeclaration`. But that only closes the bulk-leak path — **named re-exports from sibling internals are also banned** via the custom rule `claudemesh-custom/no-index-reexport-internal`.
**Rules for `services/<name>/index.ts`**:
- ALLOWED: `export { getAuthService } from './implementation';` — but only if the exported name matches `/^get\w+Service$/`, confirming it's a factory getter
- REJECTED: `export { deviceCodeLogin } from './device-code';` — leaks an internal function
- REJECTED: `export { writeTokenFile } from './token-store';` — leaks a low-level helper
- REJECTED: `export { AuthService } from './types';` — leaks an implementation type (use explicit imports from `./types` if needed by internals, and expose via the facade)
**Rules for `services/<name>/facade.ts`**:
- Exports are named only — no `export *`, no namespace re-export
- Every exported symbol is either an `async function` or a `const` data value
- No re-exports at all — the facade BUILDS output from scratch via Zod parsing, never passes through
**Rules for internal files** (`services/<name>/*.ts` except `facade.ts` and `index.ts`):
- Can import from each other freely within the same service folder
- Can `export` named symbols to sibling files in the same service
- Cannot `export` to non-sibling consumers except via the facade or the factory getter in `index.ts`
The custom rule enforces these at CI. Any violation fails the PR with a clear error message pointing to the offending export.
## 10. Testing facades and contract drift
### 10.1 Facade contract test
Every service's `facade.test.ts` verifies the contract holds:
```ts
// services/auth/facade.test.ts
import { describe, it, expect, vi } from 'vitest';
import * as facade from './facade';
import { getAuthService } from './index';
vi.mock('./index', () => ({
getAuthService: vi.fn(),
}));
describe('auth facade contract', () => {
it('loginWithDeviceCode returns only user info — no token leaks', async () => {
vi.mocked(getAuthService).mockReturnValue({
startDeviceCodeFlow: vi.fn().mockResolvedValue({
user: { id: 'u1', display_name: 'Alejandro', email: 'a@b.c' },
token: 'cm_session_SECRETSECRETSECRETSECRETSECRETSE',
raw_response: { headers: {} },
}),
} as any);
const result = await facade.loginWithDeviceCode();
const serialized = JSON.stringify(result);
expect(serialized).not.toContain('cm_session_');
expect(serialized).not.toContain('SECRET');
expect(serialized).not.toContain('raw_response');
expect(result).toEqual({
user: { id: 'u1', display_name: 'Alejandro', email: 'a@b.c' },
});
});
it('whoAmI never throws even when service fails', async () => {
vi.mocked(getAuthService).mockReturnValue({
getCurrentState: vi.fn().mockRejectedValue(new Error('boom')),
} as any);
await expect(facade.whoAmI()).resolves.toBeDefined();
});
it('loginWithToken rejects malformed token with InvalidTokenError', async () => {
const { InvalidTokenError } = await import('./errors');
await expect(facade.loginWithToken({ token: 'not-a-token' }))
.rejects.toBeInstanceOf(InvalidTokenError);
});
it('every exported function is async (returns a Promise)', () => {
for (const key of Object.keys(facade)) {
const v = (facade as any)[key];
if (typeof v === 'function') {
const r = v({});
expect(r).toBeInstanceOf(Promise);
r.catch(() => {}); // swallow test-only rejection
}
}
});
});
```
### 10.1.5 Boundaries classification test (verify pattern precedence)
`eslint-plugin-boundaries` pattern resolution is implementation-defined — not all versions guarantee "first match wins" or "most specific glob wins." Before trusting the config, we verify classification empirically:
```ts
// tests/unit/facade-boundaries-classification.test.ts
import { describe, it, expect } from 'vitest';
import { ESLint } from 'eslint';
const linter = new ESLint({ overrideConfigFile: '.eslintrc.cjs' });
async function classifyFile(filePath: string): Promise<string | null> {
// Use the boundaries plugin's internal classification function.
// If the plugin exposes it via getFileElement(), use that directly.
// Otherwise, run the linter and check which element-types rule fires.
const config = await linter.calculateConfigForFile(filePath);
const boundariesSettings = config.settings?.['boundaries/elements'] ?? [];
// Match each pattern in order; return the first hit
for (const element of boundariesSettings) {
const patterns = Array.isArray(element.pattern) ? element.pattern : [element.pattern];
for (const p of patterns) {
// Use minimatch or picomatch to test the pattern
if (minimatch(filePath, p)) return element.type;
}
}
return null;
}
describe('boundaries classification', () => {
const cases: Array<[string, string]> = [
['src/entrypoints/cli.ts', 'entrypoints'],
['src/cli/print.ts', 'cli'],
['src/commands/launch.ts', 'commands'],
['src/ui/screens/AuthScreen.tsx', 'ui'],
['src/mcp/tools/memory.ts', 'mcp'],
// Service classifications — the critical part
['src/services/auth/facade.ts', 'service-facade'],
['src/services/auth/facade/helper.ts', 'service-facade'], // facade-as-folder bypass closed
['src/services/auth/index.ts', 'service-index'],
['src/services/auth/client.ts', 'service-internal'],
['src/services/auth/device-code.ts', 'service-internal'],
['src/services/auth/nested/deep/helper.ts', 'service-internal'], // nested files caught
['src/services/auth/auth.test.ts', 'service-test'],
['src/services/auth/nested/deep.test.ts', 'service-test'], // nested tests caught
// Pure layers
['src/utils/levenshtein.ts', 'utils'],
['src/types/api.ts', 'types'],
['src/constants/paths.ts', 'constants'],
['src/locales/en.ts', 'locales'],
['src/templates/solo.ts', 'templates'],
['src/migrations/0001-v1-config.ts', 'migrations'],
];
for (const [path, expected] of cases) {
it(`${path} classifies as ${expected}`, async () => {
const actual = await classifyFile(path);
expect(actual).toBe(expected);
});
}
});
```
This test runs in CI and fails if the boundaries plugin misclassifies any file. If a future version of `eslint-plugin-boundaries` changes pattern resolution, this test catches it before the real rules silently break.
### 10.2 Boundary leak scanner (AST-based)
Regex scanning has too many false positives (`device_token` as a legitimate field name) and false negatives (schemas not named with `Output` or `Result`). The leak scanner uses `ts-morph` to walk each facade's AST and extract actual Zod schema output types:
```ts
// tests/unit/facade-boundary-scan.test.ts
import { describe, it, expect } from 'vitest';
import { Project, Type, VariableDeclaration } from 'ts-morph';
import { globSync } from 'glob';
// Keys we never want to expose through an output schema. Whole-key match, not substring —
// so "device_token" (legitimate device identifier) doesn't collide with "token" (auth secret).
const FORBIDDEN_OUTPUT_KEYS = new Set([
// Auth
'token', 'access_token', 'refresh_token', 'api_key', 'apiKey', 'secret',
'password', 'session_token', 'sessionToken',
// Low-level handles
'connection', 'db', 'pool', 'client', 'socket', 'stream',
// Internal URLs
'broker_url', 'api_url', 'internal_url', 'webhook_secret',
]);
// Patterns that indicate a raw filesystem path or secret-looking value
const FORBIDDEN_VALUE_PATTERNS = [
/^\/home\//,
/^\/Users\//,
/^\/var\//,
/^\/etc\//,
/^~\//,
/cm_(session|pat)_/,
];
describe('facade boundary scan (AST-based)', () => {
const project = new Project({
tsConfigFilePath: 'tsconfig.json',
});
const facadeSourceFiles = project
.getSourceFiles()
.filter(f => f.getFilePath().includes('/services/') && f.getBaseName().startsWith('facade'));
for (const sourceFile of facadeSourceFiles) {
const filePath = sourceFile.getFilePath();
it(`${filePath} — no forbidden keys in exported types`, () => {
// Walk all exported type declarations and check their shape
const exportedTypes = sourceFile.getExportedDeclarations();
for (const [exportName, declarations] of exportedTypes) {
for (const decl of declarations) {
// Get the TypeScript type for this declaration
const type = decl.getType();
assertNoForbiddenKeysInType(type, exportName, filePath);
}
}
});
it(`${filePath} — no export * statements`, () => {
const hasExportStar = sourceFile
.getExportDeclarations()
.some(d => d.isNamespaceExport());
expect(hasExportStar, `${filePath} uses export * — use named exports`).toBe(false);
});
it(`${filePath} — does not import from ui/, commands/, cli/, mcp/, entrypoints/`, () => {
const imports = sourceFile.getImportDeclarations();
for (const imp of imports) {
const spec = imp.getModuleSpecifierValue();
expect(
spec,
`${filePath} imports from forbidden layer: ${spec}`,
).not.toMatch(/^(?:\.\.?\/)*(?:ui|commands|cli|mcp|entrypoints)\//);
}
});
}
});
function assertNoForbiddenKeysInType(type: Type, contextName: string, file: string): void {
// Check object property names
if (type.isObject()) {
for (const prop of type.getProperties()) {
const name = prop.getName();
// Exact match (not substring) so `device_token` doesn't collide with `token`
if (FORBIDDEN_OUTPUT_KEYS.has(name)) {
throw new Error(
`Forbidden key "${name}" in exported type "${contextName}" at ${file}. ` +
`Output types cannot expose raw tokens, secrets, or low-level handles.`,
);
}
// Recurse into nested object types
const propType = prop.getTypeAtLocation(prop.getValueDeclarationOrThrow());
assertNoForbiddenKeysInType(propType, `${contextName}.${name}`, file);
}
}
// Check union members (e.g. `A | B`)
if (type.isUnion()) {
for (const member of type.getUnionTypes()) {
assertNoForbiddenKeysInType(member, contextName, file);
}
}
// Check array element types
if (type.isArray()) {
const elementType = type.getArrayElementType();
if (elementType) {
assertNoForbiddenKeysInType(elementType, `${contextName}[]`, file);
}
}
}
```
This test:
- **Walks actual TypeScript types** using `ts-morph`, not regex on source strings
- **Exact key matches** on the forbidden list, so `device_token` (legitimate) doesn't trip on `token` (secret)
- **Recursive** — catches nested objects, arrays, and union types
- **Covers every exported type** including ones not named `Output` or `Result`
- **Runs on every CI build** — adding a facade automatically adds test coverage
The test is slower than regex scanning (parses the TS project), but it runs once per CI build (~5 seconds for the whole service tree) and its false-positive rate is zero.
### 10.3 UI tests use mock facades
UI components test against a mock facade, never the real service:
```ts
// ui/screens/AuthScreen.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render } from '@/tests/helpers/ink-render';
import { AuthScreen } from './AuthScreen';
vi.mock('@/services/auth/facade', () => ({
loginWithDeviceCode: vi.fn().mockResolvedValue({
user: { id: 'u1', display_name: 'Test User', email: 't@e.st' },
}),
whoAmI: vi.fn().mockResolvedValue({
signed_in: false, user: null, token_source: null,
}),
}));
it('AuthScreen renders signed-in state after login', async () => {
const { lastFrame, stdin } = render(<AuthScreen />);
stdin.write('\r');
await new Promise(r => setTimeout(r, 50));
expect(lastFrame()).toContain('Signed in as Test User');
});
```
The UI test never touches SQLite, never makes a network call, never reads an environment variable. It's a pure render test. If the UI ever accidentally imports from `services/auth/device-code` directly, ESLint catches it at CI before the test runs.
## 11. What facades never expose
Explicit blocklist. Any facade output containing one of these fails the boundary scanner:
1. **Raw auth tokens** — including session tokens, PATs, API keys, refresh tokens
2. **Full API URLs** — callers learn the endpoint through the service, not as data
3. **Database handles, prepared statements, transaction objects**
4. **Filesystem paths** as strings — if UI needs to show a path to the user, the service returns a `{ user_visible_path: '~/.claudemesh/...' }` where the field name explicitly says "for display"
5. **HTTP response objects** — headers, status codes, raw bodies
6. **Function references to other services** — the facade composes internally; callers get data only
7. **Opaque handles** that require follow-up facade calls to make useful — prefer self-contained returns
8. **Error stack traces** — facades throw domain errors; stack traces go to logs via `runtime/logger.ts`
## 12. Async streams and cancellation
For operations that stream data (log tails, message streams, sync progress), facades use async iterators with explicit `AbortSignal`:
```ts
// services/stream/facade.ts
import { z } from 'zod';
import { getStreamService } from './index';
const StreamEventSchema = z.object({
type: z.enum(['message', 'peer_update', 'sync_progress']),
timestamp: z.number().int(),
payload: z.record(z.unknown()),
}).strict();
export type StreamEvent = z.infer<typeof StreamEventSchema>;
export async function* subscribeToMesh(input: {
mesh_slug: string;
signal: AbortSignal;
}): AsyncIterable<StreamEvent> {
const service = getStreamService();
const stream = service.subscribe(input.mesh_slug);
try {
input.signal.addEventListener('abort', () => stream.close(), { once: true });
for await (const raw of stream) {
if (input.signal.aborted) return;
yield StreamEventSchema.parse({
type: raw.type,
timestamp: raw.timestamp,
payload: raw.payload,
});
}
} finally {
stream.close();
}
}
```
**Cancellation rules**:
- Every async iterator facade takes an `AbortSignal` as a required input field
- The facade attaches an `abort` listener that closes the underlying stream
- The `finally` block ensures the stream closes on any exit path (early return, throw, iterator break)
- Consumers MUST pass a signal — there's no "listen forever" mode
Consumers use it like this:
```ts
// ui/screens/StreamScreen.tsx
const ctrl = new AbortController();
useEffect(() => {
(async () => {
for await (const event of subscribeToMesh({ mesh_slug, signal: ctrl.signal })) {
setEvents(prev => [...prev, event]);
}
})();
return () => ctrl.abort();
}, [mesh_slug]);
```
## 13. Errors and validation
### 13.1 Input validation
Every facade input is `unknown` in the public type signature and parsed with Zod at the boundary:
```ts
export async function doThing(input: unknown): Promise<Result> {
const parsed = InputSchema.safeParse(input);
if (!parsed.success) {
throw new InvalidInputError('specific message', parsed.error);
}
// ... use parsed.data safely
}
```
Why `unknown` instead of a typed input? Because facade callers sometimes come from dynamic sources (JSON input, command args, user config). Typing the input as `unknown` forces the facade to validate — a typed input would let a caller bypass validation with a cast.
### 13.2 Output validation
Every facade output is built explicitly (no spread, no pass-through) and `.parse()`'d through the output schema with `.strict()`. Strict mode rejects extra fields, eliminating the "class instance with matching fields" bypass.
### 13.3 Error mapping
Every facade catches all errors and maps them through `toDomainError(err)`:
- Domain errors already in the error hierarchy → returned as-is
- `ZodError` → mapped to a domain `InvalidInputError` with a generic message (never the raw Zod error, which might contain internal details)
- Node errors (`ENOTFOUND`, `ECONNREFUSED`, etc.) → mapped to `NetworkError` or similar
- Everything else → mapped to a generic `InternalError` with the raw error stored in `cause` for logging
The caller can catch specific error classes:
```ts
try {
await loginWithDeviceCode();
} catch (err) {
if (err instanceof DeviceCodeTimeoutError) {
// specific handling
} else if (err instanceof AuthNetworkError) {
// different handling
} else {
// unexpected — report to telemetry
}
}
```
### 13.4 Logging
Facades never log. Services log via `runtime/logger.ts`. This keeps the facade output deterministic (same input → same output → same thrown error), which is what makes them testable.
## 14. FAQ
### Why facades instead of tighter file naming?
Naming conventions rot. A rule "UI can only import files named `public-*.ts`" works for a week, then someone creates `helper.ts` that's "obviously meant to be public" and imports it. Facades are enforced by ESLint + dependency-cruiser + a boundary scanner test — three layers of tooling, not social pressure.
### Doesn't this add boilerplate?
~40-60 lines per service for the facade + schemas + error mapping. In exchange: testable UI, refactorable services, zero accidental leaks, zero circular imports, explicit contract that survives personnel changes. Worth it at scale.
### What about cross-service composition?
Services compose through each other's `index.ts`, not through facades. E.g., `services/mesh/publish.ts` imports `getAuthService` from `services/auth/index.ts`. Services are peers; facades are for non-service consumers.
### What about the MCP server?
MCP tool handlers run inside the service trust domain. They have access to `service-index` (cross-service composition) and their own internals, but NOT other services' internals. This keeps MCP implementations well-structured without forcing every tool to go through a UI-style facade.
### What if a facade needs to return a stream?
Async iterator with required `AbortSignal` (see §12). No callbacks, no EventEmitters, no Node streams exposed.
### What if two facades need the same internal helper?
Move the helper to `utils/` (if pure) or to a shared service (if effectful). Facades never share implementation — only types.
### How do we version facades across CLI releases?
Facade function signatures are the public contract. Breaking changes require a major version bump. Additive changes (new optional params, new optional return fields) are safe in minor releases. The boundary scanner test doubles as a regression guard — a PR that removes a field from an output schema will cause any test asserting on that field to fail.
### How do we handle complex flows with progress?
Either:
- Async iterator yielding progress events (preferred for long-running operations)
- Split into `start*` and `poll*` facade pairs where the UI polls for state
- Callbacks for progress (only progress, not state) — discouraged but allowed with explicit `signal` support
### What if a service has two types of consumers with different needs?
One facade per service. If MCP needs more than UI, the facade exposes more — and UI just doesn't call those methods. The facade's surface area is the union of all consumer needs, not the intersection.
### Can facades import from each other?
No. A facade belongs to exactly one service and imports only its own internals + other services' `index.ts`. If facade A wants to call facade B, that's a sign the logic belongs in a service, not a facade. The facade is an adapter, not an orchestrator.
### What about tests of nested service folders?
The boundary config uses `src/services/*/**/*.test.ts` which catches any depth. Tests can import from their own service's internals freely, as specified in the `service-test` rule.
### What about facades for utilities?
No. Utilities in `utils/` are pure functions; they need no facade. The facade pattern exists to bound effectful services, not pure code.
---
**End of spec.**