The mutation surface is the set of source locations in the inner harness that zicato is allowed to rewrite. It is annotated — every mutable location is marked in the source with a comment-form marker — not free-form. The patch proposer addresses mutations by stable id; the applier resolves each id to one location and rewrites only what the id covers.
This document specifies:
- The two marker forms (span-level, file-level) and their syntax.
- The AST resolution rules that map a marker to a source location.
- The shape of
MutationPointand theHarnessAdapter.mutation_points()protocol method. - The
zicato mutationsaudit CLI. - The applier's validator constraints.
- The interaction with the proposer brief's
## Forbiddenlist.
The "why annotated, not free-form" argument lives in RATIONALE.md; this document is the contract.
A meta-harness has a safety-vs-reach trade-off:
- Free-form source edits maximise reach (the proposer can change anything) at the cost of safety (the proposer can break anything). Validating "this still works" against a multi-agent system is extremely hard.
- Pure span-level annotated mutations maximise safety (only marked strings move) at the cost of reach (related strings in the same file may need to move together but can't be addressed as a group).
zicato takes the middle path. Span markers are the default. A file
marker covers the cases where a whole module — e.g. a prompts.py
with several closely-related templates — should move as one unit.
Both forms produce MutationPoints with stable ids; both are
addressed by the proposer through those ids; neither lets the proposer
rewrite an unmarked file.
Both forms are Python comments. The walker is a Python AST visitor; the comments are recognised by their exact prefix and the AST node they immediately precede or annotate.
A span marker is a comment on the line immediately above a string-literal assignment or a string-valued keyword argument inside a call. The comment has the form:
# zicato:mutable id="<stable-id>" [key="value" ...]
id is required and globally unique within a single registration.
Any trailing key="value" pairs are parsed verbatim into the
MutationPoint.metadata mapping; they have no effect on AST
resolution. The conventional keys the validator and proposer act on
are required_placeholders (comma-separated f-string-style
placeholders the rewritten content must preserve), min / max
(numeric bounds for set_numeric patches), enum (a comma-separated
closed domain for set_enum patches), and free-form documentation
keys such as language / role.
Two examples:
# Specialist's system prompt — coordinator routes user research turns here.
# zicato:mutable id="researcher_instruction"
INSTRUCTION = """You research the user's question by ..."""
researcher = LlmAgent(
name="researcher",
# zicato:mutable id="researcher_description" role="tool_description"
description="Performs literature lookup and source aggregation.",
instruction=INSTRUCTION,
...
)In the first form the marker covers the right-hand side of the assignment. In the second the marker covers the keyword argument's value expression. Both cases resolve to a single string-literal node the applier can rewrite.
A file marker is a comment in the header region of a file (module
docstring above, marker below, no statements before it). It has the
form — note the :file suffix on the marker prefix, not a separate
file word:
# zicato:mutable:file id="<stable-id>" [key="value" ...]
Example:
"""Specialist prompts for the presentation agent."""
# zicato:mutable:file id="presentation_agent_prompts"
INTRO = "..."
OUTLINE = "..."
REVISION = "..."When a file marker is present, the proposer may propose a replace
patch whose target is the file id and whose new_content is the
entire post-edit contents of the file. The applier writes the file in
full, then runs every validator constraint on it.
A file with a file marker MAY also carry span markers for finer-grained
targets. Both are emitted by mutation_points(); the proposer chooses
the granularity that fits the change.
- Markers do not declare any constraints on the new text. They mark a target; validation happens at apply time, not at mark time.
- Markers do not carry the current value. The walker reads the value
from the AST and stamps it onto the
MutationPointat enumeration time. - Markers do not survive into the wire format. The JSONL event stream knows nothing about mutation points; this is a source-side concern only.
- Markers are not executable. They are comments. The Python interpreter ignores them.
The mutation enumerator is a Python AST visitor. For each registered
source root it walks every .py file, parses to AST, and walks the
nodes.
Span marker resolution:
- Iterate over every comment in the file (the enumerator uses
tokenizefor comment lines paired with theast.parsetree). - For each
# zicato:mutable id="..."comment that is NOT a file marker (i.e. lacks the:filesuffix), find the AST node on the next non-blank, non-comment source line. - The target node MUST be one of:
ast.Assignwhosevalueisast.Constantof typestr(coversX = "..."andX = """...""").ast.AnnAssignwhosevalueisast.Constantof typestr.ast.keyword(inside aast.Call) whosevalueisast.Constantof typestr.
- If the marker's id has been seen earlier in the walk, this is an error: ids must be unique within a registration.
- The enumerator records the target location (file path, start line, end line, start column, end column) and the current string value.
File marker resolution:
- In the file's header region, search for a line matching
# zicato:mutable:file id="...". - If found, the entire file is the target. The recorded
contentis the file's full text. - A file may carry at most one file marker.
Unrecognized marker form: the enumerator emits a warning to stderr and skips it. The mutation surface does not silently absorb typos.
The enumerator returns a list of MutationPoint objects. The dataclass
shape:
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
@dataclass(frozen=True, slots=True)
class MutationPoint:
id: str # globally unique within registration
kind: Literal["span", "file"] # marker form
file: Path # absolute path to the source file
source_root: Path # which registered root this came from
line_start: int # 1-indexed inclusive
line_end: int # 1-indexed inclusive
content: str # current text of the region at enumeration
content_hash: str # hex SHA-256 of `content` (stale-write guard)
metadata: Mapping[str, str] = field(default_factory=dict)content is a snapshot — the next round will re-enumerate after
patches land, and content will reflect the new value. content_hash
is the hex-encoded SHA-256 of content; the applier checks it before
writing so a stale proposer round cannot clobber an already-rewritten
region. metadata is the bag of key="value" pairs lifted from the
marker line (e.g. required_placeholders, min / max, enum,
role). The id is stable across snapshots; the location may drift if
patches above it add or remove lines.
The dataclass is column-free: a span is recorded by its inclusive
line_start / line_end range, not by character columns. There is no
separate label field — the marker's optional attributes all live in
metadata.
The id is the contract. The proposer addresses patches by id. The applier resolves the id to a new location after every patch round. The id MUST resolve to exactly one mutation point after a patch lands — otherwise the patch is rejected at validation time (see §6).
This is why the marker syntax requires id="..." on every marker: an
id-less marker would be unaddressable from one generation to the next
and the proposer would have no way to refer back to it.
The HarnessAdapter protocol exposes mutation-point enumeration:
from typing import Protocol
class HarnessAdapter(Protocol):
async def run_entry(self, entry: BoardEntry, *, sinks: list[EventSink]) -> RunResult:
...
def mutation_points(self) -> list[MutationPoint]:
"""Walk every registered source root and return every annotated
mutation point. Idempotent; safe to call multiple times per
round.
"""
...mutation_points() returns a list over the registered list of source
roots, not a single tree. v0 typically uses one root — the inner
harness's package. v0+1 uses two — the inner harness and the
adapter-instrumented goldfive checkout it wraps (target 2 — see
DOGFOOD-TARGETS.md).
The CLI exposes this with the --mutable-tree flag on register:
zicato register --adk path/to/agent.py:root_agent \
--mutable-tree path/to/agent_package \
--mutable-tree path/to/another/package
The first registered root is conventionally the package containing the
agent factory; additional roots are added with repeated
--mutable-tree flags. All registered roots contribute mutation
points to the same enumeration.
The list shape is part of the v0 contract even though v0 typically uses one root. Forcing the shape now means target 2 plugs in without schema breakage later.
mutation_points() re-parses every source file on every call. There
is no caching by design. Parsing a few hundred Python files is fast
(<100ms in practice) and the freshness guarantee is more valuable than
the speedup.
The validator (zicato.mutation.validator) is deterministic and
side-effect-free; it returns a list of human-readable problem strings
rather than raising, so the tournament can log one rejection record
per experiment. It runs in two phases.
Pre-apply (validate_patches, before the patch set is written —
so a malformed batch is refused as a whole rather than half-applied):
| # | Constraint | Why |
|---|---|---|
| P1 | Each patch's mutation_id resolves to an enumerated MutationPoint. |
A patch that targets nothing cannot be applied. |
| P2 | The op matches its payload: replace carries new_content, set_numeric carries new_numeric, set_enum carries new_enum, and no foreign payload field is set. |
Catches a malformed proposer response before it touches disk. |
| P3 | The op is compatible with the target point's kind: replace works on span or file; set_numeric / set_enum require a span point (they locate a constant after the marker). |
A file-level rewrite has no single constant to retarget. |
A standalone helper, check_forbidden_ids, rejects any patch whose
mutation_id is in an operator-supplied forbidden set — the
mechanical enforcement behind the proposer brief's ## Forbidden list.
Post-apply (validate_post_apply, after the candidate snapshot is
written — the tournament refuses to promote a snapshot with any
non-empty error list):
| # | Constraint | Why |
|---|---|---|
| A1 | Every touched .py file still parses (ast.parse). Non-Python touched files (e.g. markdown prompt bodies under a manifest-bridged surface) are checked for existence and readability only. |
A non-parsing file can't be imported; the whole snapshot is unusable. |
| A2 | Every patch's mutation_id still resolves in a fresh enumeration of the snapshot. |
The next round must be able to re-find this id. |
| A3 | For any point whose pre-apply metadata declared required_placeholders, each named placeholder (exact substring, braces included) survives in the patched content. |
Prevents the proposer from silently dropping a {user_message} formatter the surrounding code injects. |
| A4 | Top-level imports in every patched .py file are preserved — the post-apply import set must be a superset of the pre-apply set. The proposer may add imports but not silently remove them. |
A dropped import breaks the snapshot at runtime, not at parse time. |
A3 is opt-in per mutation point via the required_placeholders
metadata key on the marker; the validator never guesses placeholders
for an unannotated span.
The mutation surface stays operator-owned: the proposer addresses patches by id and rewrites within an enumerated point, but only the operator's markers define what the surface is. A2 enforces that every patched id still resolves after the rewrite.
The audit command — Advanced: audit the mutable surface the proposer may change. It resolves the registered adapter from the workspace,
enumerates mutation_points(), and renders the result. zicato evolve
enumerates this surface itself every round; this command exists to let
an operator audit what the proposer is allowed to change.
$ zicato mutations
[span] researcher_instruction
agent.py:18-18
"You research the user's question by ..."
[span] researcher_description
agent.py:38-38
"Performs literature lookup and source aggregation."
[file] presentation_agent_prompts
prompts.py (entire file)
[span] outline_prompt required_placeholders={section_count}
prompts.py:24-24
"Outline the presentation in three sections: ..."
Flags:
| Flag | Meaning |
|---|---|
--workspace PATH |
Path to the zicato workspace directory (default .zicato). |
--id <glob> |
Filter mutation points by id glob, e.g. --id 'researcher_*'. |
--kind span|file |
Restrict the listing to one mutation kind. |
--show preview|full |
Truncate content previews (preview, the default) or dump full content (full). |
--format table|json |
Output format: human-readable table (default) or json (the full MutationPoint shape). |
There is no --root flag — the listing always covers every registered
source root. Filter by id glob (--id) when you want a subset.
The intended workflow is:
- Operator marks new mutation points in the inner harness's source.
- Operator runs
zicato mutationsto confirm every marker resolves cleanly (no warnings, no duplicate ids). - Operator runs
zicato evolveand the proposer addresses patches against the surface they just confirmed.
zicato mutations is also the right place to confirm the exact id
spellings before adding one to the proposer brief's ## Forbidden
list — the forbidden-id check (check_forbidden_ids) matches on the
literal id, so the operator can copy the id straight out of the
listing.
The recommended workflow for marking up an inner harness:
- Identify the smallest unit you want the proposer to be able to rewrite. Usually one string literal — a specialist instruction, a coordinator routing template, a tool description.
- Hoist the literal to a named binding if it isn't already (
INSTR = "..."near the top of the module, used by reference in the agent definition). Span markers don't decorate inline string literals buried in expression contexts cleanly; the named-binding form is the canonical shape. - Add a span marker on the line above with a meaningful id. The id
should encode the role and the role's part:
coordinator.routing,researcher.instruction,writer.tools.summarize.description. - Run
zicato mutationsand confirm the marker resolves.
For a whole module of related strings (a prompts.py), add one file
marker at the top of the file. Span markers within it become optional.
The marker syntax above is the v0 contract. Several extensions are plausible but deliberately deferred:
- Multi-string span markers. A marker that covers a sequence of consecutive string literals. The use case is "rewrite this tuple of related prompts as a group." Today the operator hoists them to a single module-level string or uses a file marker.
- JSON / YAML inner harnesses. Markers in non-Python sources. Today the marker walker is Python-only; an inner harness whose prompts live in YAML must hoist them through a Python module to be mutable. Most ADK setups already do this; LangChain setups sometimes do not.
- Type-narrowed mutation points. A marker that asserts "the new
value must satisfy this Pydantic shape." Today the
required_placeholderscheck (A3) plus themin/max/enummetadata bounds are the only structural validators on patched text.
Each of these is straightforward to add later because the
MutationPoint.metadata mapping is an open str-keyed bag — a new
marker attribute is a new metadata key, not a schema change.
v0 starts narrow.