Skip to content

zettelkasten.synapse.memdsl

zettelkasten.synapse.memdsl

MemDSL: the versioned, bidirectional, machine-checkable synapse contract.

MemDSL is the CONTRACT (not a compression scheme) through which the deterministic synapse engine renders a heterogeneous knowledge substrate to a reasoning agent and the agent returns actions plus a cited reasoning-DAG the engine can verify. It is the first synapse workstream: substrate, epistemics, the grounding router, navigation, and the renderer all consume these types, so they are designed to be importable and stable.

The two directions of the contract:

  • RENDER (engine → agent) — an :class:~.schema.Envelope of :class:~.schema.RenderedNode bodies, each carrying a unified :class:~.schema.Address, an engine-emitted :class:~.schema.EpistemicStatus, provenance handles, and :class:~.schema.Affordance handles, plus first-class :class:~.schema.GapToken absences.
  • OUTPUT (agent → engine) — an :class:~.schema.AgentOutput of :class:~.schema.Action moves (an OPEN registry) and a cited :class:~.schema.ReasoningDAG of :class:~.schema.Claim nodes, or a labeled :class:~.schema.Abstention.

An :class:~.schema.ActionResponse is the engine's versioned mid-loop reply to a single action (read results in v1; shaped to report state mutations + impact sets later). The (de)serializers live in :mod:~.parser and are deterministic and injection-safe.

Abstention dataclass

A labeled, calibrated refusal to answer.

reason is a short label (see :data:ABSTAIN_REASONS for the canonical v1 set — the field stays open so the router may add labels); detail is an optional single-line note.

Source code in zettelkasten/synapse/memdsl/schema.py
@dataclass
class Abstention:
    """A labeled, calibrated refusal to answer.

    ``reason`` is a short label (see :data:`ABSTAIN_REASONS` for the canonical v1
    set — the field stays open so the router may add labels); ``detail`` is an
    optional single-line note.
    """

    reason: str
    detail: str = ""

Action dataclass

A single non-terminal navigation action the agent returned.

Generic verb(args, kwargs) shape so the registry can grow. verb is validated against :data:ACTION_REGISTRY at parse time; args are node refs/addresses, kwargs an open map for future parameters.

Source code in zettelkasten/synapse/memdsl/schema.py
@dataclass
class Action:
    """A single non-terminal navigation action the agent returned.

    Generic ``verb(args, kwargs)`` shape so the registry can grow. ``verb`` is
    validated against :data:`ACTION_REGISTRY` at parse time; ``args`` are node
    refs/addresses, ``kwargs`` an open map for future parameters.
    """

    verb: str
    args: list[str] = field(default_factory=list)
    kwargs: dict[str, str] = field(default_factory=dict)

ActionResponse dataclass

The engine's versioned reply to a single agent action, mid-loop.

The navigation loop is render → action → action-response → action, so this fragment is a first-class part of the contract. v1 populates only nodes and gaps (read results); mutations and impact (the set of addresses a mutation touched) are reserved for future write/apply actions and stay empty. status is a short machine token (ok by default).

Source code in zettelkasten/synapse/memdsl/schema.py
@dataclass
class ActionResponse:
    """The engine's versioned reply to a single agent action, mid-loop.

    The navigation loop is render → action → action-response → action, so this
    fragment is a first-class part of the contract. v1 populates only ``nodes``
    and ``gaps`` (read results); ``mutations`` and ``impact`` (the set of
    addresses a mutation touched) are reserved for future write/apply actions and
    stay empty. ``status`` is a short machine token (``ok`` by default).
    """

    status: str = "ok"
    nodes: list[RenderedNode] = field(default_factory=list)
    gaps: list[GapToken] = field(default_factory=list)
    mutations: list[Mutation] = field(default_factory=list)
    impact: list[str] = field(default_factory=list)
    format_version: str = FORMAT_VERSION

ActionSpec dataclass

One entry in the open action registry.

kind is read for every v1 action; write is reserved for future apply actions (the registry is the extension seam — a new action is a new entry, not a grammar change). terminal marks the whole-turn decisions (answer/abstain) that end the navigation loop and are expressed with a @-marker rather than the verb(args) call form. min_args/ max_args bound the positional arity (max_args=None is unbounded). rule_class is the optional graph-rewrite class (see :class:RuleClass): advisory engine-side metadata that never touches the wire, so it is purely additive — an unclassified action (None) round-trips exactly as before.

Source code in zettelkasten/synapse/memdsl/schema.py
@dataclass(frozen=True)
class ActionSpec:
    """One entry in the open action registry.

    ``kind`` is ``read`` for every v1 action; ``write`` is reserved for future
    apply actions (the registry is the extension seam — a new action is a new
    entry, not a grammar change). ``terminal`` marks the whole-turn decisions
    (``answer``/``abstain``) that end the navigation loop and are expressed with a
    ``@``-marker rather than the ``verb(args)`` call form. ``min_args``/
    ``max_args`` bound the positional arity (``max_args=None`` is unbounded).
    ``rule_class`` is the optional graph-rewrite class (see :class:`RuleClass`):
    advisory engine-side metadata that never touches the wire, so it is purely
    additive — an unclassified action (``None``) round-trips exactly as before.
    """

    name: str
    kind: str = "read"
    terminal: bool = False
    min_args: int = 0
    max_args: int | None = None
    rule_class: "RuleClass | None" = None

Address dataclass

A stable, collision-free, short unified address for a substrate node.

Canonical form is store:source:id with an optional trailing {locator} that encodes external-dataset provenance shapes without changing the scheme:

  • a passage char-offset span → {#<start>-<end>};
  • a temporal as-of stamp → {@<stamp>} (e.g. an ISO 8601 instant);
  • both, in that order → {#<start>-<end>@<stamp>}.

A schema table.column provenance needs no locator: the dotted column is the natural id (e.g. sql:salesdb:orders.total). store, source and id must be non-empty and free of the :/{/}/,/whitespace separators; id may contain dots. , is forbidden because address tokens are embedded in ,-delimited lines (cites/prov/aff/ impact); a comma inside a component would silently split one token into two on parse. The session/work stores are reserved (see :data:RESERVED_STORES).

Source code in zettelkasten/synapse/memdsl/schema.py
@dataclass(frozen=True)
class Address:
    """A stable, collision-free, short unified address for a substrate node.

    Canonical form is ``store:source:id`` with an optional trailing ``{locator}``
    that encodes external-dataset provenance shapes without changing the scheme:

    * a passage char-offset span → ``{#<start>-<end>}``;
    * a temporal as-of stamp → ``{@<stamp>}`` (e.g. an ISO 8601 instant);
    * both, in that order → ``{#<start>-<end>@<stamp>}``.

    A schema ``table.column`` provenance needs no locator: the dotted column is
    the natural ``id`` (e.g. ``sql:salesdb:orders.total``). ``store``, ``source``
    and ``id`` must be non-empty and free of the ``:``/``{``/``}``/``,``/whitespace
    separators; ``id`` may contain dots. ``,`` is forbidden because address
    tokens are embedded in ``, ``-delimited lines (``cites``/``prov``/``aff``/
    ``impact``); a comma inside a component would silently split one token into
    two on parse. The ``session``/``work`` stores are reserved (see
    :data:`RESERVED_STORES`).
    """

    store: str
    source: str
    id: str
    # Optional locators for external-dataset provenance shapes. ``None`` on the
    # overwhelming majority of nodes (a plain store node), so the token stays the
    # short ``store:source:id`` form.
    span: tuple[int, int] | None = None  # a passage char-offset [start, end)
    as_of: str | None = None             # a temporal as-of stamp

    def __post_init__(self) -> None:
        # ``,`` joins the ':'/'{'/'}'/whitespace separators here: an address token
        # is embedded verbatim in ``, ``-delimited lines (cites/prov/aff/impact)
        # and whitespace-delimited node/gap headers, so a component carrying any
        # of these would corrupt the surrounding line on parse.
        for name, value in (("store", self.store), ("source", self.source), ("id", self.id)):
            if not value:
                raise MemDSLParseError(f"Address {name!r} must be non-empty")
            if any(c in value for c in (":", "{", "}", ",")) or any(c.isspace() for c in value):
                raise MemDSLParseError(
                    f"Address {name!r}={value!r} may not contain ':', '{{', '}}', ',', or whitespace"
                )
        if self.span is not None:
            start, end = self.span
            if start < 0 or end < start:
                raise MemDSLParseError(f"Address span {self.span!r} must satisfy 0 <= start <= end")
        if self.as_of is not None:
            # ``as_of`` may legitimately contain ':' and '-' (ISO 8601 stamps), but
            # NOT whitespace/newlines (would break whitespace-delimited headers or
            # inject a new physical line), ',' (would split ``, ``-delimited lines),
            # or the '{'/'}' locator braces (would corrupt the address token).
            if any(c in self.as_of for c in ("{", "}", ",")) or any(c.isspace() for c in self.as_of):
                raise MemDSLParseError(
                    "Address as_of may not contain whitespace, ',', '{', or '}'"
                )

    def to_token(self) -> str:
        """Serialize to the canonical ``store:source:id[{locator}]`` string."""
        token = f"{self.store}:{self.source}:{self.id}"
        locator = ""
        if self.span is not None:
            locator += f"#{self.span[0]}-{self.span[1]}"
        if self.as_of is not None:
            locator += f"@{self.as_of}"
        return f"{token}{{{locator}}}" if locator else token

    @classmethod
    def parse(cls, token: str) -> "Address":
        """Parse a canonical address token back into an :class:`Address`.

        Raises :class:`MemDSLParseError` on anything that is not a well-formed
        ``store:source:id[{locator}]`` string.
        """
        token = (token or "").strip()
        if not token:
            raise MemDSLParseError("empty address token")
        locator: str | None = None
        base = token
        if token.endswith("}"):
            open_at = token.find("{")
            if open_at == -1:
                raise MemDSLParseError(f"unbalanced locator braces in address {token!r}")
            base = token[:open_at]
            locator = token[open_at + 1 : -1]
        parts = base.split(":", 2)
        if len(parts) != 3:
            raise MemDSLParseError(f"address {token!r} must have the form store:source:id")
        store, source, node_id = parts
        span: tuple[int, int] | None = None
        as_of: str | None = None
        if locator is not None:
            span, as_of = _parse_locator(locator, token)
        return cls(store=store, source=source, id=node_id, span=span, as_of=as_of)

to_token

to_token() -> str

Serialize to the canonical store:source:id[{locator}] string.

Source code in zettelkasten/synapse/memdsl/schema.py
def to_token(self) -> str:
    """Serialize to the canonical ``store:source:id[{locator}]`` string."""
    token = f"{self.store}:{self.source}:{self.id}"
    locator = ""
    if self.span is not None:
        locator += f"#{self.span[0]}-{self.span[1]}"
    if self.as_of is not None:
        locator += f"@{self.as_of}"
    return f"{token}{{{locator}}}" if locator else token

parse classmethod

parse(token: str) -> 'Address'

Parse a canonical address token back into an :class:Address.

Raises :class:MemDSLParseError on anything that is not a well-formed store:source:id[{locator}] string.

Source code in zettelkasten/synapse/memdsl/schema.py
@classmethod
def parse(cls, token: str) -> "Address":
    """Parse a canonical address token back into an :class:`Address`.

    Raises :class:`MemDSLParseError` on anything that is not a well-formed
    ``store:source:id[{locator}]`` string.
    """
    token = (token or "").strip()
    if not token:
        raise MemDSLParseError("empty address token")
    locator: str | None = None
    base = token
    if token.endswith("}"):
        open_at = token.find("{")
        if open_at == -1:
            raise MemDSLParseError(f"unbalanced locator braces in address {token!r}")
        base = token[:open_at]
        locator = token[open_at + 1 : -1]
    parts = base.split(":", 2)
    if len(parts) != 3:
        raise MemDSLParseError(f"address {token!r} must have the form store:source:id")
    store, source, node_id = parts
    span: tuple[int, int] | None = None
    as_of: str | None = None
    if locator is not None:
        span, as_of = _parse_locator(locator, token)
    return cls(store=store, source=source, id=node_id, span=span, as_of=as_of)

Affordance dataclass

A navigation handle the engine advertises FROM a rendered node.

verb is one of :data:AFFORDANCE_VERBS and target is the address token (or a node ref) the move applies to — e.g. expand the neighborhood of this node, or join it with another. Advisory: the engine still validates any action the agent returns.

Source code in zettelkasten/synapse/memdsl/schema.py
@dataclass
class Affordance:
    """A navigation handle the engine advertises FROM a rendered node.

    ``verb`` is one of :data:`AFFORDANCE_VERBS` and ``target`` is the address
    token (or a node ref) the move applies to — e.g. ``expand`` the neighborhood
    of this node, or ``join`` it with another. Advisory: the engine still
    validates any action the agent returns.
    """

    verb: str
    target: str

AgentOutput dataclass

The output-direction payload: the actions + terminal decision an agent returned.

actions are the non-terminal navigation moves. Exactly one terminal decision may accompany them: reasoning (an answer with its cited DAG) XOR abstain (a labeled refusal). Both being set is a contract violation the parser rejects.

Source code in zettelkasten/synapse/memdsl/schema.py
@dataclass
class AgentOutput:
    """The output-direction payload: the actions + terminal decision an agent returned.

    ``actions`` are the non-terminal navigation moves. Exactly one terminal
    decision may accompany them: ``reasoning`` (an ``answer`` with its cited DAG)
    XOR ``abstain`` (a labeled refusal). Both being set is a contract violation
    the parser rejects.
    """

    actions: list[Action] = field(default_factory=list)
    reasoning: ReasoningDAG | None = None
    abstain: Abstention | None = None
    format_version: str = LEGACY_OUTPUT_FORMAT_VERSION

Claim dataclass

One node of the agent's cited reasoning-DAG.

cites are the node refs (or addresses) the claim rests on; form is the :class:InferenceForm connecting them to the claim; premises are other claim ids this one builds on (making the reasoning a DAG, not a flat list); derivation is an optional derivation-expression (e.g. the arithmetic a CALCULATION claim computed). There is deliberately no epistemic-status field: the engine RE-DERIVES the claim's status from its cited nodes and form, so the agent cannot assert grounded.

Source code in zettelkasten/synapse/memdsl/schema.py
@dataclass
class Claim:
    """One node of the agent's cited reasoning-DAG.

    ``cites`` are the node refs (or addresses) the claim rests on; ``form`` is the
    :class:`InferenceForm` connecting them to the claim; ``premises`` are other
    claim ids this one builds on (making the reasoning a DAG, not a flat list);
    ``derivation`` is an optional derivation-expression (e.g. the arithmetic a
    ``CALCULATION`` claim computed). There is deliberately no epistemic-status
    field: the engine RE-DERIVES the claim's status from its cited nodes and
    form, so the agent cannot assert ``grounded``.
    """

    id: str
    text: str
    form: InferenceForm
    cites: list[str] = field(default_factory=list)
    premises: list[str] = field(default_factory=list)
    derivation: str | None = None

Envelope dataclass

The render-direction payload: nodes + gaps the engine shows the agent.

Determinism is the caller's responsibility for ordering (nodes are rendered in list order with their assigned refs — see :func:zettelkasten.synapse.memdsl.parser.assign_refs) and the serializer is a pure function of the envelope, so identical input yields byte-identical output. focus optionally names the ref the render is centered on.

Source code in zettelkasten/synapse/memdsl/schema.py
@dataclass
class Envelope:
    """The render-direction payload: nodes + gaps the engine shows the agent.

    Determinism is the caller's responsibility for ordering (nodes are rendered
    in list order with their assigned refs — see
    :func:`zettelkasten.synapse.memdsl.parser.assign_refs`) and the serializer is
    a pure function of the envelope, so identical input yields byte-identical
    output. ``focus`` optionally names the ref the render is centered on.
    """

    nodes: list[RenderedNode] = field(default_factory=list)
    gaps: list[GapToken] = field(default_factory=list)
    focus: str | None = None
    format_version: str = FORMAT_VERSION

EpistemicStatus

Bases: str, Enum

The machine-checkable epistemic status the engine stamps on a node.

Values mirror :data:zettelkasten.graph.VALID_EPISTEMIC_STATUS exactly so the render contract and the note store share one vocabulary. This status is engine-emitted on the RENDER side only; nothing on the OUTPUT side lets an agent assert it. UNKNOWN/unresolved absence is represented by a :class:GapToken, not by a status value, so it is never confused with a positive claim of groundedness.

Source code in zettelkasten/synapse/memdsl/schema.py
class EpistemicStatus(str, Enum):
    """The machine-checkable epistemic status the engine stamps on a node.

    Values mirror :data:`zettelkasten.graph.VALID_EPISTEMIC_STATUS` exactly so
    the render contract and the note store share one vocabulary. This status is
    engine-emitted on the RENDER side only; nothing on the OUTPUT side lets an
    agent assert it. ``UNKNOWN``/unresolved absence is represented by a
    :class:`GapToken`, not by a status value, so it is never confused with a
    positive claim of groundedness.
    """

    GROUNDED = "grounded"
    MEASURED = "measured"
    INFERRED = "inferred"

GapKind

Bases: str, Enum

The kind of a first-class absence token.

Absence is always rendered, never dropped, so a coverage-gated abstention can reason about the hole:

  • STALE — the node's tether drifted since it was pinned.
  • UNRESOLVED — a referenced address did not resolve to a node.
  • MISSING — an expected hop/relation is absent from the substrate.
  • ELIDED — a body was truncated to a lossy view; a ground re-fetches it. Truncation must never silently break a verbatim-quote check, so the elision is explicit.
  • OUTDATED — the node's git pin is a faithfully-cited-but-SUPERSEDED commit: the pinned bytes are still a retrievable immutable git object (so the node stays GROUNDED — this is a DISCLOSURE, not an abstain-forcing taint like STALE), but HEAD has since moved on, so a consumer must be told "grounded as of the pinned sha; the current file may differ".
Source code in zettelkasten/synapse/memdsl/schema.py
class GapKind(str, Enum):
    """The kind of a first-class absence token.

    Absence is always rendered, never dropped, so a coverage-gated abstention can
    reason about the hole:

    * ``STALE`` — the node's tether drifted since it was pinned.
    * ``UNRESOLVED`` — a referenced address did not resolve to a node.
    * ``MISSING`` — an expected hop/relation is absent from the substrate.
    * ``ELIDED`` — a body was truncated to a lossy view; a ``ground`` re-fetches
      it. Truncation must never silently break a verbatim-quote check, so the
      elision is explicit.
    * ``OUTDATED`` — the node's git pin is a faithfully-cited-but-SUPERSEDED
      commit: the pinned bytes are still a retrievable immutable git object (so
      the node stays GROUNDED — this is a DISCLOSURE, not an abstain-forcing
      taint like ``STALE``), but HEAD has since moved on, so a consumer must be
      told "grounded as of the pinned sha; the current file may differ".
    """

    STALE = "stale"
    UNRESOLVED = "unresolved"
    MISSING = "missing"
    ELIDED = "elided"
    OUTDATED = "outdated"

GapToken dataclass

A first-class rendered absence (see :class:GapKind).

address is the node/hop the gap concerns when known (e.g. the unresolved reference, or the elided node), else None. detail is an optional single-line human note. Rendered explicitly so calibrated abstention can see the hole.

Source code in zettelkasten/synapse/memdsl/schema.py
@dataclass
class GapToken:
    """A first-class rendered absence (see :class:`GapKind`).

    ``address`` is the node/hop the gap concerns when known (e.g. the unresolved
    reference, or the elided node), else ``None``. ``detail`` is an optional
    single-line human note. Rendered explicitly so calibrated abstention can see
    the hole.
    """

    kind: GapKind
    address: Address | None = None
    detail: str = ""

InferenceForm

Bases: str, Enum

The reasoning form connecting a claim's cited nodes to the claim.

Domain-agnostic taxonomy the epistemics layer later maps onto :data:zettelkasten.graph.VALID_RELATIONS for status propagation. Carried on the OUTPUT side (:class:Claim) so the engine can re-derive a claim's status from its cited nodes and its form, rather than trusting an agent assertion.

Source code in zettelkasten/synapse/memdsl/schema.py
class InferenceForm(str, Enum):
    """The reasoning form connecting a claim's cited nodes to the claim.

    Domain-agnostic taxonomy the epistemics layer later maps onto
    :data:`zettelkasten.graph.VALID_RELATIONS` for status propagation. Carried on
    the OUTPUT side (:class:`Claim`) so the engine can re-derive a claim's status
    from its cited nodes and its form, rather than trusting an agent assertion.
    """

    RESTATEMENT = "restatement"  # a direct, verbatim grounding in one node
    DEDUCTION = "deduction"      # necessarily follows from the cited premises
    INDUCTION = "induction"      # generalized from cited instances
    ABDUCTION = "abduction"      # best explanation of the cited evidence
    CALCULATION = "calculation"  # computed via ``derivation`` from cited numbers
    COMPARISON = "comparison"    # relation established by comparing cited nodes
    CAUSAL = "causal"            # a cause/effect link asserted over cited nodes
    ANALOGY = "analogy"          # mapped from a cited analogue
    ASSUMPTION = "assumption"    # asserted without a mechanical ground (inferred)

MemDSLError

Bases: Exception

Base class for every MemDSL contract error.

Source code in zettelkasten/synapse/memdsl/schema.py
class MemDSLError(Exception):
    """Base class for every MemDSL contract error."""

MemDSLParseError

Bases: MemDSLError

A serialized MemDSL artifact was malformed or of an unsupported version.

Raised by the parser on a missing/wrong header, an unparseable address, an unknown or badly-ar'd action, or a malformed reasoning-DAG. Unknown NODE TYPES are NOT an error — they degrade to the default body codec — so this is reserved for genuine grammar violations.

Source code in zettelkasten/synapse/memdsl/schema.py
class MemDSLParseError(MemDSLError):
    """A serialized MemDSL artifact was malformed or of an unsupported version.

    Raised by the parser on a missing/wrong header, an unparseable address, an
    unknown or badly-ar'd action, or a malformed reasoning-DAG. Unknown NODE
    TYPES are NOT an error — they degrade to the default body codec — so this is
    reserved for genuine grammar violations.
    """

Mutation dataclass

A state mutation reported by the engine after a write/apply action.

v1 emits none (the navigation loop is read-only), but the fragment is shaped to carry them so v2 write actions need no grammar change. op names the mutation kind, address the node it touched, detail an optional note.

Source code in zettelkasten/synapse/memdsl/schema.py
@dataclass
class Mutation:
    """A state mutation reported by the engine after a write/apply action.

    v1 emits none (the navigation loop is read-only), but the fragment is shaped
    to carry them so v2 write actions need no grammar change. ``op`` names the
    mutation kind, ``address`` the node it touched, ``detail`` an optional note.
    """

    op: str
    address: Address
    detail: str = ""

NodeTypeSpec dataclass

How a node type's compact body is encoded on the wire.

body_kind selects a codec in :data:BODY_CODECS. v1 ships a single text codec (the body is plain, injection-framed text), but the registry is the seam for richer per-type bodies (e.g. a schema descriptor for a data node) without re-cutting the parser. An UNKNOWN type resolves to the default text spec via :func:node_type_spec, so a brand-new node type degrades gracefully instead of crashing.

Source code in zettelkasten/synapse/memdsl/schema.py
@dataclass(frozen=True)
class NodeTypeSpec:
    """How a node type's compact body is encoded on the wire.

    ``body_kind`` selects a codec in :data:`BODY_CODECS`. v1 ships a single
    ``text`` codec (the body is plain, injection-framed text), but the registry
    is the seam for richer per-type bodies (e.g. a schema descriptor for a data
    node) without re-cutting the parser. An UNKNOWN type resolves to the default
    text spec via :func:`node_type_spec`, so a brand-new node type degrades
    gracefully instead of crashing.
    """

    type: str
    body_kind: str = "text"

ProseCitation dataclass

A binding from one prose assertion to the claim that licenses it.

MemDSL OUTPUT v2 encodes this binding as an exact quote anchor. The strict parser resolves that quote to one half-open span [start, end) in :attr:ReasoningDAG.prose; zero or multiple matches fail closed. In-memory callers may still construct this type directly for compatibility; validated OUTPUT v2 prose requires exactly one claim id per binding.

The gate enforces per-assertion CITATION-ANCHORING against this binding: the assertion's content tokens must be licensed by a SINGLE cited verified claim (closed vocabulary per claim, never the pooled union), and any prose token outside every citation span is an UNCITED assertion. It is purely additive — ReasoningDAG.prose_citations defaults to empty, so a legacy v1 DAG leaves the gate on its conservative fallback path.

Source code in zettelkasten/synapse/memdsl/schema.py
@dataclass(frozen=True)
class ProseCitation:
    """A binding from one prose assertion to the claim that licenses it.

    MemDSL OUTPUT v2 encodes this binding as an exact quote anchor. The strict
    parser resolves that quote to one half-open ``span`` ``[start, end)`` in
    :attr:`ReasoningDAG.prose`; zero or multiple matches fail closed. In-memory
    callers may still construct this type directly for compatibility; validated
    OUTPUT v2 prose requires exactly one claim id per binding.

    The gate enforces per-assertion CITATION-ANCHORING against this binding: the
    assertion's content tokens must be licensed by a SINGLE cited verified claim
    (closed vocabulary per claim, never the pooled union), and any prose token
    outside every citation span is an UNCITED assertion. It is purely additive —
    ``ReasoningDAG.prose_citations`` defaults to empty, so a legacy v1 DAG leaves
    the gate on its conservative fallback path.
    """

    span: tuple[int, int]
    claim_ids: tuple[str, ...] = ()

    def __post_init__(self) -> None:
        start, end = self.span
        if start < 0 or end < start:
            raise MemDSLParseError(
                f"ProseCitation span {self.span!r} must satisfy 0 <= start <= end"
            )

ReasoningDAG dataclass

The cited reasoning-DAG returned with an answer.

claims are the DAG nodes; conclusion names the claim id that IS the answer (defaults to the last claim when empty). This is what makes "don't fabricate" enforceable: every claim is traceable to cited substrate nodes and an inference form the engine can check.

prose is the answer text licensed for public projection. OUTPUT v2 serializes it with exact quote-anchored prose_citations; OUTPUT v1 omits both fields and remains accepted byte-for-byte. Empty defaults preserve construction compatibility for existing v1 and direct router callers.

The empty-conclusion default is resolved to the last claim id in :meth:__post_init__, so the in-memory form is canonical: an omitted and a last-claim-named conclusion are the same object. That canonicalization is what lets the serializer always emit a conclude line and still satisfy parse(serialize(x)) == x (the round-trip-symmetry guarantee).

Source code in zettelkasten/synapse/memdsl/schema.py
@dataclass
class ReasoningDAG:
    """The cited reasoning-DAG returned with an ``answer``.

    ``claims`` are the DAG nodes; ``conclusion`` names the claim id that IS the
    answer (defaults to the last claim when empty). This is what makes
    "don't fabricate" enforceable: every claim is traceable to cited substrate
    nodes and an inference form the engine can check.

    ``prose`` is the answer text licensed for public projection. OUTPUT v2
    serializes it with exact quote-anchored ``prose_citations``; OUTPUT v1 omits
    both fields and remains accepted byte-for-byte. Empty defaults preserve
    construction compatibility for existing v1 and direct router callers.

    The empty-conclusion default is resolved to the last claim id in
    :meth:`__post_init__`, so the in-memory form is canonical: an omitted and a
    last-claim-named conclusion are the same object. That canonicalization is
    what lets the serializer always emit a ``conclude`` line and still satisfy
    ``parse(serialize(x)) == x`` (the round-trip-symmetry guarantee).
    """

    claims: list[Claim] = field(default_factory=list)
    conclusion: str = ""
    prose: str = ""
    prose_citations: list[ProseCitation] = field(default_factory=list)

    def __post_init__(self) -> None:
        # Canonicalize an omitted conclusion to the last claim id so there is a
        # single in-memory representation of "the answer is the final claim".
        if not self.conclusion and self.claims:
            self.conclusion = self.claims[-1].id

    def validate(self) -> None:
        """Structurally verify the reasoning-DAG (raises :class:`MemDSLParseError`).

        Enforces the epistemic-honesty guarantees the engine relies on, so a
        fabricated or malformed DAG is rejected at parse rather than trusted:

        * **Citation floor** — every non-``assumption`` claim carries at least one
          citation. An ``assumption`` is the only form allowed to cite nothing;
          any grounding-bearing form (deduction/restatement/calculation/…) must
          rest on cited substrate, so an uncited deduction can no longer be the
          whole answer.
        * **Premise integrity** — every ``premises`` reference resolves to a
          defined claim, claim ids are unique, and the premise edges form no
          cycle (the reasoning is a DAG, not a tangle).
        * **Non-empty answer** — a zero-claim ``@answer`` is rejected: an answer
          with no claims has no ground, so it can never be a valid answer (the
          claim-less path is ``@abstain``, which never builds a DAG).
        * **Grounded conclusion** — the conclusion names a defined claim and its
          transitive premise closure bottoms out in at least one cited substrate
          reference, so an answer can never be assembled purely from uncited
          assumptions.

        Direct citation *existence* (does ``n3`` resolve to a rendered node?) is
        deliberately NOT checked here: cites reference substrate the DAG alone
        cannot see, so the grounding gate verifies them against the envelope.
        """
        by_id: dict[str, Claim] = {}
        for claim in self.claims:
            if claim.id in by_id:
                raise MemDSLParseError(f"duplicate claim id {claim.id!r} in reasoning-DAG")
            by_id[claim.id] = claim

        # Citation floor + premise-existence.
        for claim in self.claims:
            if claim.form is not InferenceForm.ASSUMPTION and not claim.cites:
                raise MemDSLParseError(
                    f"claim {claim.id!r} of form {claim.form.value!r} must cite at least one "
                    f"substrate ref (only an 'assumption' may be uncited)"
                )
            for premise in claim.premises:
                if premise not in by_id:
                    raise MemDSLParseError(
                        f"claim {claim.id!r} premise {premise!r} does not resolve to a claim"
                    )

        # Acyclicity: three-colour DFS over the premise edges.
        _WHITE, _GRAY, _BLACK = 0, 1, 2
        colour = {cid: _WHITE for cid in by_id}

        def _visit(cid: str) -> None:
            colour[cid] = _GRAY
            for premise in by_id[cid].premises:
                if colour[premise] == _GRAY:
                    raise MemDSLParseError(
                        f"cycle in reasoning-DAG premises through claim {cid!r}"
                    )
                if colour[premise] == _WHITE:
                    _visit(premise)
            colour[cid] = _BLACK

        for cid in by_id:
            if colour[cid] == _WHITE:
                _visit(cid)

        # A zero-claim ``@answer`` has no ground at all, so it cannot be a valid
        # answer: an answer must carry >=1 grounded claim bottoming out in a
        # citation. (An abstain path never builds a DAG, so ``@abstain`` with no
        # claims stays legal — this only rejects an empty ``@answer``.)
        if not self.claims:
            raise MemDSLParseError(
                "an @answer must carry at least one grounded claim (a zero-claim "
                "answer has no ground); use @abstain to decline"
            )
        # Grounded conclusion: it must name a claim and its premise closure must
        # contain at least one real citation.
        conclusion = self.conclusion or self.claims[-1].id
        if conclusion not in by_id:
            raise MemDSLParseError(f"conclusion {conclusion!r} does not name a claim")
        closure: set[str] = set()
        stack = [conclusion]
        while stack:
            cid = stack.pop()
            if cid in closure:
                continue
            closure.add(cid)
            stack.extend(by_id[cid].premises)
        if not any(by_id[cid].cites for cid in closure):
            raise MemDSLParseError(
                f"conclusion {conclusion!r} and its premise closure cite no substrate ref"
            )

        # OUTPUT v2 prose bindings are intentionally stricter than the router's
        # semantic gate: every proposition-bearing character must lie inside one
        # non-overlapping exact-quote span, and each span must point to exactly
        # one claim in the conclusion closure. This prevents citation splicing,
        # pooled licensing, and disconnected claims from laundering prose.
        if self.prose:
            if not self.prose_citations:
                raise MemDSLParseError("OUTPUT v2 prose requires at least one cite binding")
            covered = [False] * len(self.prose)
            for citation in sorted(self.prose_citations, key=lambda item: item.span):
                start, end = citation.span
                if start == end or end > len(self.prose):
                    raise MemDSLParseError(
                        f"ProseCitation span {citation.span!r} is outside the prose"
                    )
                if len(citation.claim_ids) != 1:
                    raise MemDSLParseError(
                        "each OUTPUT v2 prose assertion must cite exactly one claim"
                    )
                claim_id = citation.claim_ids[0]
                if claim_id not in closure:
                    raise MemDSLParseError(
                        f"prose citation claim {claim_id!r} is outside the conclusion closure"
                    )
                if any(covered[start:end]):
                    raise MemDSLParseError("prose citation spans must not overlap")
                covered[start:end] = [True] * (end - start)
            if any(ch.isalnum() and not covered[index] for index, ch in enumerate(self.prose)):
                raise MemDSLParseError(
                    "OUTPUT v2 prose contains an assertion not covered by an exact cite binding"
                )

validate

validate() -> None

Structurally verify the reasoning-DAG (raises :class:MemDSLParseError).

Enforces the epistemic-honesty guarantees the engine relies on, so a fabricated or malformed DAG is rejected at parse rather than trusted:

  • Citation floor — every non-assumption claim carries at least one citation. An assumption is the only form allowed to cite nothing; any grounding-bearing form (deduction/restatement/calculation/…) must rest on cited substrate, so an uncited deduction can no longer be the whole answer.
  • Premise integrity — every premises reference resolves to a defined claim, claim ids are unique, and the premise edges form no cycle (the reasoning is a DAG, not a tangle).
  • Non-empty answer — a zero-claim @answer is rejected: an answer with no claims has no ground, so it can never be a valid answer (the claim-less path is @abstain, which never builds a DAG).
  • Grounded conclusion — the conclusion names a defined claim and its transitive premise closure bottoms out in at least one cited substrate reference, so an answer can never be assembled purely from uncited assumptions.

Direct citation existence (does n3 resolve to a rendered node?) is deliberately NOT checked here: cites reference substrate the DAG alone cannot see, so the grounding gate verifies them against the envelope.

Source code in zettelkasten/synapse/memdsl/schema.py
def validate(self) -> None:
    """Structurally verify the reasoning-DAG (raises :class:`MemDSLParseError`).

    Enforces the epistemic-honesty guarantees the engine relies on, so a
    fabricated or malformed DAG is rejected at parse rather than trusted:

    * **Citation floor** — every non-``assumption`` claim carries at least one
      citation. An ``assumption`` is the only form allowed to cite nothing;
      any grounding-bearing form (deduction/restatement/calculation/…) must
      rest on cited substrate, so an uncited deduction can no longer be the
      whole answer.
    * **Premise integrity** — every ``premises`` reference resolves to a
      defined claim, claim ids are unique, and the premise edges form no
      cycle (the reasoning is a DAG, not a tangle).
    * **Non-empty answer** — a zero-claim ``@answer`` is rejected: an answer
      with no claims has no ground, so it can never be a valid answer (the
      claim-less path is ``@abstain``, which never builds a DAG).
    * **Grounded conclusion** — the conclusion names a defined claim and its
      transitive premise closure bottoms out in at least one cited substrate
      reference, so an answer can never be assembled purely from uncited
      assumptions.

    Direct citation *existence* (does ``n3`` resolve to a rendered node?) is
    deliberately NOT checked here: cites reference substrate the DAG alone
    cannot see, so the grounding gate verifies them against the envelope.
    """
    by_id: dict[str, Claim] = {}
    for claim in self.claims:
        if claim.id in by_id:
            raise MemDSLParseError(f"duplicate claim id {claim.id!r} in reasoning-DAG")
        by_id[claim.id] = claim

    # Citation floor + premise-existence.
    for claim in self.claims:
        if claim.form is not InferenceForm.ASSUMPTION and not claim.cites:
            raise MemDSLParseError(
                f"claim {claim.id!r} of form {claim.form.value!r} must cite at least one "
                f"substrate ref (only an 'assumption' may be uncited)"
            )
        for premise in claim.premises:
            if premise not in by_id:
                raise MemDSLParseError(
                    f"claim {claim.id!r} premise {premise!r} does not resolve to a claim"
                )

    # Acyclicity: three-colour DFS over the premise edges.
    _WHITE, _GRAY, _BLACK = 0, 1, 2
    colour = {cid: _WHITE for cid in by_id}

    def _visit(cid: str) -> None:
        colour[cid] = _GRAY
        for premise in by_id[cid].premises:
            if colour[premise] == _GRAY:
                raise MemDSLParseError(
                    f"cycle in reasoning-DAG premises through claim {cid!r}"
                )
            if colour[premise] == _WHITE:
                _visit(premise)
        colour[cid] = _BLACK

    for cid in by_id:
        if colour[cid] == _WHITE:
            _visit(cid)

    # A zero-claim ``@answer`` has no ground at all, so it cannot be a valid
    # answer: an answer must carry >=1 grounded claim bottoming out in a
    # citation. (An abstain path never builds a DAG, so ``@abstain`` with no
    # claims stays legal — this only rejects an empty ``@answer``.)
    if not self.claims:
        raise MemDSLParseError(
            "an @answer must carry at least one grounded claim (a zero-claim "
            "answer has no ground); use @abstain to decline"
        )
    # Grounded conclusion: it must name a claim and its premise closure must
    # contain at least one real citation.
    conclusion = self.conclusion or self.claims[-1].id
    if conclusion not in by_id:
        raise MemDSLParseError(f"conclusion {conclusion!r} does not name a claim")
    closure: set[str] = set()
    stack = [conclusion]
    while stack:
        cid = stack.pop()
        if cid in closure:
            continue
        closure.add(cid)
        stack.extend(by_id[cid].premises)
    if not any(by_id[cid].cites for cid in closure):
        raise MemDSLParseError(
            f"conclusion {conclusion!r} and its premise closure cite no substrate ref"
        )

    # OUTPUT v2 prose bindings are intentionally stricter than the router's
    # semantic gate: every proposition-bearing character must lie inside one
    # non-overlapping exact-quote span, and each span must point to exactly
    # one claim in the conclusion closure. This prevents citation splicing,
    # pooled licensing, and disconnected claims from laundering prose.
    if self.prose:
        if not self.prose_citations:
            raise MemDSLParseError("OUTPUT v2 prose requires at least one cite binding")
        covered = [False] * len(self.prose)
        for citation in sorted(self.prose_citations, key=lambda item: item.span):
            start, end = citation.span
            if start == end or end > len(self.prose):
                raise MemDSLParseError(
                    f"ProseCitation span {citation.span!r} is outside the prose"
                )
            if len(citation.claim_ids) != 1:
                raise MemDSLParseError(
                    "each OUTPUT v2 prose assertion must cite exactly one claim"
                )
            claim_id = citation.claim_ids[0]
            if claim_id not in closure:
                raise MemDSLParseError(
                    f"prose citation claim {claim_id!r} is outside the conclusion closure"
                )
            if any(covered[start:end]):
                raise MemDSLParseError("prose citation spans must not overlap")
            covered[start:end] = [True] * (end - start)
        if any(ch.isalnum() and not covered[index] for index, ch in enumerate(self.prose)):
            raise MemDSLParseError(
                "OUTPUT v2 prose contains an assertion not covered by an exact cite binding"
            )

RenderedNode dataclass

One node as rendered to the agent.

Carries a short, stable, envelope-local ref (e.g. n1) the agent cites instead of the full address, the unified address, the node type (from :data:zettelkasten.graph.VALID_TYPES, or an unknown type that degrades to the default body codec), a single-line title, a body (which may be a lossy view — see elided), the engine-emitted status, provenance handles, and the affordance handles available from here.

When elided is True the body is a truncated/lossy view and a ground(ref) re-fetches the full body; a verbatim-quote check must treat an elided body as not-yet-fetched rather than authoritative.

Source code in zettelkasten/synapse/memdsl/schema.py
@dataclass
class RenderedNode:
    """One node as rendered to the agent.

    Carries a short, stable, envelope-local ``ref`` (e.g. ``n1``) the agent cites
    instead of the full address, the unified ``address``, the node ``type`` (from
    :data:`zettelkasten.graph.VALID_TYPES`, or an unknown type that degrades to
    the default body codec), a single-line ``title``, a ``body`` (which may be a
    lossy view — see ``elided``), the engine-emitted ``status``, provenance
    handles, and the affordance handles available from here.

    When ``elided`` is True the ``body`` is a truncated/lossy view and a
    ``ground(ref)`` re-fetches the full body; a verbatim-quote check must treat an
    elided body as not-yet-fetched rather than authoritative.
    """

    ref: str
    address: Address
    type: str
    title: str = ""
    body: str = ""
    status: EpistemicStatus = EpistemicStatus.INFERRED
    provenance: list[str] = field(default_factory=list)
    affordances: list[Affordance] = field(default_factory=list)
    elided: bool = False

RuleClass

Bases: str, Enum

The graph-rewrite class of an action, in the sense of a design grammar.

An action is a production over the substrate view, so it carries the topologic/parametric distinction from grammar-based computational design synthesis (Königseder & Shea): a rule either changes the structure (which nodes/edges exist) or the parameters (a node's content/resolution) of the graph. This is engine-side metadata only — advisory, never serialized — that lets the (deferred) navigation/VOI policy reason about which kind of move it is spending budget on, and gives the reserved v2 write actions a principled axis:

  • TOPOLOGIC — changes the graph's structure. Read: expand/join/ prune change which nodes/edges are in view. Write (v2): assert/ retract add/remove a node, link/unlink add/remove an edge.
  • PARAMETRIC — changes a node's content/parameters without changing topology. Read: ground deepens a node's resolution. Write (v2): revise edits a node body in place.
  • TERMINAL — the whole-turn answer/abstain decisions, which end the loop rather than rewriting the graph.
Source code in zettelkasten/synapse/memdsl/schema.py
class RuleClass(str, Enum):
    """The graph-rewrite class of an action, in the sense of a design grammar.

    An action is a production over the substrate view, so it carries the
    topologic/parametric distinction from grammar-based computational design
    synthesis (Königseder & Shea): a rule either changes the *structure* (which
    nodes/edges exist) or the *parameters* (a node's content/resolution) of the
    graph. This is engine-side metadata only — advisory, never serialized — that
    lets the (deferred) navigation/VOI policy reason about *which kind* of move it
    is spending budget on, and gives the reserved v2 write actions a principled
    axis:

    * ``TOPOLOGIC`` — changes the graph's structure. Read: ``expand``/``join``/
      ``prune`` change which nodes/edges are in view. Write (v2): ``assert``/
      ``retract`` add/remove a node, ``link``/``unlink`` add/remove an edge.
    * ``PARAMETRIC`` — changes a node's content/parameters without changing
      topology. Read: ``ground`` deepens a node's resolution. Write (v2):
      ``revise`` edits a node body in place.
    * ``TERMINAL`` — the whole-turn ``answer``/``abstain`` decisions, which end
      the loop rather than rewriting the graph.
    """

    TOPOLOGIC = "topologic"
    PARAMETRIC = "parametric"
    TERMINAL = "terminal"

body_codec

body_codec(body_kind: str) -> BodyCodec

Return the (encode, decode) codec for body_kind, defaulting to text.

Source code in zettelkasten/synapse/memdsl/schema.py
def body_codec(body_kind: str) -> BodyCodec:
    """Return the (encode, decode) codec for ``body_kind``, defaulting to ``text``."""
    return BODY_CODECS.get(body_kind) or BODY_CODECS["text"]

get_action_spec

get_action_spec(name: str) -> ActionSpec | None

Return the :class:ActionSpec for name, or None if unregistered.

Source code in zettelkasten/synapse/memdsl/schema.py
def get_action_spec(name: str) -> ActionSpec | None:
    """Return the :class:`ActionSpec` for ``name``, or ``None`` if unregistered."""
    return ACTION_REGISTRY.get(name)

node_type_spec

node_type_spec(node_type: str) -> NodeTypeSpec

Return the :class:NodeTypeSpec for node_type.

Falls back to a default text spec for any unregistered/new type, which is what lets an unknown node type round-trip rather than crash the parser.

Source code in zettelkasten/synapse/memdsl/schema.py
def node_type_spec(node_type: str) -> NodeTypeSpec:
    """Return the :class:`NodeTypeSpec` for ``node_type``.

    Falls back to a default ``text`` spec for any unregistered/new type, which is
    what lets an unknown node type round-trip rather than crash the parser.
    """
    return NODE_TYPE_REGISTRY.get(node_type) or NodeTypeSpec(type=node_type, body_kind="text")

register_action

register_action(spec: ActionSpec) -> None

Register (or override) an action type in the open registry.

Source code in zettelkasten/synapse/memdsl/schema.py
def register_action(spec: ActionSpec) -> None:
    """Register (or override) an action type in the open registry."""
    ACTION_REGISTRY[spec.name] = spec

register_node_type

register_node_type(spec: NodeTypeSpec) -> None

Register (or override) a node type's body spec.

Source code in zettelkasten/synapse/memdsl/schema.py
def register_node_type(spec: NodeTypeSpec) -> None:
    """Register (or override) a node type's body spec."""
    NODE_TYPE_REGISTRY[spec.type] = spec

assign_refs

assign_refs(nodes: list[RenderedNode], *, prefix: str = 'n', start: int = 1) -> list[RenderedNode]

Assign stable, deterministic envelope-local refs (n1, n2, …) in order.

Mutates each node's ref in list order and returns the list, so the same node sequence always yields the same refs — the "stable id assignment" half of deterministic rendering. Ordering of the nodes themselves is the caller's responsibility (the serializer preserves it).

Source code in zettelkasten/synapse/memdsl/parser.py
def assign_refs(nodes: list[RenderedNode], *, prefix: str = "n", start: int = 1) -> list[RenderedNode]:
    """Assign stable, deterministic envelope-local refs (``n1``, ``n2``, …) in order.

    Mutates each node's ``ref`` in list order and returns the list, so the same
    node sequence always yields the same refs — the "stable id assignment" half
    of deterministic rendering. Ordering of the nodes themselves is the caller's
    responsibility (the serializer preserves it).
    """
    for offset, node in enumerate(nodes):
        node.ref = f"{prefix}{start + offset}"
    return nodes

parse_envelope

parse_envelope(text: str) -> Envelope

Parse MemDSL render text back into an :class:Envelope.

Raises :class:MemDSLParseError on a bad/foreign header or malformed block.

Source code in zettelkasten/synapse/memdsl/parser.py
def parse_envelope(text: str) -> Envelope:
    """Parse MemDSL render text back into an :class:`Envelope`.

    Raises :class:`MemDSLParseError` on a bad/foreign header or malformed block.
    """
    lines = _split_lines(text)
    if not lines:
        raise MemDSLParseError("empty envelope")
    version = _parse_header(lines[0], RENDER_HEADER)
    nodes: list[RenderedNode] = []
    gaps: list[GapToken] = []
    focus: str | None = None
    i = 1
    while i < len(lines):
        line = lines[i]
        if line.startswith("focus "):
            focus = line[len("focus ") :].strip()
            i += 1
        elif line.startswith("node "):
            node, i = _parse_node_block(lines, i)
            nodes.append(node)
        elif line.startswith("gap "):
            gaps.append(_parse_gap(line))
            i += 1
        elif line == "":
            i += 1
        else:
            raise MemDSLParseError(f"unexpected line in envelope: {line!r}")
    return Envelope(nodes=nodes, gaps=gaps, focus=focus, format_version=version)

parse_output

parse_output(text: str) -> AgentOutput

Parse agent OUTPUT text into an :class:AgentOutput.

The required @memdsl.out/<ver> header is what makes this injection-safe: a render envelope or a raw untrusted node body has a different (or no) header, so it is rejected here rather than mined for actions. Within an @answer block, v1 carries claims/conclude and v2 additionally requires prose plus exact cite bindings.

A PURELY SYNTACTIC, MARKER-DELIMITED pre-parse recovery (:func:_recover_output_envelope) first unwraps a single, exactly-matched enclosing markdown code fence (with only blank lines outside it), so a production LLM's common near-miss still yields an answer. It trims NOTHING it would have to classify as prose-vs-structural — only a matched fence pair's wrapper and leading/trailing blank lines — and hands the envelope interior VERBATIM to the strict parse below, which requires the first non-blank line to be the header and consumes to the end or refuses. So recovery can launder nothing: a pristine structural line OUTSIDE the recovered core (pre-header preamble, or outside a fence) is never dropped — it stays in the interior and is rejected here, or lies outside the fence and refuses — and everything below fails-closed.

Exactly one terminal decision is allowed: @answer XOR @abstain, once. A second terminal, or any action AFTER a terminal, is a :class:MemDSLParseError — a turn ends at its single terminal decision, so a trailing action can never silently ride along after it.

Source code in zettelkasten/synapse/memdsl/parser.py
def parse_output(text: str) -> AgentOutput:
    """Parse agent OUTPUT text into an :class:`AgentOutput`.

    The required ``@memdsl.out/<ver>`` header is what makes this injection-safe:
    a render envelope or a raw untrusted node body has a different (or no) header,
    so it is rejected here rather than mined for actions. Within an ``@answer``
    block, v1 carries claims/``conclude`` and v2 additionally requires
    ``prose`` plus exact ``cite`` bindings.

    A PURELY SYNTACTIC, MARKER-DELIMITED pre-parse recovery
    (:func:`_recover_output_envelope`) first unwraps a single, exactly-matched
    enclosing markdown code fence (with only blank lines outside it), so a
    production LLM's common near-miss still yields an answer. It trims NOTHING it
    would have to classify as prose-vs-structural — only a matched fence pair's
    wrapper and leading/trailing blank lines — and hands the envelope interior
    VERBATIM to the strict parse below, which requires the first non-blank line to
    be the header and consumes to the end or refuses. So recovery can launder
    nothing: a pristine structural line OUTSIDE the recovered core (pre-header
    preamble, or outside a fence) is never dropped — it stays in the interior and
    is rejected here, or lies outside the fence and refuses — and everything below
    fails-closed.

    Exactly one terminal decision is allowed: ``@answer`` XOR ``@abstain``, once.
    A second terminal, or any action AFTER a terminal, is a
    :class:`MemDSLParseError` — a turn ends at its single terminal decision, so a
    trailing action can never silently ride along after it.
    """
    lines = _split_lines(_recover_output_envelope(text))
    if not lines:
        raise MemDSLParseError("empty output")
    header = lines[0].strip()
    if header == OUTPUT_HEADER_V2:
        version = OUTPUT_FORMAT_VERSION
    elif header == OUTPUT_HEADER_V1:
        version = LEGACY_OUTPUT_FORMAT_VERSION
    else:
        raise MemDSLParseError(
            f"expected MemDSL header {OUTPUT_HEADER_V2!r} or {OUTPUT_HEADER_V1!r}, got {header!r}"
        )
    actions: list[Action] = []
    reasoning: ReasoningDAG | None = None
    abstain: Abstention | None = None
    terminal_seen = False
    i = 1
    while i < len(lines):
        line = lines[i]
        if line == "":
            i += 1
            continue
        if line == "@answer" or line.startswith("@answer "):
            if terminal_seen:
                raise MemDSLParseError(
                    "an output has at most one terminal decision (@answer/@abstain)"
                )
            reasoning, i = _parse_answer_block(lines, i + 1, version=version)
            terminal_seen = True
        elif line.startswith("@abstain"):
            if terminal_seen:
                raise MemDSLParseError(
                    "an output has at most one terminal decision (@answer/@abstain)"
                )
            abstain = _parse_abstain(line)
            terminal_seen = True
            i += 1
        else:
            if terminal_seen:
                raise MemDSLParseError(
                    f"action after the terminal decision is not allowed: {line!r}"
                )
            actions.append(_parse_action(line))
            i += 1
    return AgentOutput(
        actions=actions,
        reasoning=reasoning,
        abstain=abstain,
        format_version=version,
    )

parse_response

parse_response(text: str) -> ActionResponse

Parse an :class:ActionResponse fragment back from MemDSL text.

Source code in zettelkasten/synapse/memdsl/parser.py
def parse_response(text: str) -> ActionResponse:
    """Parse an :class:`ActionResponse` fragment back from MemDSL text."""
    lines = _split_lines(text)
    if not lines:
        raise MemDSLParseError("empty action response")
    version = _parse_header(lines[0], RESPONSE_HEADER)
    status = "ok"
    nodes: list[RenderedNode] = []
    gaps: list[GapToken] = []
    mutations: list[Mutation] = []
    impact: list[str] = []
    i = 1
    while i < len(lines):
        line = lines[i]
        if line.startswith("status "):
            status = line[len("status ") :].strip()
            i += 1
        elif line.startswith("node "):
            node, i = _parse_node_block(lines, i)
            nodes.append(node)
        elif line.startswith("gap "):
            gaps.append(_parse_gap(line))
            i += 1
        elif line.startswith("mut "):
            mutations.append(_parse_mutation(line))
            i += 1
        elif line.startswith("impact "):
            impact = [p.strip() for p in line[len("impact ") :].split(",") if p.strip()]
            i += 1
        elif line == "":
            i += 1
        else:
            raise MemDSLParseError(f"unexpected line in action response: {line!r}")
    return ActionResponse(
        status=status,
        nodes=nodes,
        gaps=gaps,
        mutations=mutations,
        impact=impact,
        format_version=version,
    )

serialize_envelope

serialize_envelope(env: Envelope) -> str

Serialize a render :class:Envelope to MemDSL text (deterministic).

Source code in zettelkasten/synapse/memdsl/parser.py
def serialize_envelope(env: Envelope) -> str:
    """Serialize a render :class:`Envelope` to MemDSL text (deterministic)."""
    lines = [RENDER_HEADER]
    if env.focus is not None:
        lines.append(_line(_lit("focus"), _emit(env.focus, _FOCUS)))
    for node in env.nodes:
        lines.extend(_serialize_node(node))
    for gap in env.gaps:
        lines.append(_serialize_gap(gap))
    return "\n".join(lines) + "\n"

serialize_output

serialize_output(out: AgentOutput) -> str

Serialize an :class:AgentOutput (actions + terminal decision) to MemDSL text.

Raises :class:MemDSLParseError if both a reasoning answer and an abstain are present (they are mutually exclusive terminal decisions).

The conclude line is emitted whenever the reasoning-DAG has claims: the conclusion is canonicalized to the last claim id at DAG construction (see :meth:ReasoningDAG.__post_init__), so there is no empty-conclusion case for the serializer to drop. This is what makes parse(serialize(x)) == x hold, closing the old asymmetry where the serializer omitted conclude but the parser back-filled it.

Source code in zettelkasten/synapse/memdsl/parser.py
def serialize_output(out: AgentOutput) -> str:
    """Serialize an :class:`AgentOutput` (actions + terminal decision) to MemDSL text.

    Raises :class:`MemDSLParseError` if both a reasoning ``answer`` and an
    ``abstain`` are present (they are mutually exclusive terminal decisions).

    The ``conclude`` line is emitted whenever the reasoning-DAG has claims: the
    conclusion is canonicalized to the last claim id at DAG construction (see
    :meth:`ReasoningDAG.__post_init__`), so there is no empty-conclusion case for
    the serializer to drop. This is what makes ``parse(serialize(x)) == x`` hold,
    closing the old asymmetry where the serializer omitted ``conclude`` but the
    parser back-filled it.
    """
    if out.reasoning is not None and out.abstain is not None:
        raise MemDSLParseError("an output cannot both @answer and @abstain")
    if out.format_version == LEGACY_OUTPUT_FORMAT_VERSION:
        header = OUTPUT_HEADER
        if out.reasoning is not None and (
            out.reasoning.prose or out.reasoning.prose_citations
        ):
            raise MemDSLParseError("OUTPUT v1 cannot serialize prose cite bindings")
    elif out.format_version == OUTPUT_FORMAT_VERSION:
        header = OUTPUT_HEADER_V2
        if out.reasoning is not None:
            out.reasoning.validate()
    else:
        raise MemDSLParseError(
            f"unsupported OUTPUT version {out.format_version!r}; expected "
            f"{LEGACY_OUTPUT_FORMAT_VERSION!r} or {OUTPUT_FORMAT_VERSION!r}"
        )

    lines = [header]
    for action in out.actions:
        lines.append(_serialize_action(action))
    if out.reasoning is not None:
        lines.append("@answer")
        for claim in out.reasoning.claims:
            lines.append(_serialize_claim(claim))
        if out.format_version == OUTPUT_FORMAT_VERSION:
            lines.append(_line(_lit("prose"), _lit("::"), _emit(out.reasoning.prose, _PROSE_TEXT)))
            for citation in sorted(
                out.reasoning.prose_citations, key=lambda item: item.span
            ):
                start, end = citation.span
                quote = out.reasoning.prose[start:end]
                lines.append(
                    _line(
                        _lit("cite"),
                        _emit(citation.claim_ids[0], _CLAIM_ID),
                        _lit("::"),
                        _emit(quote, _PROSE_QUOTE),
                    )
                )
        if out.reasoning.conclusion:
            # ``conclude`` is ``[len:].strip()``-parsed and names a claim id (a
            # whitespace-free token), so guard it like a claim id.
            lines.append(_line(_lit("conclude"), _emit(out.reasoning.conclusion, _CONCLUDE_ID)))
    if out.abstain is not None:
        # The reason is the anchored head of the ``@abstain`` grammar
        # (:data:`_ABSTAIN_RE`): it must not carry the `` :: `` reason/detail
        # separator or a line break (either would let the parser rewrite the
        # reason/detail boundary or split a fresh structural line), and must be
        # non-empty. It also forbids leading/trailing whitespace: the regex
        # capture does NOT strip the reason (so such whitespace would technically
        # round-trip), but it is rejected as harmless over-strictness — a reason
        # label carries no surrounding whitespace. The detail, like every other
        # rest-of-line detail, is captured verbatim, so its whitespace round-trips.
        parts = [_lit("@abstain"), _emit(out.abstain.reason, _ABSTAIN_REASON)]
        if out.abstain.detail:
            parts.append(_lit("::"))
            parts.append(_emit(out.abstain.detail, _ABSTAIN_DETAIL))
        lines.append(_line(*parts))
    return "\n".join(lines) + "\n"

serialize_response

serialize_response(resp: ActionResponse) -> str

Serialize an :class:ActionResponse fragment to MemDSL text.

v1 emits status + read results (nodes/gaps); the reserved mut/ impact lines only appear when future write actions populate them.

Source code in zettelkasten/synapse/memdsl/parser.py
def serialize_response(resp: ActionResponse) -> str:
    """Serialize an :class:`ActionResponse` fragment to MemDSL text.

    v1 emits ``status`` + read results (nodes/gaps); the reserved ``mut``/
    ``impact`` lines only appear when future write actions populate them.
    """
    lines = [RESPONSE_HEADER, _line(_lit("status"), _emit(resp.status, _STATUS))]
    for node in resp.nodes:
        lines.extend(_serialize_node(node))
    for gap in resp.gaps:
        lines.append(_serialize_gap(gap))
    for mutation in resp.mutations:
        lines.append(_serialize_mutation(mutation))
    if resp.impact:
        lines.append(_line(_lit("impact"), _join(_lit(", "), [_emit(x, _IMPACT) for x in resp.impact])))
    return "\n".join(lines) + "\n"