Procházet zdrojové kódy

fix: extend the schema cross-check to nested key paths; scope the catalog framing

Review findings on the config catalog:

The schema-subset check compared only top-level z.object keys against
top-level type members, so a nested loader-accepted key (agents[].id,
agentOptions.model, capabilities.*) missing from the declared type would
pass the gate unseen. The walk now collects nested object/array
compositions as key paths and resolves each against the declared config
type — through interfaces (heritage included), aliases, literals,
intersections, unions, arrays, indexed access, Partial-style wrappers, and
type references across package-local and workspace imports (re-export
chains included). The check stays presence-only and one-directional, and
only a definite miss is a violation: a path crossing a type the walk
cannot enumerate (an external package's) is skipped, never mis-reported.
The recursion guard applies at named declarations only — a structural
first child shares its span start with its parent, so a span-keyed guard
on every node mistakes ordinary descent for a cycle and silently turns
definite misses into unknowns.

The page and RFC framing also overstated the catalog as the exact
cordis.yml-settable surface: the paste is the plugin's full declared
config type, and a field the runtime schema deliberately excludes (the ACP
bridge's test-injected stream) is a runtime-only seam its own JSDoc marks.
Both now say so.

Five spec cases pin the new behavior: nested hidden key, workspace
intersection via star re-export, Partial wrapper, indexed-access
composition, and the external-type unknown path.
Tianyi Cui před 2 měsíci
rodič
revize
fb3f3a69ec

+ 2 - 2
docs/config-catalog.md

@@ -3,9 +3,9 @@
 
 # Plugin Config Catalog
 
-Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). This is the **deployment**-axis reference — the wiring a plugin author works against is the cordis [events](cordis-catalog/events.md) + [services](cordis-catalog/services.md) catalogs, the model-facing tool schemas are the [tool catalog](tool-catalog/tools.md), and [core-data-structures/](core-data-structures/core.md) documents the types these declarations reference.
+Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). The paste is the plugin's full declared config type — a field the runtime schema deliberately excludes is a runtime-only seam (its own JSDoc says so) and is not settable from `cordis.yml`. This is the **deployment**-axis reference — the wiring a plugin author works against is the cordis [events](cordis-catalog/events.md) + [services](cordis-catalog/services.md) catalogs, the model-facing tool schemas are the [tool catalog](tool-catalog/tools.md), and [core-data-structures/](core-data-structures/core.md) documents the types these declarations reference.
 
-This file is GENERATED from source (`scripts/gen-config-catalog.ts`) and verified fresh by `pnpm run verify-config-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks use a `ts config-catalog` fence (skipped by doc-typecheck, since a lone declaration referencing imports is not standalone-compilable). The generator also cross-checks the runtime schemastery schema against the pasted declaration — every schema-validated key must be a declared member — so the paste cannot hide a loader-accepted field.
+This file is GENERATED from source (`scripts/gen-config-catalog.ts`) and verified fresh by `pnpm run verify-config-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks use a `ts config-catalog` fence (skipped by doc-typecheck, since a lone declaration referencing imports is not standalone-compilable). The generator also cross-checks the runtime schemastery schema against the pasted declaration — every schema-validated key, nested keys included, must be locatable on the declared config type — so the paste cannot hide a loader-accepted field.
 
 A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` tree must also load providers for those services. Scope is the harness tier (`packages/`); the vendored cordis plugins a config tree may also load (`hmr`, the console logger, …) are pinned upstream source ([vendoring policy](../vendor/README.md)) and not catalogued here.
 

+ 2 - 2
docs/rfc/implemented/process/2026-07-06-generated-config-catalog.md

@@ -8,7 +8,7 @@ The config surface — the exact set of fields a `cordis.yml` entry's `config:`
 
 ## Decision
 
-Generate the catalog from source: `scripts/gen-config-catalog.ts` emits [docs/config-catalog.md](../../../config-catalog.md), one section per configurable package containing the VERBATIM config declaration — the `export interface Config` (or equivalently named type) with its JSDoc, pasted as-is in a ` ```ts config-catalog ` fence — plus a `Requires:` line (the plugin's `inject`), a `Depends on:` line resolving every type name the paste references, and a source pointer. Package-local referenced types are pasted transitively into the same fence; another plugin's config type links to that plugin's section; names in the cordis catalog's shared `LINK_MAP` link to core-data-structures; any other workspace type links to its source; an external type is named with its module. It mirrors the `gen-cordis-catalog` pattern exactly: `--write` regenerates, `--check` (`verify-config-catalog`, inside `doc-sync`) fails if the committed file is stale, output is deterministic, the file is a build artifact never hand-edited.
+Generate the catalog from source: `scripts/gen-config-catalog.ts` emits [docs/config-catalog.md](../../../config-catalog.md), one section per configurable package containing the VERBATIM config declaration — the `export interface Config` (or equivalently named type) with its JSDoc, pasted as-is in a ` ```ts config-catalog ` fence — plus a `Requires:` line (the plugin's `inject`), a `Depends on:` line resolving every type name the paste references, and a source pointer. The paste is the plugin's full declared config type: a field the runtime schema deliberately excludes is a runtime-only seam, marked as such by its own JSDoc, not a `cordis.yml`-settable knob. Package-local referenced types are pasted transitively into the same fence; another plugin's config type links to that plugin's section; names in the cordis catalog's shared `LINK_MAP` link to core-data-structures; any other workspace type links to its source; an external type is named with its module. It mirrors the `gen-cordis-catalog` pattern exactly: `--write` regenerates, `--check` (`verify-config-catalog`, inside `doc-sync`) fails if the committed file is stale, output is deterministic, the file is a build artifact never hand-edited.
 
 Pure AST generation is correct here for the same reason it is for the events/services catalog and NOT for the tool catalog: a config type is a static declaration and every schemastery schema in the repo is a static `z.object`/`z.intersect` literal, so the source is the whole truth — nothing about the config surface is runtime-composed.
 
@@ -17,7 +17,7 @@ Specific choices:
 - **The config type is the second-parameter type.** What the catalog documents is the declared type of `apply(ctx, config)` / the service constructor's `(ctx, config)` — the value cordis actually passes — not a `Config` export located by naming convention. This is what makes the walk total: it works for interfaces named `AcpConfig` or `BasicCompactConfig`, for types declared in a sibling file, and for plugins with no validating schema at all.
 - **Classification is total.** Every `packages/<group>/<pkg>` entry resolves, mirroring the Loader's `unwrapExports` (`exports.default ?? exports`), to a configurable plugin, a config-free plugin, an abstract seam class, or a library — each rendered in its own section — and an unclassifiable entry hard-errors. A new package cannot be silently undocumented.
 - **Per-field JSDoc is enforced.** Every property of a pasted declaration (nested type literals included) needs non-empty JSDoc prose, or generation fails. The paste IS the documentation, so this is the same forcing function the events catalog applies via `@mode`: thin source docs fail the gate rather than yielding a thin catalog.
-- **The schema is cross-checked, one-directionally.** When a plugin declares a schemastery schema (`export const Config` / `static Config`), the generator walks it statically — object-literal keys, chained refinements, and `z.intersect` composition across workspace packages — and every schema-validated top-level key must be a declared member of the config type, so the paste cannot hide a loader-accepted field. The reverse is deliberately unchecked: a declared field may be a runtime-only seam the schema excludes (the ACP bridge's test-injected `stream`).
+- **The schema is cross-checked, one-directionally, nested keys included.** When a plugin declares a schemastery schema (`export const Config` / `static Config`), the generator walks it statically — object-literal keys and their nested object/array compositions as key paths (`agents[].id`), chained refinements, and `z.intersect` composition across workspace packages — and every schema-validated key path must be locatable on the declared config type, resolving package-local and workspace-imported types (re-export chains included), intersections, unions, utility wrappers, and indexed access. So the paste cannot hide a loader-accepted field, top-level or nested. The check is presence-only and fails loud only on a definite miss: a path crossing a type the walk cannot enumerate (an external package's type) is skipped rather than mis-reported, and dynamic-key shapes (`z.dict`) or union alternatives contribute no nested paths. The reverse direction is deliberately unchecked: a declared field may be a runtime-only seam the schema excludes (the ACP bridge's test-injected `stream`).
 - **A dedicated fence.** Pasted declarations use a ` ```ts config-catalog ` info string that `doc-typecheck` skips (a lone declaration referencing imported types is not standalone-compilable), excluded from the opt-out ratio — the same treatment the `cordis-catalog` and `persistence-catalog` fences get.
 - **A single file at `docs/config-catalog.md`**, not a one-file directory: the page serves one audience (the `cordis.yml` author) with one axis, unlike `cordis-catalog/`, which holds two sibling pages.
 

+ 120 - 0
packages/core/agent-core/tests/gen-config-catalog.spec.ts

@@ -224,6 +224,89 @@ export function apply(ctx: Context, config: Config): void {}
     }))).toThrow(/schema validates key 'hidden' but config type 'Config' declares no such member/)
   })
 
+  it('hard-errors on a NESTED schema key the config type does not declare', () => {
+    expect(() => collectConfigCatalog(make({
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+/** Fixture config. */
+export interface Config {
+  /** Entries. */
+  entries: {
+    /** Id. */
+    id: string
+  }[]
+}
+export const Config: z<Config> = z.object({ entries: z.array(z.object({ id: z.string(), ghost: z.string() })) })
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    }))).toThrow(/schema validates key 'entries\[\]\.ghost'/)
+  })
+
+  it('resolves nested keys through a workspace-imported intersection part (re-export chains included)', () => {
+    const root = makeRoot()
+    writePkg(root, 'group/dep', '@fix/dep', {
+      'src/index.ts': 'export * from \'./types.ts\'\n',
+      'src/types.ts': '/** Shared options. */\nexport interface Opts {\n  /** Model. */\n  model?: string\n}\n',
+    })
+    writePkg(root, 'group/one', '@fix/one', {
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+import type { Opts } from '@fix/dep'
+/** Fixture config. */
+export interface Config {
+  /** Entries. */
+  entries: (Opts & {
+    /** Id. */
+    id: string
+  })[]
+}
+export const Config: z<Config> = z.object({ entries: z.array(z.object({ id: z.string(), model: z.string() })) })
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    })
+    expect(() => collectConfigCatalog(root)).not.toThrow()
+  })
+
+  it('resolves nested keys through a Partial<> wrapper', () => {
+    expect(() => collectConfigCatalog(make({
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+/** Caps. */
+export interface Caps {
+  /** X. */
+  x?: boolean
+}
+/** Fixture config. */
+export interface Config {
+  /** Capabilities. */
+  capabilities?: Partial<Caps>
+}
+export const Config: z<Config> = z.object({ capabilities: z.object({ x: z.boolean() }) })
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    }))).not.toThrow()
+  })
+
+  it('leaves a nested key under an external (unresolvable) type unreported', () => {
+    expect(() => collectConfigCatalog(make({
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+import type { External } from 'some-external-pkg'
+/** Fixture config. */
+export interface Config {
+  /** Options. */
+  options?: External
+}
+export const Config: z<Config> = z.object({ options: z.object({ whatever: z.string() }) })
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    }))).not.toThrow()
+  })
+
   it('folds an intersected workspace schema into the subset check', () => {
     const root = makeRoot()
     writePkg(root, 'group/leaf', '@fix/leaf', {
@@ -259,6 +342,43 @@ export function apply(ctx: Context, config: Config): void {}
     expect(entries.find(e => e.pkg === '@fix/bundle')?.schemaComposes).toEqual(['@fix/leaf'])
   })
 
+  it('resolves composed nested keys through an indexed-access forwarder', () => {
+    const root = makeRoot()
+    writePkg(root, 'group/leaf', '@fix/leaf', {
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+/** Leaf config. */
+export interface Config {
+  /** Agents. */
+  agents: {
+    /** Id. */
+    id: string
+  }[]
+}
+/** Leaf service. */
+export default class Leaf {
+  static Config = z.object({ agents: z.array(z.object({ id: z.string() })) }) as unknown as z<Config>
+  constructor(ctx: Context, config: Config) {}
+}
+`,
+    })
+    writePkg(root, 'group/bundle', '@fix/bundle', {
+      'src/index.ts': `import type { Context } from 'cordis'
+import z from 'schemastery'
+import Leaf, { type Config as LeafConfig } from '@fix/leaf'
+/** Bundle config forwarding the leaf's agents list. */
+export interface Config {
+  /** Forwarded agents list. */
+  agents?: LeafConfig['agents']
+}
+export const Config = z.intersect([Leaf.Config]) as unknown as z<Config>
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+    })
+    expect(() => collectConfigCatalog(root)).not.toThrow()
+  })
+
   it('hard-errors when an intersected schema key is missing from the bundle config type', () => {
     const root = makeRoot()
     writePkg(root, 'group/leaf', '@fix/leaf', {

+ 257 - 30
scripts/gen-config-catalog.ts

@@ -43,10 +43,16 @@
  *   config type / a core-data-structures entry / a workspace or external
  *   import. An unresolvable name is an error, never silently unexplained.
  * - The runtime schemastery schema (`Config` export or `static Config`),
- *   when present, is walked statically (`z.object` keys, `z.intersect`
- *   composition across packages) and every schema-validated top-level key
- *   must be a declared member of the config type — the paste cannot hide a
- *   loader-accepted field. The reverse is deliberately NOT checked: a
+ *   when present, is walked statically — `z.object` keys, nested object/array
+ *   compositions as key PATHS (`agents[].id`), and `z.intersect` composition
+ *   across packages — and every schema-validated key path must be locatable
+ *   on the declared config type, resolving package-local and
+ *   workspace-imported types, re-export chains, intersections, utility
+ *   wrappers, and indexed access. The paste cannot hide a loader-accepted
+ *   field, top-level or nested. A path that crosses a type the walk cannot
+ *   enumerate (an external package's type) is skipped, never mis-reported,
+ *   and nested keys under dynamic-key shapes (`z.dict`) or union alternatives
+ *   contribute no paths. The reverse direction is deliberately NOT checked: a
  *   declared field may be a runtime-only seam the schema excludes (e.g. the
  *   ACP bridge's test-injected `stream`).
  *
@@ -119,8 +125,8 @@ export interface CatalogEntry {
   pastes?: Paste[]
   /** References the pastes leave unresolved locally (kind `config`). */
   refs?: TypeRef[]
-  /** Top-level keys of the runtime schema, `null` when no schema exists or
-   * composition is still pending resolution (kind `config`). */
+  /** Top-level keys and nested key paths (`agents[].id`) of the runtime
+   * schema, `null` when no schema exists (kind `config`). */
   schemaKeys?: string[] | null
   /** Package names whose schemas an intersect composes (kind `config`). */
   schemaComposes?: string[]
@@ -258,17 +264,197 @@ function checkMemberDocs(ctx: FileCtx, decl: TypeDecl, violations: string[]): vo
   else walkNested(decl.type, decl.name.text)
 }
 
-/** Top-level property names of the config type (for the schema-subset check). */
-function topLevelMembers(decl: TypeDecl): Set<string> | null {
-  if (ts.isInterfaceDeclaration(decl)) {
-    return new Set(decl.members.filter(ts.isPropertySignature).map(m => m.name.getText()))
+/** Cross-file resolution context for the schema-path check. */
+interface World {
+  scanRoot: string
+  cache: Map<string, FileCtx>
+  /** Workspace package name → repo-relative package dir. */
+  pkgDirByName: Map<string, string>
+}
+
+/** How a schema key path fared against the declared config type: definitely
+ * present, definitely absent, or crossing a shape the walk cannot enumerate
+ * (only `missing` is a violation — `unknown` must never mis-report). */
+type PathLookup = 'found' | 'missing' | 'unknown'
+
+/** One step of a schema key path: a named member, or an array-element hop. */
+type PathStep = { member: string } | { array: true }
+
+/** Parse a schema key path (`agents[].id`) into member/array steps. */
+function parsePath(path: string): PathStep[] {
+  const steps: PathStep[] = []
+  for (const seg of path.split('.')) {
+    let name = seg
+    let arrays = 0
+    while (name.endsWith('[]')) {
+      name = name.slice(0, -2)
+      arrays += 1
+    }
+    steps.push({ member: name })
+    for (let i = 0; i < arrays; i += 1) steps.push({ array: true })
   }
-  if (ts.isTypeLiteralNode(decl.type)) {
-    return new Set(decl.type.members.filter(ts.isPropertySignature).map(m => m.name.getText()))
+  return steps
+}
+
+/** Load a package-relative import target as a FileCtx. */
+function loadRelative(world: World, from: FileCtx, specifier: string): FileCtx {
+  const abs = resolve(dirname(from.abs), specifier)
+  const rel = from.rel.slice(0, from.rel.lastIndexOf('/') + 1) + specifier.replace(/^\.\//, '')
+  return loadFile(abs, rel, world.cache)
+}
+
+/** Find a type declaration EXPORTED (directly or via re-export chains) from a
+ * file, following `export … from './x.ts'` and `export * from './x.ts'`. */
+function findExportedTypeDecl(world: World, ctx: FileCtx, name: string, seen = new Set<string>()): { decl: TypeDecl; ctx: FileCtx } | null {
+  const key = `${ctx.abs}#${name}`
+  if (seen.has(key)) return null
+  seen.add(key)
+  const local = findTypeDecl(ctx, name)
+  if (local) return { decl: local, ctx }
+  for (const stmt of ctx.sf.statements) {
+    if (!ts.isExportDeclaration(stmt) || !stmt.moduleSpecifier || !ts.isStringLiteral(stmt.moduleSpecifier)) continue
+    const spec = stmt.moduleSpecifier.text
+    if (!spec.startsWith('.') || !spec.endsWith('.ts')) continue
+    let lookFor: string | null = null
+    if (!stmt.exportClause) {
+      lookFor = name // export * from './x.ts'
+    } else if (ts.isNamedExports(stmt.exportClause)) {
+      const el = stmt.exportClause.elements.find(e => e.name.text === name)
+      if (el) lookFor = (el.propertyName ?? el.name).text
+    }
+    if (lookFor === null) continue
+    const hit = findExportedTypeDecl(world, loadRelative(world, ctx, spec), lookFor, seen)
+    if (hit) return hit
   }
   return null
 }
 
+/** Resolve a referenced type NAME to its declaration: declared locally, via a
+ * package-relative import, or via a workspace-package import (entry file +
+ * re-export chains). `'unknown'` = external or otherwise out of reach. */
+function declForTypeName(world: World, ctx: FileCtx, name: string): { decl: TypeDecl; ctx: FileCtx } | 'unknown' {
+  const local = findTypeDecl(ctx, name)
+  if (local) return { decl: local, ctx }
+  const imp = ctx.imports.get(name)
+  if (!imp) return 'unknown'
+  if (imp.specifier.startsWith('.')) {
+    if (!imp.specifier.endsWith('.ts')) return 'unknown'
+    return findExportedTypeDecl(world, loadRelative(world, ctx, imp.specifier), imp.imported) ?? 'unknown'
+  }
+  const dir = world.pkgDirByName.get(imp.specifier)
+  if (dir === undefined) return 'unknown'
+  const entryRel = `${dir}/src/index.ts`
+  let entry: FileCtx
+  try {
+    entry = loadFile(resolve(world.scanRoot, entryRel), entryRel, world.cache)
+  } catch {
+    // A workspace package without a readable entry is reported by its own
+    // classification pass; for a lookup it is merely out of reach.
+    return 'unknown'
+  }
+  return findExportedTypeDecl(world, entry, imp.imported) ?? 'unknown'
+}
+
+/** Utility wrappers that pass a member lookup through to their type argument. */
+const PASSTHROUGH_WRAPPERS = new Set(['Partial', 'Required', 'Readonly', 'NonNullable'])
+
+/**
+ * Walk a schema key path against a declared type. This is a PRESENCE check,
+ * not a shape check: it answers "does the declared config type have a member
+ * here", resolving interfaces (heritage included), type aliases, literals,
+ * intersections, unions, arrays, indexed access, pass-through utility
+ * wrappers, and type references across package-local and workspace imports.
+ * Anything it cannot see through resolves `'unknown'`, never `'missing'`.
+ */
+function lookupPath(world: World, ctx: FileCtx, node: ts.Node, steps: PathStep[], seen: Set<string>): PathLookup {
+  if (steps.length === 0) return 'found'
+  // Guard recursion at NAMED declarations only — the sole way a walk can loop
+  // (a recursive interface/alias). Structural nodes must not be guarded: a
+  // first child shares `.pos` with its parent, so a span-keyed guard there
+  // would mistake ordinary descent for a cycle.
+  if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node)) {
+    const key = `${ctx.abs}:${node.pos}:${steps.length}`
+    if (seen.has(key)) return 'unknown' // recursive type — bail rather than loop
+    seen.add(key)
+  }
+  const step = steps[0]
+  if (step === undefined) return 'found'
+  // Combine branch results: any found wins, else any unknown taints, else missing.
+  const combine = (results: PathLookup[]): PathLookup => {
+    if (results.includes('found')) return 'found'
+    if (results.includes('unknown')) return 'unknown'
+    return 'missing'
+  }
+  const intoMembers = (members: ts.NodeArray<ts.TypeElement>): PathLookup | null => {
+    if (!('member' in step)) return null
+    for (const m of members) {
+      if (!ts.isPropertySignature(m) || m.name.getText(ctx.sf) !== step.member) continue
+      if (steps.length === 1) return 'found'
+      return m.type ? lookupPath(world, ctx, m.type, steps.slice(1), seen) : 'unknown'
+    }
+    return null // not among these members; caller consults heritage/parts
+  }
+  if (ts.isInterfaceDeclaration(node)) {
+    if (!('member' in step)) return 'unknown' // an array step cannot land on an interface
+    const direct = intoMembers(node.members)
+    if (direct !== null) return direct
+    const bases: PathLookup[] = []
+    for (const clause of node.heritageClauses ?? []) {
+      for (const base of clause.types) {
+        if (!ts.isIdentifier(base.expression)) {
+          bases.push('unknown')
+          continue
+        }
+        const resolved = declForTypeName(world, ctx, base.expression.text)
+        bases.push(resolved === 'unknown' ? 'unknown' : lookupPath(world, resolved.ctx, resolved.decl, steps, seen))
+      }
+    }
+    return bases.length ? combine(bases) : 'missing'
+  }
+  if (ts.isTypeAliasDeclaration(node)) return lookupPath(world, ctx, node.type, steps, seen)
+  if (ts.isTypeLiteralNode(node)) {
+    if (!('member' in step)) return 'unknown'
+    return intoMembers(node.members) ?? 'missing'
+  }
+  if (ts.isParenthesizedTypeNode(node)) return lookupPath(world, ctx, node.type, steps, seen)
+  if (ts.isIntersectionTypeNode(node)) {
+    return combine(node.types.map(t => lookupPath(world, ctx, t, steps, seen)))
+  }
+  if (ts.isUnionTypeNode(node)) {
+    // Presence on a union is only definite when every branch agrees.
+    const results = node.types.map(t => lookupPath(world, ctx, t, steps, seen))
+    if (results.every(r => r === 'found')) return 'found'
+    if (results.every(r => r === 'missing')) return 'missing'
+    return 'unknown'
+  }
+  if (ts.isArrayTypeNode(node)) {
+    return 'array' in step ? lookupPath(world, ctx, node.elementType, steps.slice(1), seen) : 'unknown'
+  }
+  if (ts.isTypeOperatorNode(node)) return lookupPath(world, ctx, node.type, steps, seen)
+  if (ts.isIndexedAccessTypeNode(node)) {
+    const index = node.indexType
+    if (ts.isLiteralTypeNode(index) && ts.isStringLiteral(index.literal)) {
+      return lookupPath(world, ctx, node.objectType, [{ member: index.literal.text }, ...steps], seen)
+    }
+    return 'unknown'
+  }
+  if (ts.isTypeReferenceNode(node)) {
+    let head: ts.EntityName = node.typeName
+    while (ts.isQualifiedName(head)) head = head.left
+    const name = head.text
+    if (PASSTHROUGH_WRAPPERS.has(name) && node.typeArguments?.[0]) {
+      return lookupPath(world, ctx, node.typeArguments[0], steps, seen)
+    }
+    if ((name === 'Array' || name === 'ReadonlyArray') && node.typeArguments?.[0]) {
+      return 'array' in step ? lookupPath(world, ctx, node.typeArguments[0], steps.slice(1), seen) : 'unknown'
+    }
+    if (!ts.isIdentifier(node.typeName)) return 'unknown' // namespace-qualified: out of reach
+    const resolved = declForTypeName(world, ctx, name)
+    return resolved === 'unknown' ? 'unknown' : lookupPath(world, resolved.ctx, resolved.decl, steps, seen)
+  }
+  return 'unknown'
+}
+
 /** Unwrap `as` / `satisfies` / parenthesized wrappers around an expression. */
 function unwrapExpr(expr: ts.Expression): ts.Expression {
   let e = expr
@@ -277,11 +463,14 @@ function unwrapExpr(expr: ts.Expression): ts.Expression {
 }
 
 /**
- * Statically walk a schemastery schema expression to its top-level object
- * keys plus the packages whose schemas an intersect composes. Handles the
- * shapes the repo declares — `z.object({…})` (possibly behind chained calls)
- * and `z.intersect([X.Config, …])` — and hard-errors on anything else, so a
- * schema the walk cannot see fails the gate instead of silently thinning it.
+ * Statically walk a schemastery schema expression to its key paths plus the
+ * packages whose schemas an intersect composes. A key path is the top-level
+ * key or a nested path through object/array compositions (`agents[].id`).
+ * Handles the shapes the repo declares — `z.object({…})` (possibly behind
+ * chained calls) and `z.intersect([X.Config, …])` — and hard-errors on
+ * anything else, so a schema the walk cannot see fails the gate instead of
+ * silently thinning it. Nested values that are neither `object` nor `array`
+ * compositions (primitives, unions, dynamic-key dicts) contribute no paths.
  */
 function walkSchemaExpr(
   ctx: FileCtx,
@@ -291,6 +480,28 @@ function walkSchemaExpr(
 ): { keys: string[]; composes: string[] } {
   const keys: string[] = []
   const composes: string[] = []
+  // Nested paths under one object property's VALUE expression: recurse through
+  // chained refinements toward the base call, descending into object/array.
+  const collectValuePaths = (value: ts.Expression, base: string): void => {
+    const call = unwrapExpr(value)
+    if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) return
+    const method = call.expression.name.text
+    if (method === 'object' && call.arguments[0] && ts.isObjectLiteralExpression(call.arguments[0])) {
+      for (const prop of call.arguments[0].properties) {
+        if (!ts.isPropertyAssignment(prop)) continue
+        const key = ts.isStringLiteral(prop.name) ? prop.name.text : prop.name.getText(ctx.sf)
+        keys.push(`${base}.${key}`)
+        collectValuePaths(prop.initializer, `${base}.${key}`)
+      }
+      return
+    }
+    if (method === 'array' && call.arguments[0]) {
+      collectValuePaths(call.arguments[0], `${base}[]`)
+      return
+    }
+    const inner = unwrapExpr(call.expression.expression)
+    if (ts.isCallExpression(inner)) collectValuePaths(inner, base)
+  }
   const visit = (e: ts.Expression): void => {
     const call = unwrapExpr(e)
     if (!ts.isCallExpression(call) || !ts.isPropertyAccessExpression(call.expression)) {
@@ -301,7 +512,9 @@ function walkSchemaExpr(
     if (method === 'object' && call.arguments[0] && ts.isObjectLiteralExpression(call.arguments[0])) {
       for (const prop of call.arguments[0].properties) {
         if (ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) {
-          keys.push(ts.isStringLiteral(prop.name) ? prop.name.text : prop.name.getText(ctx.sf))
+          const key = ts.isStringLiteral(prop.name) ? prop.name.text : prop.name.getText(ctx.sf)
+          keys.push(key)
+          if (ts.isPropertyAssignment(prop)) collectValuePaths(prop.initializer, key)
         } else {
           violations.push(`${where}: schema object property '${prop.getText(ctx.sf)}' is not a plain key.`)
         }
@@ -410,10 +623,23 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
   const cache = new Map<string, FileCtx>()
   const entries: CatalogEntry[] = []
 
+  // Pre-pass: package name → dir, so schema-path lookups can follow
+  // workspace-package imports while individual packages are still being walked.
+  const pkgDirByName = new Map<string, string>()
+  const manifests: { dir: string; pkg: string }[] = []
   for (const manifestRel of globSync('packages/*/*/package.json', { cwd: scanRoot }).sort()) {
     const dir = manifestRel.slice(0, -'/package.json'.length)
     const pkg = (JSON.parse(readFileSync(resolve(scanRoot, manifestRel), 'utf8')) as { name?: string }).name
-    if (!pkg) { violations.push(`${manifestRel} has no "name".`); continue }
+    if (!pkg) {
+      violations.push(`${manifestRel} has no "name".`)
+      continue
+    }
+    pkgDirByName.set(pkg, dir)
+    manifests.push({ dir, pkg })
+  }
+  const world: World = { scanRoot, cache, pkgDirByName }
+
+  for (const { dir, pkg } of manifests) {
     const entryRel = `${dir}/src/index.ts`
     let ctx: FileCtx
     try {
@@ -513,8 +739,10 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
     }
   }
 
-  // Second phase: fold composed schemas' keys in, then check every
-  // schema-validated key is a declared member of the config type.
+  // Second phase: fold composed schemas' key paths in, then walk every
+  // schema-validated path against the declared config type. Only a definite
+  // miss is a violation — a path through a shape the walk cannot enumerate
+  // stays silent rather than mis-reporting.
   const byName = new Map(entries.map(e => [e.pkg, e]))
   for (const entry of entries) {
     if (entry.kind !== 'config' || entry.schemaKeys === null || entry.schemaKeys === undefined) continue
@@ -537,15 +765,14 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
     const mainPaste = entry.pastes?.[0]
     const mainFile = mainPaste?.source.split(':')[0]
     const mainCtx = mainFile !== undefined ? cache.get(resolve(scanRoot, mainFile)) : undefined
-    const mainDecl = mainCtx && entry.configTypeName ? findTypeDecl(mainCtx, entry.configTypeName) : null
-    const members = mainDecl ? topLevelMembers(mainDecl) : null
-    if (!members) {
-      violations.push(`${entry.pkg}: cannot enumerate the members of config type '${entry.configTypeName}' for the schema-subset check.`)
+    const mainDecl = mainCtx && entry.configTypeName !== undefined ? findTypeDecl(mainCtx, entry.configTypeName) : null
+    if (!mainCtx || !mainDecl) {
+      violations.push(`${entry.pkg}: cannot locate config type '${entry.configTypeName ?? ''}' for the schema-path check.`)
       continue
     }
-    for (const key of allKeys) {
-      if (!members.has(key)) {
-        violations.push(`${entry.pkg}: schema validates key '${key}' but config type '${entry.configTypeName}' declares no such member — the catalog paste would hide a loader-accepted field.`)
+    for (const keyPath of allKeys) {
+      if (lookupPath(world, mainCtx, mainDecl, parsePath(keyPath), new Set()) === 'missing') {
+        violations.push(`${entry.pkg}: schema validates key '${keyPath}' but config type '${entry.configTypeName ?? ''}' declares no such member — the catalog paste would hide a loader-accepted field.`)
       }
     }
   }
@@ -607,9 +834,9 @@ export function render(entries: CatalogEntry[]): string {
     '',
     '# Plugin Config Catalog',
     '',
-    'Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). This is the **deployment**-axis reference — the wiring a plugin author works against is the cordis [events](cordis-catalog/events.md) + [services](cordis-catalog/services.md) catalogs, the model-facing tool schemas are the [tool catalog](tool-catalog/tools.md), and [core-data-structures/](core-data-structures/core.md) documents the types these declarations reference.',
+    'Every `config:` block a `cordis.yml` entry can set: for each loadable harness package, the verbatim config declaration (JSDoc included) its `apply` function or service constructor receives, with every referenced type pasted alongside (package-local types) or linked (everything else). The paste is the plugin\'s full declared config type — a field the runtime schema deliberately excludes is a runtime-only seam (its own JSDoc says so) and is not settable from `cordis.yml`. This is the **deployment**-axis reference — the wiring a plugin author works against is the cordis [events](cordis-catalog/events.md) + [services](cordis-catalog/services.md) catalogs, the model-facing tool schemas are the [tool catalog](tool-catalog/tools.md), and [core-data-structures/](core-data-structures/core.md) documents the types these declarations reference.',
     '',
-    'This file is GENERATED from source (`scripts/gen-config-catalog.ts`) and verified fresh by `pnpm run verify-config-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks use a `ts config-catalog` fence (skipped by doc-typecheck, since a lone declaration referencing imports is not standalone-compilable). The generator also cross-checks the runtime schemastery schema against the pasted declaration — every schema-validated key must be a declared member — so the paste cannot hide a loader-accepted field.',
+    'This file is GENERATED from source (`scripts/gen-config-catalog.ts`) and verified fresh by `pnpm run verify-config-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks use a `ts config-catalog` fence (skipped by doc-typecheck, since a lone declaration referencing imports is not standalone-compilable). The generator also cross-checks the runtime schemastery schema against the pasted declaration — every schema-validated key, nested keys included, must be locatable on the declared config type — so the paste cannot hide a loader-accepted field.',
     '',
     'A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` tree must also load providers for those services. Scope is the harness tier (`packages/`); the vendored cordis plugins a config tree may also load (`hmr`, the console logger, …) are pinned upstream source ([vendoring policy](../vendor/README.md)) and not catalogued here.',
     '',