Spec: Federated Project Configuration
- Status: Draft
- Last amended: 2026-05-26 (ADR-0022 — project-reference flattening outputs
AgentSpecsubtree; 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:
- URI prefix protocol — all path references use
project:/orconfig:/prefixes. Naked strings rejected. - Configuration merging —
project.yaml+project.local.yamldeep merge with nullification. - Local file shadowing —
config:/paths auto-check for.localfile variants. - 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
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-protocolNo escape. The path portion after the prefix must not contain
..segments that escape the resolution root.project:/../etc/passwdandconfig:/../../etc/passwdare rejected.No empty path.
project:/alone is invalid — the path portion must be non-empty.No double slash.
project://foois invalid. The format isprefix:/path, notprefix://path.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 pathplugins.<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.mdresolveUri("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)
project.yaml— base config, committed to VCS.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:
versionis forbidden. Project identity cannot be overridden locally.projectis 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:
- Read
.kaged/project.yaml→ parse YAML → raw object A. - If
.kaged/project.local.yamlexists, read → parse YAML → raw object B. - If B contains
versionorproject, throw immediately. merged = deepMerge(A, B).- Validate
mergedagainstProjectDslSchema. - 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:
- Compute shadow path:
config:/prompts/agent.local.md. - If the shadow file exists on disk, use it instead.
- 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 useproject.local.yamlto pointsystem_promptat 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)
- Check
<path>.local.<ext>on disk. - If present → use shadow. Log:
Loaded shadow: config:/prompts/agent.local.md. - 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:
nullvalue 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 onlyb
Overlay tests
project.local.yamlabsent → 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.toolskey → 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.mdwithfoo.local.mdpresent on disk → resolver returns shadow pathconfig:/prompts/foo.mdwithoutfoo.local.mdon disk → resolver returns original pathproject:/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/passparseDsl()(existing — but updated for URI prefixes) - Dogfood
.kaged/project.yamlpassesparseDsl()
Open questions
- Plugin source prefix. Plugin
sourcefields accept URLs (https://...) and relative paths (./plugins/...). Enforcingproject:/on relative plugin sources is deferred until plugin-host ships. - 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.
Array-to-named-object migration.Resolved. ADR-0022 movedsubagentsfrom array toRecord<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.mddefines 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 inpath:(project:/in v1;git:/andhttps:/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)