Skip to content
Nauro

Concepts

Core concepts

A decision is Nauro's concrete unit of project judgment: the human-ratified part of the project record, alongside current state and open questions. Each decision is a markdown file with YAML frontmatter plus a structured body. This page covers the on-disk model, how superseding works, and how check_decision returns related history over BM25 retrieval. The check is advisory: it does not judge conflicts or auto-supersede. Every add, update, or supersede requires explicit approval in conversation before the write tool is called.

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. There is no automatic rollback or repair; recovery is manual.
  • 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. A mistaken 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). On the local adapter, decision_write_lock spans that allocation and the kernel write. The cloud store has no compare-and-swap primitive or equivalent cross-request lock.
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).

Seeing the lifecycle as drawn structure

The on-disk lifecycle metadata also renders as visible structure. nauro graph renders the whole store to a self-contained HTML file in which each supersession thread is drawn as its own lineage and a consolidation of several decisions into one reads as a fan-in. Active decisions render at full strength and superseded ones are dimmed, so you can see what replaced what. See nauro graph in the CLI reference for the views and flags.

The check_decision retrieval step

  • 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. Its internal action label is ("auto_confirm", []) or ("needs_review", related); those labels do not represent human approval.
  • In propose_decision, Tier 2 is advisory only. Its hits surface as similar_decisions on the response and do NOT gate the write. The chat-session layer must obtain explicit human approval for every add, update, or supersede before firing propose_decision. After that call, the kernel writes on structural success without a second confirmation step.
  • 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), isolated per project behind a fail-closed membership check: a request can read or write a project only if your account is recorded as a member of it. No self-service deletion command exists; deletion is via support request.
  • Remote MCP: when connected to an MCP client (Claude AI, Perplexity, 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.
  • Nauro sends no product analytics. Neither the CLI nor the hosted service emits product-usage events.
  • During 1.x, nauro telemetry status, enable, disable, and reset are deprecated no-network, no-mutation compatibility shims. They report retirement and disappear in 2.0. Legacy telemetry config is ignored and left untouched.
  • Operational Lambda metrics and logs remain in AWS CloudWatch and never include project content. Hosted authorization, usage counters, and rate limiting also remain separate from product analytics.

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 and sequential; a half-state requires manual recovery.
  • 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 chat-session layer requires explicit approval for every add, update, or supersede before the call.
  • The scaffold seed (num==1, title "Initial project setup") is excluded from check_decision and Tier 2 retrieval.
  • Privacy: context, not source code, is stored encrypted in S3 us-east-1 SSE-S3 and isolated per project behind a fail-closed membership check. Nauro sends no product analytics; CloudWatch remains for operational metrics and logs.
  • 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.