Spec: Federated Project Configuration

  • Status: Draft
  • Last amended: 2026-05-26 (ADR-0022 — project-reference flattening outputs AgentSpec subtree; per-agent tools in merge example)
  • Constrained by: ADR-0015, ADR-0006, ADR-0011, ADR-0022
  • Implements: packages/dsl/ (URI resolver, deep merge, overlay loader)

Purpose

This spec defines how kaged projects compose, override, and resolve configuration across multiple layers. It covers:

  1. URI prefix protocol — all path references use project:/ or config:/ prefixes. Naked strings rejected.
  2. Configuration mergingproject.yaml + project.local.yaml deep merge with nullification.
  3. Local file shadowingconfig:/ paths auto-check for .local file variants.
  4. Project silo boundary — path traversal beyond the project/config root is rejected.

This spec is not normative for:

  • Cross-project injection (ADR-0015 §6 — deferred to sandbox runtime).
  • The DSL surface for nested-project subagents (ADR-0015 §7). That surface is defined in project-dsl.md § Project-reference subagents. The merge algorithm and URI resolution rules in this spec are what that surface composes against.
  • External protocols (git:/, https:/) — future, not implemented.
  • Security ceiling enforcement (ADR-0015 §4 — deferred; will be added with remote project references).

Constraints (from ADRs)

Constraint Source
All paths use URI prefix; naked strings rejected ADR-0015 §3, Rule 1
Project root is the ceiling; no ../ escapes ADR-0015 §1, Rule 2
Named objects deep-merge; arrays replace wholesale ADR-0015 §2, Rule 3
Deep merge with nullification (set to null to remove) ADR-0015 §2
Local shadowing for config:/ paths ADR-0015 §5
Project is a self-contained silo ADR-0015 §1

File layout

<project root>/
├── .kaged/
│   ├── project.yaml              # base config (committed to VCS)
│   ├── project.local.yaml        # local overrides (gitignored, optional)
│   └── templates/
│       └── tool.md               # reachable via config:/templates/tool.md
└── prompts/
    ├── primary.md                # reachable via project:/prompts/primary.md
    └── primary.local.md          # local shadow (auto-checked for config:/ paths only)

project.local.yaml is optional. When present, it is deep-merged on top of project.yaml before schema validation. The merged result must be a valid ProjectDsl.

.gitignore convention

Projects SHOULD add to .gitignore:

.kaged/project.local.yaml

This is a convention, not enforced by the parser.


URI prefix protocol

Accepted prefixes

Prefix Resolves to Example
project:/ <project-root>/ project:/prompts/primary.md<root>/prompts/primary.md
config:/ <project-root>/.kaged/ config:/templates/tool.md<root>/.kaged/templates/tool.md

Rules

  1. Prefix or fail. All path fields require a URI prefix. Naked paths (e.g. prompts/primary.md) are rejected at parse time:

    .kaged/project.yaml:6:18
      Paths must use a URI prefix (project:/ or config:/). See ADR-0015.
      see docs/specs/federated-config.md#uri-prefix-protocol
    
  2. No escape. The path portion after the prefix must not contain .. segments that escape the resolution root. project:/../etc/passwd and config:/../../etc/passwd are rejected.

  3. No empty path. project:/ alone is invalid — the path portion must be non-empty.

  4. No double slash. project://foo is invalid. The format is prefix:/path, not prefix://path.

  5. External prefixes deferred. git:/, https:/, and any other prefixes are rejected in v1. They will be added when ADR-0015 §4 (security protocol hierarchy) is implemented.

Where prefixes are required

DSL field Accepted prefixes Notes
<agent>.system_prompt project:/, config:/ Any AgentSpec at any depth — primary, primary.subagents.<name>, etc.
<agent>.cage.fs[].path project:/ Per-agent cage mount; config:/ accepted but unusual
tasks.<name>.cwd project:/ config:/ accepted but unusual

Fields NOT subject to prefixing:

  • tasks.<name>.command — shell command string, not a path
  • plugins.<name>.source — mixed-protocol field (URLs, git URIs); prefix enforcement deferred until plugin-host ships
  • Model aliases — not paths

Resolution

interface UriParts {
  prefix: "project" | "config";
  path: string;
}

/** Parse a prefixed URI. Throws on invalid format. */
function parseUri(uri: string): UriParts;

/** Resolve a prefixed URI to an absolute filesystem path. */
function resolveUri(uri: string, projectRoot: string): string;

Examples:

  • resolveUri("project:/prompts/primary.md", "/home/user/myproject")/home/user/myproject/prompts/primary.md
  • resolveUri("config:/templates/tool.md", "/home/user/myproject")/home/user/myproject/.kaged/templates/tool.md

The resolver does NOT check file existence. File existence is a load-time concern (see project-dsl.md § Validation timing).

Zod schema

ProjectRelativePathSchema is replaced by PrefixedPathSchema:

const URI_PREFIX_RE = /^(project|config):\/(.+)$/;

export const PrefixedPathSchema = z
  .string()
  .min(1, "Path must not be empty.")
  .refine((v) => URI_PREFIX_RE.test(v), {
    message:
      "Paths must use a URI prefix (project:/ or config:/). " +
      "Naked paths are not accepted. See ADR-0015.",
  })
  .refine((v) => {
    const m = v.match(URI_PREFIX_RE);
    if (!m) return false;
    return !/(^|\/)\.\.($|\/)/.test(m[2]!);
  }, {
    message: "Paths must not escape the resolution root via '..' segments.",
  });

This replaces ProjectRelativePathSchema in all DSL fields that reference files or directories.


Configuration merging

Merge order (precedence low → high)

  1. project.yaml — base config, committed to VCS.
  2. project.local.yaml — local overrides, gitignored.

Future: parent injection as layer 3 (ADR-0015 §3, deferred).

Merge algorithm

merge(base, overlay):
  result = shallow_copy(base)
  for key in overlay:
    if overlay[key] === null:
      delete result[key]                                   // nullification
    else if is_plain_object(overlay[key]) AND is_plain_object(result[key]):
      result[key] = merge(result[key], overlay[key])       // recurse
    else:
      result[key] = overlay[key]                           // replace (scalars AND arrays)
  return result

Key behaviors:

Input Behavior
Objects Deep-merge field-by-field recursively
Arrays Overlay replaces base entirely (per ADR-0015 Rule 3)
Scalars Overlay replaces base
null Removes the key from the merged result (nullification)
Absent key Base value passes through unchanged

Overlay schema

The overlay file has the same field names and types as project.yaml, but:

  • version is forbidden. Project identity cannot be overridden locally.
  • project is forbidden. Project identity cannot be overridden locally.
  • All other top-level fields are optional. The overlay is partial by definition.
  • Any field may be null. For nullification.

If project.local.yaml contains version or project, the parser rejects it immediately:

.kaged/project.local.yaml:1:1
  project.local.yaml must not override 'version' (project identity). See ADR-0015.
  see docs/specs/federated-config.md#overlay-schema

Merge timing

Merging happens before Zod schema validation:

  1. Read .kaged/project.yaml → parse YAML → raw object A.
  2. If .kaged/project.local.yaml exists, read → parse YAML → raw object B.
  3. If B contains version or project, throw immediately.
  4. merged = deepMerge(A, B).
  5. Validate merged against ProjectDslSchema.
  6. Cross-reference validation on the validated result.

The merged result must pass ProjectDslSchema. The overlay alone does not need to — only the merged output is validated.

Worked example

.kaged/project.yaml:

version: 1
project: my-app

primary:
  model: smart-generalist
  system_prompt: project:/prompts/primary.md
  cage: disabled
  tools:
    "file.read":
      enabled: true
      parameters:
        max_results: 1000
  subagents:
    worker:
      model: low-cost-fast
      system_prompt: project:/prompts/worker.md
      cage:
        fs: [{ mode: ro, path: project:/src }]
        net: { allow: [] }
        state: ephemeral
      tools:
        "file.read": { enabled: true }

.kaged/project.local.yaml:

primary:
  model: my-local-model
  tools:
    "file.read":
      parameters:
        max_results: 500
    "debug": null
  subagents:
    worker:
      model: my-local-fast-model

Merged result:

version: 1
project: my-app

primary:
  model: my-local-model               # overridden
  system_prompt: project:/prompts/primary.md  # kept from base
  cage: disabled                       # kept from base
  tools:
    "file.read":
      enabled: true                    # kept from base (deep merge)
      parameters:
        max_results: 500               # overridden (deep merge)
    # "debug" removed by nullification
  subagents:
    worker:
      model: my-local-fast-model       # overridden (deep merge into named object)
      system_prompt: project:/prompts/worker.md  # kept from base
      cage:                            # kept from base (deep merge)
        fs: [{ mode: ro, path: project:/src }]
        net: { allow: [] }
        state: ephemeral
      tools:                           # kept from base (deep merge)
        "file.read": { enabled: true }

Local file shadowing

For paths using the config:/ prefix, the harness automatically checks for a .local variant before reading the declared file.

Shadowing rule

When loading a file at config:/prompts/agent.md:

  1. Compute shadow path: config:/prompts/agent.local.md.
  2. If the shadow file exists on disk, use it instead.
  3. If not, use the declared file.

File name insertion

The .local segment is inserted before the final extension:

Declared path Shadow path
config:/prompts/agent.md config:/prompts/agent.local.md
config:/templates/tool.yaml config:/templates/tool.local.yaml
config:/data/seed.json config:/data/seed.local.json
config:/noext config:/noext.local

Scope

  • Only config:/ paths are shadowed. project:/ paths are project files under version control — operators who want local variations use project.local.yaml to point system_prompt at a different path entirely.
  • Shadowing is transparent. The DSL still declares config:/prompts/agent.md; the resolver substitutes silently. Audit logs record which file was actually loaded.
  • Shadowing is file reads only. It applies when the harness reads a file for content (system prompts, templates). It does NOT apply to:
    • cage.fs[].path (mount declarations — the cage sees the real filesystem).
    • tasks.<name>.cwd (directory reference, not a file to read).

Resolution order (for config:/ file reads)

  1. Check <path>.local.<ext> on disk.
  2. If present → use shadow. Log: Loaded shadow: config:/prompts/agent.local.md.
  3. If absent → use declared path.

Error messages

Condition Message
Naked path (no prefix) Paths must use a URI prefix (project:/ or config:/). See ADR-0015.
Unknown prefix Unknown path prefix "foo:/". Accepted: project:/, config:/. See ADR-0015.
.. escape in path portion Paths must not escape the resolution root via '..' segments.
Empty path portion (project:/) Path after prefix must not be empty.
Double slash (project://x) Paths must use a URI prefix (project:/ or config:/). See ADR-0015.
version in overlay project.local.yaml must not override 'version' (project identity).
project in overlay project.local.yaml must not override 'project' (project identity).
Overlay YAML syntax error project.local.yaml: <yaml error details>
Merged result invalid Standard schema errors from ProjectDslSchema validation

API surface

@kaged/dsl exports

// --- URI resolution ---

/** Parsed URI parts. */
export interface UriParts {
  prefix: "project" | "config";
  path: string;
}

/** Parse a prefixed URI into parts. Throws on invalid format. */
export function parseUri(uri: string): UriParts;

/** Resolve a prefixed URI to an absolute filesystem path. */
export function resolveUri(uri: string, projectRoot: string): string;

/**
 * Compute the local shadow path for a config:/ URI.
 * Returns null if the URI is not config:/ prefixed.
 */
export function shadowPath(uri: string): string | null;

// --- Deep merge ---

/** Deep-merge overlay on top of base. Arrays replace. null nullifies. */
export function deepMergeDsl(
  base: Record<string, unknown>,
  overlay: Record<string, unknown>,
): Record<string, unknown>;

// --- Overlay loading ---

/**
 * Load project DSL from base + optional overlay.
 * Parses both YAML strings, merges, validates, cross-refs.
 */
export function loadProjectDsl(
  baseYaml: string,
  overlayYaml: string | null,
  options?: ParseDslOptions,
): ParseDslResult;

// --- Schema ---

/** Replaces ProjectRelativePathSchema. Requires project:/ or config:/ prefix. */
export const PrefixedPathSchema: ZodString;

Daemon integration

// project-init.ts — generate default project.yaml with prefixed paths
function generateDefaultProjectYaml(slug: string): string;
// Generates: system_prompt: project:/prompts/primary.md

// primary-runner.ts — resolve URI when reading prompt files
async function readProjectDsl(projectPath: string): Promise<ParsedDsl | null>;
// Now loads project.local.yaml if present, merges, then validates

// Prompt file loading — resolveUri() + shadow check for config:/ paths

Testing notes

Per ADR-0003, failing tests first.

URI prefix tests

  • Accept project:/foo.md, project:/a/b/c.md, config:/bar.md
  • Reject naked foo.md, prompts/primary.md (no prefix)
  • Reject http:/foo.md, git:/repo (unsupported prefix)
  • Reject project:/ (empty path portion)
  • Reject config:/ (empty path portion)
  • Reject project:/.., project:/../foo, config:/../../etc/passwd (escape)
  • Reject project://foo (double slash — fails prefix regex)
  • Resolution: project:/ → project root, config:/.kaged/

Deep merge tests

  • Object merge: nested fields merged recursively
  • Array replace: overlay array replaces base array entirely
  • Scalar replace: overlay scalar replaces base scalar
  • Nullification: null value removes key from result
  • Absent keys: base values pass through unchanged
  • Mixed: object with some merged, some replaced, some nullified
  • Empty overlay: returns base unchanged
  • Nested nullification: { a: { b: null } } removes only b

Overlay tests

  • project.local.yaml absent → base-only parsing (existing behavior)
  • Overlay overrides primary.model → merged result uses overlay value
  • Overlay overrides nested object field → deep merge preserves siblings
  • Overlay overrides primary.subagents.worker.model → deep merge preserves sibling fields (system_prompt, cage, tools)
  • Overlay nullifies primary.tools key → key removed from merged result
  • Overlay contains version → parse error
  • Overlay contains project → parse error
  • Overlay YAML syntax error → error referencing project.local.yaml
  • Merged result invalid (e.g. missing required field after nullification) → schema error
  • Both files use URI-prefixed paths → prefixes preserved through merge

Local shadowing tests

  • config:/prompts/foo.md with foo.local.md present on disk → resolver returns shadow path
  • config:/prompts/foo.md without foo.local.md on disk → resolver returns original path
  • project:/prompts/foo.md → never shadowed (returns original always)
  • Shadow path insertion: .md.local.md, .yaml.local.yaml, no ext → .local
  • shadowPath("config:/a.md")"config:/a.local.md"
  • shadowPath("project:/a.md")null

Example fixture tests

  • All files in docs/dsl/examples/ pass parseDsl() (existing — but updated for URI prefixes)
  • Dogfood .kaged/project.yaml passes parseDsl()

Open questions

  1. Plugin source prefix. Plugin source fields accept URLs (https://...) and relative paths (./plugins/...). Enforcing project:/ on relative plugin sources is deferred until plugin-host ships.
  2. Cascading overlays. The current spec supports exactly two layers (base + local). ADR-0015 §3 envisions parent injection as a third layer. The merge algorithm is layer-agnostic; adding layers is additive.
  3. Array-to-named-object migration. Resolved. ADR-0022 moved subagents from array to Record<string, AgentSpec> (named-object map). Named objects deep-merge field-by-field per the merge algorithm above; overlays can override individual subagent fields without replacing the entire map. No schema v2 migration needed.

Amendments

2026-05-26 — Un-defer ADR-0015 §7; nested-project DSL surface lands in project-dsl.md

ADR-0015 §7 ("Compiled Contextualization") is no longer marked deferred at this spec's purpose section. The DSL surface — how an operator declares a nested project as a subagent — is defined in project-dsl.md § Project-reference subagents. The merge algorithm, URI resolution, and silo boundary rules in this spec (sections "URI prefix protocol", "Configuration merging", "Local file shadowing") are what that surface composes against unchanged.

The cross-spec contract:

  • project-dsl.md defines the parser-visible shape of project references (path:, name:, description:, overrides:) and the compilation pass with cycle detection.
  • This spec defines the merge semantics applied to overrides: (ADR-0015 §2), the URI scheme accepted in path: (project:/ in v1; git:/ and https:/ deferred behind §4), and the silo boundary that prevents nested projects from referencing parents (§1).

Still deferred in this spec:

  • Cross-project injection (ADR-0015 §6) — a parent inserting virtual subagent definitions into a child. Distinct mechanism from project references.
  • External protocols and security ceiling (ADR-0015 §4) — gates remote project references; will land together in a later amendment.

No schema or code changes in this PR; the existing URI and merge machinery already supports the nested-project use case. The Zod schema mirror in @kaged/dsl for the new ProjectReference shape lands in a follow-up per ADR-0003.

2026-05-26 — ADR-0022: per-agent tools/cage; project-reference flattening outputs AgentSpec subtree

ADR-0022 collapses PrimaryAgent and Subagent into a single recursive AgentSpec. This affects federated config in three areas:

1. Worked example updated. The merge example now shows per-agent tools: and cage: inside primary, with subagents as a named-object map under primary. The overlay demonstrates deep-merging into a nested agent (primary.subagents.worker.model overridden while sibling fields are preserved). Project-level tools: is removed — tool overrides live on each AgentSpec.

2. Project-reference flattening output is AgentSpec subtree. The previous amendment (ADR-0015 §7) described project-reference compilation with a _compiled wrapper shape. ADR-0022 eliminates the wrapper: a project reference at primary.subagents.<name> resolves to a plain AgentSpec whose subagents map contains the nested project's own agents, recursively. The _source annotation tracks provenance but the structural shape is uniform AgentSpec at every position. The cross-spec contract with project-dsl.md is unchanged — this spec provides the merge semantics; project-dsl.md defines the compilation pass — but the output shape is now AgentSpec, not a wrapper.

3. Open question #3 resolved. subagents moved from array to Record<string, AgentSpec> (named-object map) per ADR-0022. Named objects deep-merge field-by-field per this spec's merge algorithm; overlays can override individual subagent fields without replacing the entire map. The array-to-named-object concern is settled.

Prefix table updated. The "Where prefixes are required" table now uses <agent>.system_prompt and <agent>.cage.fs[].path to reflect that these fields appear on any AgentSpec at any depth, not only on primary or subagents.<name>.

Overlay tests updated. The "Overlay provides subagents array → replaces base array entirely" test case is replaced with "Overlay overrides primary.subagents.worker.model → deep merge preserves sibling fields" since subagents is now a named-object map, not an array.


References

  • ADR-0015 — the decision this spec implements
  • ADR-0006 — DSL format constraints
  • ADR-0011 — project portability (paths, aliases)
  • project-dsl.md — DSL schema spec (references this spec for URI and merge semantics; defines the nested-project DSL surface in § Project-reference subagents)
  • ADR-0022 — recursive agents; per-agent tools and cage; project-reference flattening shape
  • agent-tooling.md — tool config resolution (implements ADR-0015 merge for tools)