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 PydanticDecisionmodel innauro_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 asuperseded_byref all raise. parse_decision(text, filename)reads the file and returns a validatedDecision;format_decision(decision)goes the other way. The round-trip is idempotent: format, parse, format is byte-identical.- The frontmatter requires a leading
---\nfence; the body must contain an H1 of the form# NNN — Title(with an em-dash separator) and a## Decisionsection (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## Decisionbody),body(the full markdown body), andcontent(the full file text including frontmatter).
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) andconfidence(enum: high, medium, low). - Defaulted frontmatter:
version(int >= 1, default 1) andstatus(enum: active, superseded, default active). - Optional frontmatter:
decision_type,reversibility,source,files_affected(list of strings, default empty),supersedes,superseded_by. DecisionTypeenum values (decision_model.py): architecture, api_design, infrastructure, pattern, refactor, data_model. Note: the separateDECISION_TYPEStuple inconstants.pylistslibrary_choiceand orders differently (architecture, library_choice, pattern, refactor, api_design, infrastructure, data_model). The authoritative validated enum for parsed decisions is thedecision_model.pyDecisionType.Reversibilityenum values: easy, moderate, hard.DecisionSourceenum values: mcp, commit, compaction, manual, and import (the Python member is namedimport_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.
rejectedis NOT in frontmatter. It is popped from the dump dict and rendered into the markdown body as## Rejected Alternativeswith one### namesubsection per alternative.
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_decisionfor the scaffolded first decision (the seed Nauro writes when a store is created vianauro init). It shows the canonical shape: frontmatter fence,# NNN — TitleH1 (zero-padded to 3 digits with an em-dash),## Decisionrationale, and## Rejected Alternativeswith### namesubsections and reasons. - On active decisions, every rejected alternative must carry a reason. A reasonless rejection raises (the
require_reasons_on_activevalidator). The scaffold seed hasconfidence: highandstatus: active, withversionandstatusemitted 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.
---
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:
---
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 viaextract_decision_number, not the H1. - The
## Decisionsection body becomesrationale. It is required; a file with## Rationaleinstead of## Decisionraises (the parser stays strict; legacy renames happen in a separate migration script). ## Rejected Alternativesis optional. Its body is split on### namesubsections (_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 stem042-some-title(or with.md), synthetic iddecision-042, prefixedD042/D42(the char after thed/Dmust be a digit), and bare integer42. It returns the leading integer or None.
_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 alternativesSource: 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
statushas 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 isstr(int(v)). - A decision with
status=supersededMUST carry asuperseded_byref pointing at the replacing decision (thesuperseded_requires_refvalidator), otherwise parsing raises. - Superseding is a two-write operation (
propose_decisionoperation="supersede",_do_supersede): (1) write the new decision, read it back, then rewrite it to carrysupersedes = str(old_num); (2) flip the old decision tostatus=supersededwithsuperseded_by = str(new_num). The writes are sequential and best-effort. If the second write fails, the new decision stands and a structured half-stateErrorPayloadis returned so sync-repair reconciles on the next pull. - operation="update" is rationale-only (
_do_update): it bumpsversionand 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 toaddwhen 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).
@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# 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 callget_decisionon each hit and reason itself.- Retrieval is BM25 over the in-store decision corpus, indexing
title + rationaleper decision (built in-memory per call with bm25s and PyStemmer). Only active decisions are considered (bm25_retrievefilters out anything whose status is not active and excludesscore <= 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]}".contextis concatenated intobody_text. - Before retrieval,
proposed_approachandcontextare each validated againstMAX_APPROACH_LENGTH/MAX_CONTEXT_LENGTH(both 5000) viacheck_content_length, which REJECTS over-length input (returns anErrorPayload); it does not truncate. This rejection is separate from the 100/200 query-shaping truncation. check_decisionuses 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 == 1and 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_CHECKonboarding text; no BM25 hits returns"No related decisions found."Otherwise it returns aCheckDecisionResultwithrelated_decisions(a list ofRelatedDecision: id, title, score, status, date, rationale_preview) and a single-lineassessment. - 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_decisionbefore 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_embeddingsis fail-open (union_retrieve): when True, the BM25 top-k is returned first unchanged and any embedding-only hits are appended withsimilarity=None(surfaced as score 0.0); if the optional embedding dependency is absent the result is BM25-only. Embeddings are off by default.
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
)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 excludedtop = 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_structuralinvalidation.py): it rejects empty title/rationale, invalid confidence, rationale shorter thanMIN_RATIONALE_LENGTH(20 chars), exact-hash duplicates (SHA-256 of the normalizedtitle|rationaleviacompute_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 samebm25_retrieveover active decisions used bycheck_decision. It returns("auto_confirm", [])or("needs_review", related). - Crucially, in
propose_decisionTier 2 is advisory only. Its hits surface assimilar_decisionson the response and do NOT gate the write. The human approval gate is enforced at the chat-session layer before the agent firespropose_decision, not inside the kernel. - Both
check_decisionand Tier 2 share theSTOPWORDS_EN+"use"extension, so the same proposal produces the same near-neighbour set on either surface.
# --- 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 hitsSource: 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) ornauro 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.
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 — Titlewith an em-dash; the required## Decisionsection becomes rationale;## Rejected Alternativesuses### namesubsections. - 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_decisionindexes 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_decisionquery = 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_decisionare 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_decisionand 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=0ornauro telemetry disableopts 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; theconstants.pyDECISION_TYPES tuple includes library_choice (not in the enum) and orders differently. Document the enum, not the tuple.