Skip to content
nauro

Concepts

Core concepts

A decision in Nauro is a markdown file with YAML frontmatter plus a structured body, parsed and validated by a strict Pydantic model. This page covers what a decision is on disk, the frontmatter model and its enums, how superseding works, and how the conflict-catch check returns related decisions over BM25 retrieval. The conflict check surfaces related history and a deterministic assessment; it does not judge conflicts or auto-supersede.

What a decision is

  • A decision is a single markdown file (under the store's decisions/ directory) made of a YAML frontmatter fence (---) followed by a markdown body. The authoritative parsed shape is the Pydantic Decision model in nauro_core/decision_model.py.
  • The model is strict by design (Pydantic ConfigDict(extra="forbid", validate_assignment=True)): unknown frontmatter keys, missing required fields, malformed YAML, non-ISO dates, reasonless rejected alternatives on active decisions, and superseded decisions without a superseded_by ref all raise.
  • parse_decision(text, filename) reads the file and returns a validated Decision; format_decision(decision) goes the other way. The round-trip is idempotent: format, parse, format is byte-identical.
  • The frontmatter requires a leading ---\n fence; the body must contain an H1 of the form # NNN — Title (with an em-dash separator) and a ## Decision section (v2 does not accept ## Rationale).
  • Five fields are derived by the parser, not stored in frontmatter: num (from filename), title (from H1), rationale (from the ## Decision body), body (the full markdown body), and content (the full file text including frontmatter).
decision_model.py
class Decision(BaseModel):
    model_config = ConfigDict(extra="forbid", validate_assignment=True)

    # Frontmatter: required
    date: _date
    confidence: DecisionConfidence

    # Frontmatter: defaulted
    version: int = Field(default=1, ge=1)
    status: DecisionStatus = DecisionStatus.active

    # Frontmatter: semantically optional
    decision_type: DecisionType | None = None
    reversibility: Reversibility | None = None
    source: DecisionSource | None = None
    files_affected: list[str] = Field(default_factory=list)
    supersedes: str | None = None
    superseded_by: str | None = None

    # Body-rendered
    rejected: list[RejectedAlternative] = Field(default_factory=list)

    # Derived (set by parse_decision; excluded from frontmatter dump)
    num: int = Field(default=0, ge=0)
    title: str = ""
    rationale: str = ""
    body: str = ""
    content: str = ""

Source: packages/nauro-core/src/nauro_core/decision_model.py (Decision, parse_decision, format_decision).

On-disk format: frontmatter fields and enums

  • Required frontmatter: date (ISO date, e.g. 2026-04-16) and confidence (enum: high, medium, low).
  • Defaulted frontmatter: version (int >= 1, default 1) and status (enum: active, superseded, default active).
  • Optional frontmatter: decision_type, reversibility, source, files_affected (list of strings, default empty), supersedes, superseded_by.
  • DecisionType enum values (decision_model.py): architecture, api_design, infrastructure, pattern, refactor, data_model. Note: the separate DECISION_TYPES tuple in constants.py lists library_choice and orders differently (architecture, library_choice, pattern, refactor, api_design, infrastructure, data_model). The authoritative validated enum for parsed decisions is the decision_model.py DecisionType.
  • Reversibility enum values: easy, moderate, hard. DecisionSource enum values: mcp, commit, compaction, manual, and import (the Python member is named import_ but serializes as the string "import").
  • Frontmatter is emitted in a fixed canonical key order: date, version, status, confidence, decision_type, reversibility, source, files_affected, supersedes, superseded_by. None-valued optional fields are dropped from the dump.
  • rejected is NOT in frontmatter. It is popped from the dump dict and rendered into the markdown body as ## Rejected Alternatives with one ### name subsection per alternative.
decision_model.py
class DecisionStatus(str, Enum):
    active = "active"
    superseded = "superseded"

class DecisionConfidence(str, Enum):
    high = "high"; medium = "medium"; low = "low"

class DecisionType(str, Enum):
    architecture = "architecture"
    api_design = "api_design"
    infrastructure = "infrastructure"
    pattern = "pattern"
    refactor = "refactor"
    data_model = "data_model"

class Reversibility(str, Enum):
    easy = "easy"; moderate = "moderate"; hard = "hard"

class DecisionSource(str, Enum):
    mcp = "mcp"; commit = "commit"; compaction = "compaction"
    manual = "manual"
    import_ = "import"  # serializes as the string "import"

Source: packages/nauro-core/src/nauro_core/decision_model.py (DecisionStatus, DecisionConfidence, DecisionType, Reversibility, DecisionSource, _FRONTMATTER_ORDER, _DERIVED_FIELDS); packages/nauro-core/src/nauro_core/constants.py (DECISION_TYPES, REVERSIBILITY_LEVELS, VALID_CONFIDENCES).

A real decision file

  • This is the exact output of format_decision for the scaffolded first decision (the seed Nauro writes when a store is created via nauro init). It shows the canonical shape: frontmatter fence, # NNN — Title H1 (zero-padded to 3 digits with an em-dash), ## Decision rationale, and ## Rejected Alternatives with ### name subsections and reasons.
  • On active decisions, every rejected alternative must carry a reason. A reasonless rejection raises (the require_reasons_on_active validator). The scaffold seed has confidence: high and status: active, with version and status emitted because they are part of the fixed key order.
  • The H1 number is rendered with {decision.num:03d} and the body sections are joined by blank lines; the file always ends with a trailing newline.
001-initial-setup.md
---
date: 2026-04-16
version: 1
status: active
confidence: high
---

# 001 — Initial project setup

## Decision

Initial project setup — scaffold the Nauro project store and begin tracking architectural decisions.

Explicit decision tracking from day one prevents context loss when onboarding contributors or switching between projects.

## Rejected Alternatives

### Ad-hoc notes in README

Hard to find, no structure — does not scale past a few entries.

### No tracking until later

Context is already lost by the time you decide you need it.

A decision that supersedes an earlier one carries the optional metadata fields and a supersedes backref. Here decision 084 replaces decision 070:

084-serve-the-marketing-site-from-s3-cloudfront.md
---
date: 2026-05-12
version: 1
status: active
confidence: high
decision_type: infrastructure
reversibility: hard
source: mcp
files_affected:
- infra/site.yaml
supersedes: '70'
---

# 084 — Serve the marketing site from S3 + CloudFront

## Decision

Static export deployed to S3 behind CloudFront. Replaces the prior container-based hosting in decision 070.

## Rejected Alternatives

### Keep the container host

Idle cost and patching overhead for a fully static site.

Source: packages/nauro/src/nauro/templates/scaffolds.py (_build_first_decision, _FIRST_DECISION_RATIONALE, _FIRST_DECISION_REJECTED); packages/nauro-core/src/nauro_core/decision_model.py (format_decision).

Body parsing rules

  • The H1 must match ^#\s+(\d+)\s+—\s+(.+)$ (_H1_PATTERN): a number, an em-dash, then the title. A missing or malformed H1 raises. The title is taken from the H1; the number is taken from the filename via extract_decision_number, not the H1.
  • The ## Decision section body becomes rationale. It is required; a file with ## Rationale instead of ## Decision raises (the parser stays strict; legacy renames happen in a separate migration script).
  • ## Rejected Alternatives is optional. Its body is split on ### name subsections (_SUBSECTION_SPLIT); each subsection's stripped body becomes the alternative's reason (None if empty).
  • extract_decision_number (parsing.py) accepts several identifier shapes: file stem 042-some-title (or with .md), synthetic id decision-042, prefixed D042 / D42 (the char after the d / D must be a digit), and bare integer 42. It returns the leading integer or None.
decision_model.py
_SUBSECTION_SPLIT = re.compile(r"^###\s+(.+?)\s*$", re.MULTILINE)

def _parse_rejected_subsections(section_text: str) -> list[RejectedAlternative]:
    if not section_text.strip():
        return []
    chunks = _SUBSECTION_SPLIT.split(section_text)
    # chunks alternate [preamble, name1, body1, name2, body2, ...]
    alternatives = []
    for i in range(1, len(chunks), 2):
        name = chunks[i].strip()
        reason_raw = chunks[i + 1].strip() if i + 1 < len(chunks) else ""
        alternatives.append(RejectedAlternative(name=name, reason=reason_raw or None))
    return alternatives

Source: packages/nauro-core/src/nauro_core/decision_model.py (_H1_PATTERN, _extract_section_body, _parse_rejected_subsections); packages/nauro-core/src/nauro_core/parsing.py (extract_decision_number).

The decision lifecycle: active vs superseded

  • status has exactly two values: active and superseded. A decision is never deleted. Superseding replaces it while leaving the old file on disk for history.
  • Supersession refs (supersedes, superseded_by) are validated as plain integer strings: "70", not "070", not "070-some-slug", not "D70". Leading zeros raise; the canonical form is str(int(v)).
  • A decision with status=superseded MUST carry a superseded_by ref pointing at the replacing decision (the superseded_requires_ref validator), otherwise parsing raises.
  • Superseding is a two-write operation (propose_decision operation="supersede", _do_supersede): (1) write the new decision, read it back, then rewrite it to carry supersedes = str(old_num); (2) flip the old decision to status=superseded with superseded_by = str(new_num). The writes are sequential and best-effort. If the second write fails, the new decision stands and a structured half-state ErrorPayload is returned so sync-repair reconciles on the next pull.
  • operation="update" is rationale-only (_do_update): it bumps version and appends a dated paragraph (*Update (vN) — YYYY-MM-DD:* ...) to the rationale. It explicitly rejects attempts to change title, rejected, files_affected, decision_type, reversibility, or confidence (_UPDATE_DISALLOWED_FIELDS); those require a supersede. The MCP instructions note a wrongly-confirmed supersede is hard to reverse, so default to add when uncertain.
  • Decision numbers are assigned as max(existing num) + 1 (_next_decision_num); the filename is {num:03d}-{slug}.md (_slugify, slug capped at 60 chars).
decision_model.py
@field_validator("supersedes", "superseded_by")
@classmethod
def _validate_supersession_ref(cls, v: str | None) -> str | None:
    if v is None:
        return v
    if not v.isdigit():
        raise ValueError(
            f"supersession ref must be a plain integer string (e.g. '70'), got {v!r}"
        )
    canonical = str(int(v))
    if v != canonical:
        raise ValueError(
            f"supersession ref must not have leading zeros, got {v!r}; expected {canonical!r}"
        )
    return v
propose_decision.py
# new decision written, read back, then rewritten to carry the backref
new_decision_rewritten = new_decision.model_copy(update={"supersedes": str(old_num)})
# ... then flip the old decision
old_rewritten = old_decision.model_copy(
    update={
        "status": DecisionStatus.superseded,
        "superseded_by": str(new_num),
    }
)

Source: packages/nauro-core/src/nauro_core/decision_model.py (_validate_supersession_ref, superseded_requires_ref, require_reasons_on_active); packages/nauro-core/src/nauro_core/operations/propose_decision.py (_do_supersede, _do_update, _next_decision_num, _slugify, _UPDATE_DISALLOWED_FIELDS).

The conflict-catch mechanism: check_decision

  • check_decision(store, proposed_approach, context=None, use_embeddings=False) is the precondition before adopting any technical approach. It retrieves related decisions and returns a deterministic assessment. It does NOT judge conflicts and does NOT auto-supersede. The agent must call get_decision on each hit and reason itself.
  • Retrieval is BM25 over the in-store decision corpus, indexing title + rationale per decision (built in-memory per call with bm25s and PyStemmer). Only active decisions are considered (bm25_retrieve filters out anything whose status is not active and excludes score <= 0). top_k defaults to 5.
  • The query is shaped to match the historical input envelope: an approach head capped at 100 chars, joined to the full approach plus optional context capped at 200 chars: f"{approach_head}. {body_text[:200]}". context is concatenated into body_text.
  • Before retrieval, proposed_approach and context are each validated against MAX_APPROACH_LENGTH / MAX_CONTEXT_LENGTH (both 5000) via check_content_length, which REJECTS over-length input (returns an ErrorPayload); it does not truncate. This rejection is separate from the 100/200 query-shaping truncation.
  • check_decision uses an extended stopword list: bm25s's default English list (STOPWORDS_EN) plus "use", because action verbs like "use" appear in nearly every decision title and would otherwise surface false-positive near-neighbours on every call. The same extension is used by Tier 2 validation (TIER2_STOPWORDS).
  • The scaffold-seed bookkeeping decision (num == 1 and title "Initial project setup") is excluded from retrieval so it does not gate user proposals.
  • An empty store (after seed exclusion) returns the NO_DECISIONS_TO_CHECK onboarding text; no BM25 hits returns "No related decisions found." Otherwise it returns a CheckDecisionResult with related_decisions (a list of RelatedDecision: id, title, score, status, date, rationale_preview) and a single-line assessment.
  • The assessment is built deterministically from retrieval facts only: it names the top match (label, title, status, decided date, BM25 score) and instructs the agent to call get_decision before proposing. For one hit, Call get_decision(N) before proposing. For many, Found N related decisions. ... Call get_decision on each related decision before proposing.
  • use_embeddings is fail-open (union_retrieve): when True, the BM25 top-k is returned first unchanged and any embedding-only hits are appended with similarity=None (surfaced as score 0.0); if the optional embedding dependency is absent the result is BM25-only. Embeddings are off by default.
check_decision.py
approach_head = proposed_approach[:100]
body_text = proposed_approach + (f" {context}" if context else "")
query_text = f"{approach_head}. {body_text[:200]}"
hits = union_retrieve(
    decisions, query_text, top_k=5,
    stopwords=_CHECK_DECISION_STOPWORDS,  # STOPWORDS_EN + "use"
    use_embeddings=use_embeddings,
)
if not hits:
    return CheckDecisionResult(assessment="No related decisions found.")
return CheckDecisionResult(
    related_decisions=[_hit_to_related(h, by_num) for h in hits],
    assessment=_assessment(related),  # deterministic; does not judge
)
search.py
active = [d for d in decisions if d.status is DecisionStatus.active]
corpus = [f"{d.title} {d.rationale}" for d in active]
corpus_tokens = bm25s.tokenize(corpus, stopwords=stopwords, stemmer=_stemmer, show_progress=False)
retriever = bm25s.BM25()
retriever.index(corpus_tokens, show_progress=False)
# ... results with score <= 0 are excluded
check_decision.py
top = related[0]
top_num = extract_decision_number(top.id)
top_label = f"D{top_num:03d}" if top_num is not None else top.id
top_line = (
    f'Top match: {top_label} "{top.title}"'
    f" (status {top.status}, decided {top.date}, BM25 {top.score:.1f})."
)
if len(related) == 1:
    target = f"get_decision({top_num})" if top_num is not None else "get_decision"
    return f"{top_line} Call {target} before proposing."
return (
    f"Found {len(related)} related decisions. {top_line}"
    " Call get_decision on each related decision before proposing."
)

Source: packages/nauro-core/src/nauro_core/operations/check_decision.py (check_decision, _hit_to_related, _assessment, _CHECK_DECISION_STOPWORDS, _SCAFFOLD_SEED_TITLE); packages/nauro-core/src/nauro_core/search.py (bm25_retrieve, union_retrieve, bm25_search); packages/nauro-core/src/nauro_core/operations/results.py (CheckDecisionResult, RelatedDecision); packages/nauro-core/src/nauro_core/constants.py (MAX_APPROACH_LENGTH, MAX_CONTEXT_LENGTH, NO_DECISIONS_TO_CHECK).

Two tiers of similarity (where BM25 is used)

  • Tier 1 is structural screening (screen_structural in validation.py): it rejects empty title/rationale, invalid confidence, rationale shorter than MIN_RATIONALE_LENGTH (20 chars), exact-hash duplicates (SHA-256 of the normalized title|rationale via compute_hash), and same-title duplicates of an existing active decision of any age (the caller filters the corpus to active decisions, excluding a supersede's own target so a same-title supersede is not self-rejected).
  • Tier 2 is the BM25 similarity check (check_bm25_similarity), the same bm25_retrieve over active decisions used by check_decision. It returns ("auto_confirm", []) or ("needs_review", related).
  • Crucially, in propose_decision Tier 2 is advisory only. Its hits surface as similar_decisions on the response and do NOT gate the write. The human approval gate is enforced at the chat-session layer before the agent fires propose_decision, not inside the kernel.
  • Both check_decision and Tier 2 share the STOPWORDS_EN + "use" extension, so the same proposal produces the same near-neighbour set on either surface.
propose_decision.py
# --- Tier 2: BM25 similarity (advisory only — does not gate the write) ---
parsed_decisions = _parse_all_decisions(store)
_t2_action, similar_raw = check_bm25_similarity(proposal, parsed_decisions)
similar_models = _to_related_decisions(similar_raw, parsed_decisions)
# ... commit proceeds regardless of similarity hits

Source: packages/nauro-core/src/nauro_core/validation.py (screen_structural, check_bm25_similarity, compute_hash, TIER2_STOPWORDS, TIER2_TOP_K); packages/nauro-core/src/nauro_core/operations/propose_decision.py (the propose_decision Tier 1 / Tier 2 flow); packages/nauro-core/src/nauro_core/constants.py (MIN_RATIONALE_LENGTH, VALID_CONFIDENCES).

Privacy posture

  • What syncs: project context (decisions, state, open questions), NOT source code, stored encrypted in AWS S3 (us-east-1, SSE-S3), separated per user by an S3 key prefix derived from their authentication identity. No self-service deletion command exists; deletion is via support request.
  • Remote MCP: when connected to an MCP client (Claude AI, Perplexity, ChatGPT, and others), context is read from S3 and delivered to the AI tool; that tool's own data policies then apply. Nauro does not control or monitor what the tool does with the context after delivery.
  • Telemetry is anonymous and default opt-in via a one-line first-run prompt. Only four events fire (cli.command_invoked, mcp.tool_called, sync.completed, project.created) with a fixed small property set and an anonymous per-machine UUID. No content or identifiers beyond that UUID.
  • Explicitly never sent in telemetry: decision titles, decision rationale, decision content, file paths, repo names, project names, MCP tool arguments, MCP tool return values, stack traces, command-line arguments, IP address, geolocation (country/region/city).
  • Opt-out: NAURO_TELEMETRY=0 (suppresses telemetry and the first-run prompt) or nauro telemetry disable (persists to ~/.nauro/config.json). Product analytics go to PostHog (cloud); operational Lambda metrics go to AWS CloudWatch and never include user content.
telemetry events
cli.command_invoked   { command, success, duration_bucket, nauro_version, os }
mcp.tool_called       { tool_name, transport, success, duration_bucket }
sync.completed        { snapshot_count, duration_bucket, bytes_bucket }
project.created       { schema_version }

Source: packages/nauro/PRIVACY.md.

Key facts

  • Required frontmatter: date (ISO) and confidence (high | medium | low). Defaulted: version (int >= 1, default 1), status (active | superseded, default active).
  • Optional frontmatter: decision_type, reversibility (easy | moderate | hard), source (mcp | commit | compaction | manual | import), files_affected (list), supersedes, superseded_by.
  • DecisionType enum (decision_model.py, authoritative for parsed files): architecture, api_design, infrastructure, pattern, refactor, data_model.
  • DecisionSource value "import" serializes as that string though its Python member is import_.
  • Derived (not in frontmatter): num, title, rationale, body, content. Body-rendered (not in frontmatter): rejected.
  • Frontmatter key order is fixed: date, version, status, confidence, decision_type, reversibility, source, files_affected, supersedes, superseded_by.
  • H1 must be # NNN — Title with an em-dash; the required ## Decision section becomes rationale; ## Rejected Alternatives uses ### name subsections.
  • Active decisions must give a reason for every rejected alternative; superseded decisions must carry a superseded_by ref. Both are enforced by model validators.
  • Supersession refs are plain integer strings ("70"): no leading zeros, no slug, no D prefix.
  • Supersede = two writes: the new decision gets supersedes=str(old_num); the old decision flips to status=superseded with superseded_by=str(new_num). Best-effort, sequential, half-state recoverable by sync-repair.
  • operation=update is rationale-only (bumps version, appends a dated paragraph); changing title / confidence / type / and so on requires a supersede.
  • check_decision indexes title+rationale of ACTIVE decisions only, BM25 (bm25s + PyStemmer), top_k=5; it returns related decisions plus a deterministic assessment and does NOT auto-judge or auto-supersede.
  • check_decision query = approach[:100] + ". " + (approach+context)[:200]; stopwords = bm25s STOPWORDS_EN + "use". MAX_APPROACH_LENGTH = MAX_CONTEXT_LENGTH = 5000 are over-length REJECTIONS, not truncation.
  • Tier 1 thresholds: MIN_RATIONALE_LENGTH = 20; VALID_CONFIDENCES = {high, medium, low}; exact-hash dedup is SHA-256 of normalized title|rationale; the title dedup keys on active status (a title matching any active decision is rejected regardless of age; no time window).
  • Tier 2 BM25 hits in propose_decision are advisory only and do NOT block the write; the human approval gate lives at the chat-session layer.
  • The scaffold seed (num==1, title "Initial project setup") is excluded from check_decision and Tier 2 retrieval.
  • Privacy: context (not source code) stored encrypted in S3 us-east-1 SSE-S3 under a per-user prefix; telemetry anonymous, default opt-in, four events; NAURO_TELEMETRY=0 or nauro telemetry disable opts out; PostHog for analytics, CloudWatch for ops metrics.
  • Two divergent type lists: the DecisionType enum (decision_model.py, 6 values) is authoritative for parsed/validated decisions; the constants.py DECISION_TYPES tuple includes library_choice (not in the enum) and orders differently. Document the enum, not the tuple.