"""Knowtation frontmatter schema — typed parser, validator, and migrator. This module is the **single source of truth** for every frontmatter field that the Knowtation Muse plugin reads, writes, or validates. It replaces the lightweight regex key-extractor in :mod:`~muse.plugins.knowtation.detector` for cases where field *values* matter (diff, merge, schema migration, domain-info). Schema version -------------- :data:`FRONTMATTER_SCHEMA_VERSION` is a plain integer string (``"1"``, ``"2"``, …) **independent** of the Muse package semver (which lives in :mod:`muse._version`). The schema version is bumped when: - A field is added with a non-trivial coercion rule or mandatory default, OR - A field is renamed or removed. Adding a new optional field with a ``None`` / empty-list default does **not** require a version bump (backward-compatible). Field reference (SPEC §2) -------------------------- §2.1 Common (any note): title, project, tags, date, updated §2.2 Inbox (notes written by capture/import pipeline): source (canonical), source_id (recommended), source_type (import alias) §2.3 Intention and temporal (all optional): follows, causal_chain_id, entity, episode_id, summarizes, summarizes_range, state_snapshot Phase 1.7 — mist attachment IDs (reserved, schema v2): attachments §2.4 Reserved for Phase 12 (blockchain; parsers MUST NOT require them): network, wallet_address, tx_hash, payment_status All other keys are preserved verbatim in :attr:`ParsedFrontmatter.extra` so that round-trips through ``parse → to_dict`` are lossless. """ from __future__ import annotations import logging import re from dataclasses import dataclass, field from typing import Any import yaml # pyyaml — safe_load only; no arbitrary object instantiation logger = logging.getLogger(__name__) # ────────────────────────────────────────────────────────────────────────────── # Schema version # ────────────────────────────────────────────────────────────────────────────── #: Current frontmatter schema version. Bumped independently of Muse semver. #: Phase 1.3 baseline = "1". Phase 1.7 (``attachments`` field) = "2". FRONTMATTER_SCHEMA_VERSION: str = "2" #: All schema version strings this module knows how to migrate through, ordered. SCHEMA_VERSIONS: tuple[str, ...] = ("1", "2") # ────────────────────────────────────────────────────────────────────────────── # Slug / tag normalisation (mirrors SPEC §1) # ────────────────────────────────────────────────────────────────────────────── _NON_SLUG_RE: re.Pattern[str] = re.compile(r"[^a-z0-9\-]") _EDGE_HYPHEN_RE: re.Pattern[str] = re.compile(r"^-+|-+$") def normalize_slug(value: str) -> str: """Normalise *value* to a SPEC §1 slug. Lowercases the string, replaces every character that is not ``[a-z0-9-]`` with a hyphen, then strips leading/trailing hyphens. Args: value: Arbitrary string (e.g. a project name or tag). Returns: Slug string that satisfies SPEC §1 normalisation. Examples:: >>> normalize_slug("Born Free") 'born-free' >>> normalize_slug(" hello_world! ") 'hello-world-' # trailing hyphen stripped → 'hello-world' """ lowered = value.lower() slugged = _NON_SLUG_RE.sub("-", lowered) return _EDGE_HYPHEN_RE.sub("", slugged) # ────────────────────────────────────────────────────────────────────────────── # Value coercion helpers # ────────────────────────────────────────────────────────────────────────────── def _coerce_optional_str(value: Any) -> str | None: """Return *value* as ``str`` when non-``None``, else ``None``.""" if value is None: return None return str(value) def _coerce_str_or_list(value: Any) -> list[str]: """Coerce a scalar-or-list YAML value to a ``list[str]``. Handles: - ``None`` → ``[]`` - YAML list → each element stringified - A bare string → split on commas (``"a, b, c"`` → ``["a", "b", "c"]``) - Any other scalar → ``[str(value)]`` This covers both the ``tags: [foo, bar]`` (YAML list) and ``tags: foo, bar`` (comma-separated scalar) forms permitted by SPEC §2.1. """ if value is None: return [] if isinstance(value, list): return [str(v) for v in value] if isinstance(value, str): parts = [p.strip() for p in value.split(",")] return [p for p in parts if p] return [str(value)] # ────────────────────────────────────────────────────────────────────────────── # All recognised top-level keys (used to populate ``extra``) # ────────────────────────────────────────────────────────────────────────────── _KNOWN_KEYS: frozenset[str] = frozenset( { # §2.1 Common "title", "project", "tags", "date", "updated", # §2.2 Inbox "source", "source_id", # Import-pipeline alias "source_type", # §2.3 Temporal / intention "follows", "causal_chain_id", "entity", "episode_id", "summarizes", "summarizes_range", "state_snapshot", # Phase 1.7 — mist attachment IDs "attachments", # §2.4 Reserved Phase 12 "network", "wallet_address", "tx_hash", "payment_status", } ) # ────────────────────────────────────────────────────────────────────────────── # ParsedFrontmatter dataclass # ────────────────────────────────────────────────────────────────────────────── @dataclass class ParsedFrontmatter: """Fully typed representation of a Knowtation note's YAML frontmatter. Every field mirrors SPEC §2 (§2.1 common, §2.2 inbox, §2.3 temporal, §2.4 reserved Phase-12) plus the import-pipeline ``source_type`` alias and the Phase 1.7 ``attachments`` list. All list fields default to ``[]``; optional string fields default to ``None``; ``state_snapshot`` defaults to ``False``. Unknown keys are preserved verbatim in :attr:`extra` so that ``parse → to_dict → parse`` round-trips are lossless. """ # ── §2.1 Common ────────────────────────────────────────────────────────── title: str | None = None project: str | None = None tags: list[str] = field(default_factory=list) date: str | None = None updated: str | None = None # ── §2.2 Inbox ─────────────────────────────────────────────────────────── source: str | None = None source_id: str | None = None #: Import-pipeline alias for ``source``. Kept for backward-compat reading; #: new notes should use ``source`` only (SPEC §2.2). source_type: str | None = None # ── §2.3 Intention and temporal ────────────────────────────────────────── follows: list[str] = field(default_factory=list) causal_chain_id: str | None = None entity: list[str] = field(default_factory=list) episode_id: str | None = None summarizes: list[str] = field(default_factory=list) summarizes_range: str | None = None state_snapshot: bool = False # ── Phase 1.7 — mist attachment IDs ───────────────────────────────────── attachments: list[str] = field(default_factory=list) # ── §2.4 Reserved Phase 12 ─────────────────────────────────────────────── #: Parsers MUST NOT require these; stored as-is without validation. network: str | None = None wallet_address: str | None = None tx_hash: str | None = None payment_status: str | None = None # ── Pass-through for unknown / future keys ──────────────────────────────── extra: dict[str, Any] = field(default_factory=dict) # ── Schema metadata ─────────────────────────────────────────────────────── #: Schema version at parse time, from :data:`FRONTMATTER_SCHEMA_VERSION`. schema_version: str = FRONTMATTER_SCHEMA_VERSION # ── Computed properties ─────────────────────────────────────────────────── @property def effective_source(self) -> str | None: """Return ``source`` when set, else ``source_type`` (import-pipeline alias). Callers that only care *whether* a source is present should use this rather than checking both fields separately. """ return self.source or self.source_type @property def is_inbox_note(self) -> bool: """Return ``True`` when the note satisfies the SPEC §2.2 inbox contract. An inbox note must have both ``source`` (or ``source_type``) and ``date``. """ return self.effective_source is not None and self.date is not None def to_dict(self) -> dict[str, Any]: """Serialise back to a plain ``dict`` (suitable for ``yaml.dump``). Only non-empty / non-None / non-False fields are included so that the output remains clean for human readers. ``extra`` keys are merged last (known fields take precedence if there is a collision). """ out: dict[str, Any] = {} # §2.1 if self.title is not None: out["title"] = self.title if self.project is not None: out["project"] = self.project if self.tags: out["tags"] = self.tags if self.date is not None: out["date"] = self.date if self.updated is not None: out["updated"] = self.updated # §2.2 if self.source is not None: out["source"] = self.source if self.source_id is not None: out["source_id"] = self.source_id if self.source_type is not None: out["source_type"] = self.source_type # §2.3 if self.follows: out["follows"] = self.follows if self.causal_chain_id is not None: out["causal_chain_id"] = self.causal_chain_id if self.entity: out["entity"] = self.entity if self.episode_id is not None: out["episode_id"] = self.episode_id if self.summarizes: out["summarizes"] = self.summarizes if self.summarizes_range is not None: out["summarizes_range"] = self.summarizes_range if self.state_snapshot: out["state_snapshot"] = self.state_snapshot # Phase 1.7 if self.attachments: out["attachments"] = self.attachments # §2.4 reserved if self.network is not None: out["network"] = self.network if self.wallet_address is not None: out["wallet_address"] = self.wallet_address if self.tx_hash is not None: out["tx_hash"] = self.tx_hash if self.payment_status is not None: out["payment_status"] = self.payment_status # Unknown pass-through keys (known fields already written above) for k, v in self.extra.items(): if k not in out: out[k] = v return out # ────────────────────────────────────────────────────────────────────────────── # Raw YAML block extraction # ────────────────────────────────────────────────────────────────────────────── def _extract_frontmatter_block(content: bytes) -> str | None: """Return the raw YAML string inside the leading ``---`` delimiters. Recognises both ``---`` … ``---`` and ``---`` … ``...`` (YAML end-of-document marker). Returns ``None`` when no valid frontmatter block is found. Args: content: Raw bytes of the note file. Returns: YAML string between the delimiters, or ``None``. """ try: text = content.decode("utf-8", errors="replace") except Exception: return None if not text.startswith("---"): return None rest = text[3:] end = -1 for delim in ("\n---", "\n..."): pos = rest.find(delim) if pos != -1 and (end == -1 or pos < end): end = pos if end == -1: return None return rest[:end] # ────────────────────────────────────────────────────────────────────────────── # Public parse API # ────────────────────────────────────────────────────────────────────────────── def parse_frontmatter(content: bytes) -> ParsedFrontmatter | None: """Parse the YAML frontmatter block of a note into a typed object. Uses ``yaml.safe_load`` — no arbitrary Python object instantiation. Args: content: Raw bytes of the note file (Markdown, CRLF or LF, UTF-8). Returns: A :class:`ParsedFrontmatter` when a valid frontmatter block is found, or ``None`` when the file has no frontmatter, has a YAML parse error, or the frontmatter is not a mapping. Notes: - ``tags``, ``follows``, ``entity``, ``summarizes``, ``attachments`` accept either a YAML list or a comma-separated string. - ``source_type`` is stored as-is; call :attr:`~ParsedFrontmatter.effective_source` to get the canonical source regardless of which key was used. - Unknown keys are preserved in :attr:`~ParsedFrontmatter.extra`. """ raw = _extract_frontmatter_block(content) if raw is None: return None try: data = yaml.safe_load(raw) except yaml.YAMLError as exc: logger.debug("YAMLError parsing frontmatter block: %s", exc) return None if not isinstance(data, dict): return None extra: dict[str, Any] = {k: v for k, v in data.items() if k not in _KNOWN_KEYS} return ParsedFrontmatter( # §2.1 title=_coerce_optional_str(data.get("title")), project=_coerce_optional_str(data.get("project")), tags=_coerce_str_or_list(data.get("tags")), date=_coerce_optional_str(data.get("date")), updated=_coerce_optional_str(data.get("updated")), # §2.2 source=_coerce_optional_str(data.get("source")), source_id=_coerce_optional_str(data.get("source_id")), source_type=_coerce_optional_str(data.get("source_type")), # §2.3 follows=_coerce_str_or_list(data.get("follows")), causal_chain_id=_coerce_optional_str(data.get("causal_chain_id")), entity=_coerce_str_or_list(data.get("entity")), episode_id=_coerce_optional_str(data.get("episode_id")), summarizes=_coerce_str_or_list(data.get("summarizes")), summarizes_range=_coerce_optional_str(data.get("summarizes_range")), state_snapshot=bool(data.get("state_snapshot", False)), # Phase 1.7 attachments=_coerce_str_or_list(data.get("attachments")), # §2.4 network=_coerce_optional_str(data.get("network")), wallet_address=_coerce_optional_str(data.get("wallet_address")), tx_hash=_coerce_optional_str(data.get("tx_hash")), payment_status=_coerce_optional_str(data.get("payment_status")), extra=extra, schema_version=FRONTMATTER_SCHEMA_VERSION, ) # ────────────────────────────────────────────────────────────────────────────── # Mist-ID validation constants (Phase 1.7) # ────────────────────────────────────────────────────────────────────────────── # Mist attachment IDs are the first 12 characters of the base58-encoded # SHA-256 hash of the blob content, per the Mist object-store manifest spec. # Base58 alphabet excludes 0/O/I/l to prevent transcription errors. _BASE58_ALPHABET: str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" _MIST_ID_RE: re.Pattern[str] = re.compile( r"^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{12}$" ) # ────────────────────────────────────────────────────────────────────────────── # Validation # ────────────────────────────────────────────────────────────────────────────── def validate_inbox_note(fm: ParsedFrontmatter) -> list[str]: """Validate *fm* against the SPEC §2.2 inbox-note required-field contract. Args: fm: A parsed frontmatter object (from :func:`parse_frontmatter`). Returns: List of human-readable error strings. Empty list means valid. """ errors: list[str] = [] if fm.effective_source is None: errors.append( "Inbox note MUST have 'source' (or 'source_type') field (SPEC §2.2)." ) if fm.date is None: errors.append("Inbox note MUST have 'date' field (SPEC §2.2).") return errors def validate_attachment_ids(fm: ParsedFrontmatter) -> list[str]: """Validate that every entry in *fm.attachments* is a well-formed mist ID. A valid mist ID is exactly 12 characters from the base58 alphabet (``123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz``). This matches the first 12 characters of the base58-encoded SHA-256 hash produced by the Mist object-store push command. Args: fm: A parsed frontmatter object. Returns: List of human-readable error strings; empty list means all IDs are valid (or the ``attachments`` field is empty). """ errors: list[str] = [] for i, mist_id in enumerate(fm.attachments): if not isinstance(mist_id, str): errors.append( f"attachments[{i}]: expected a string, got {type(mist_id).__name__!r}." ) elif not _MIST_ID_RE.match(mist_id): errors.append( f"attachments[{i}]: {mist_id!r} is not a valid mist ID " f"(must be exactly 12 base58 characters)." ) return errors def validate_slug_field(value: str, field_name: str) -> list[str]: """Return validation errors if *value* is not a valid SPEC §1 slug. Args: value: The field value to check. field_name: Name used in error messages (e.g. ``"project"``). Returns: List of error strings; empty means the value is a valid slug. """ normalized = normalize_slug(value) if normalized != value: return [ f"Field '{field_name}' value '{value}' is not a valid slug; " f"normalized form is '{normalized}'." ] return [] # ────────────────────────────────────────────────────────────────────────────── # Schema migration # ────────────────────────────────────────────────────────────────────────────── def migrate_frontmatter(data: dict[str, Any], from_version: str) -> dict[str, Any]: """Migrate a raw frontmatter dict from *from_version* to the current schema. Migrations are applied sequentially so ``from_version="1"`` will apply v1→v2, v2→v3, … until :data:`FRONTMATTER_SCHEMA_VERSION` is reached. When *from_version* already equals :data:`FRONTMATTER_SCHEMA_VERSION`, the dict is returned unchanged (no copy). Defined migrations ------------------ v1 → v2 (stub, Phase 1.7): Phase 1.7 will make ``attachments`` a first-class field. At that point this step will normalise any legacy attachment key that lived in ``extra``. For now the migration is a no-op so callers can exercise the pipeline end-to-end before the field name is locked. Args: data: Raw frontmatter dict as returned by ``yaml.safe_load``. from_version: Schema version string stored alongside the data. Returns: Migrated dict, or the original dict when no migration is needed. Raises: ValueError: When *from_version* is not in :data:`SCHEMA_VERSIONS`. """ if from_version == FRONTMATTER_SCHEMA_VERSION: return data if from_version not in SCHEMA_VERSIONS: raise ValueError( f"Unknown frontmatter schema version '{from_version}'. " f"Known versions: {SCHEMA_VERSIONS}." ) current = data.copy() start_idx = SCHEMA_VERSIONS.index(from_version) target_idx = SCHEMA_VERSIONS.index(FRONTMATTER_SCHEMA_VERSION) for step in range(start_idx, target_idx): step_key = (SCHEMA_VERSIONS[step], SCHEMA_VERSIONS[step + 1]) migration_fn = _MIGRATION_TABLE.get(step_key) if migration_fn is not None: current = migration_fn(current) return current def _migrate_v1_to_v2(data: dict[str, Any]) -> dict[str, Any]: """v1 → v2: promote the ``attachments`` field to first-class status. In schema v1, notes that stored mist blob IDs placed them under various ad-hoc keys (``attachment``, ``mist_id``, ``mist_ids``). v2 normalises all of these into the canonical ``attachments: [, ...]`` list. Migration steps applied (in order): 1. ``attachment`` (singular) → appended to ``attachments`` list, key removed. 2. ``mist_id`` (singular) → appended to ``attachments`` list, key removed. 3. ``mist_ids`` (list) → items merged into ``attachments`` list, key removed. 4. Existing ``attachments`` value is coerced to a list via :func:`_coerce_str_or_list` so that a bare scalar is accepted. Args: data: Raw frontmatter dict (from ``yaml.safe_load``). Returns: New dict with the normalised ``attachments`` key. """ result = data.copy() # Collect attachments from all legacy locations. merged: list[str] = [] existing = result.pop("attachments", None) if existing is not None: merged.extend(_coerce_str_or_list(existing)) for legacy_key in ("attachment", "mist_id"): val = result.pop(legacy_key, None) if val is not None: merged.extend(_coerce_str_or_list(val)) legacy_list = result.pop("mist_ids", None) if legacy_list is not None: merged.extend(_coerce_str_or_list(legacy_list)) if merged: result["attachments"] = merged return result _MIGRATION_TABLE: dict[tuple[str, str], Any] = { ("1", "2"): _migrate_v1_to_v2, }