Persona layer stacking
Design rationale for the layered-persona feature on
quorum.yml. Covers why the layered shape exists, why files are read at parse time, and why paths resolve against process CWD instead of the yaml file's parent directory.
Why layers at all
Before layers, persona: was Option<String>. Operators with
multi-agent fleets ran into a familiar problem: every agent
shares a 4–30 line block describing the deliberation style, the
output format, or the safety guardrails — and those blocks drift
between agents the moment one operator forgets to copy a change
across all of them.
The pre-layer workarounds were:
- Manually re-paste the shared block on every change (works for 2 agents, breaks for 12).
- Collapse everything into one mega-string (loses readability; PR diffs become unreviewable).
- Build a custom binary that loads the yaml, post-processes
persona, and re-serialises (every consumer reinvents this).
Stacked layers move (3) into the SDK. The yaml stays readable
(one layer per concern), shared blocks live in shared files
(single source of truth), and the resolved persona is still
plain Option<String> so every downstream call site —
MultiAgentRunner, MultiAgentStatusServer, the chat
endpoint, the Claude --append-system-prompt plumbing — keeps
working unchanged.
Why parse-time file reads, not lazy reads
The first design considered was deferring md file reads until
the agent first needed its persona — AgentConfig would hold
Option<PersonaSpec> and a resolver method would walk the
layers on demand. Two reasons that lost:
- Failure timing. Lazy reads surface a missing file somewhere deep in the runtime — the agent is already connected to NATS, has advertised a half-baked tool list, and only fails when the first job arrives. Eager reads surface the failure at fleet boot, in the same log line as the agent name, before the orchestrator has heard of it.
- Type churn.
AgentConfigis consumed at 15+ sites today (the worker builder, the control plane, the per-agent dashboard, the chat handler, the test fixtures, the JSON Schema generator). ChangingpersonafromOption<String>toOption<PersonaSpec>would mean touching every one of them. The custom deserializer keeps the public type stable and contains the new behaviour to one function.
The cost is that the deserializer does I/O. That's normally
considered bad form, but the I/O is bounded (one file read per
layer, no fan-out), happens once per fleet boot, and fits the
existing pattern of quorum validate / quorum serve already
doing file I/O against quorum.yml and ~/.nsed/agent.creds.
Why CWD-relative paths
The deserializer has no yaml-path context. By the time
serde_yaml::from_str calls the field deserializer, the source
path is gone — the parser only sees the yaml string and the
field's serde annotation.
Two ways to add yaml-path context:
- (a) Wrap the parse path: instead of calling
serde_yaml::from_str(yaml)directly, walk the parsedserde_yaml::Value, locate everypersona:field, resolve paths against the yaml's parent, then deserialise. Two-pass. Slow, duplicate logic, fragile when yaml shape changes. - (b) Thread the yaml path through a thread-local or
Deserializernewtype that the custom function reads viadeserializer.state. Possible withserde::de::DeserializeSeedbut pulls a lot of plumbing for one feature.
Both add code surface that needs to be carried forever. The CWD
approach is what every other CLI does (Docker bind-mounts,
cargo build paths, shell scripts): if the operator wants
paths to resolve from a specific directory, they cd there
first or use absolute paths. Documented in
[reference/persona-yaml-shapes.md].
Concretely:
- Operators running
quorum servefrom a project root (typical) use./prompts/x.mdand the path works. - Service units / Docker entrypoints set
WorkingDirectory=/WORKDIRto the directory holdingquorum.yml— paths still work. - CI jobs that invoke
quorum servefrom a parent dir use absolute paths or a wrapper shell script thatcds first.
If a future feature needs yaml-relative paths (e.g.
include: for sub-yaml composition), it'll be worth wiring
option (a) globally. For one field with one shape, CWD is
enough.
What's deliberately out of scope
- Template expansion inside md files. Layers concatenate raw
bytes. Adding
${ENV_VAR}or{{handlebars}}opens a much larger surface (escaping, recursion, error reporting) and every variable expansion already has a place to live (env vars before invocation, or a future include mechanism). - Layer reordering / overrides via control plane. The control
plane patches
AgentConfig.personaas a single string today — same shape, same wire format. Layered editing would need a whole new patch grammar, a much larger surface than this feature covers. - Roundtrip preservation of the layered shape. Serialising
an
AgentConfigback to yaml writes the resolved string. If operators want to dump-and-re-edit a layered persona, they do so against the sourcequorum.yml, not against a serialised snapshot ofAgentConfig.
See also
- [how-to/compose-persona-from-shared-files.md] — the operator-facing recipe.
- [reference/persona-yaml-shapes.md] — formal grammar + error modes.