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 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. There is no automatic rollback or repair; recovery is manual. - 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. A mistaken 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). On the local adapter,decision_write_lockspans that allocation and the kernel write. The cloud store has no compare-and-swap primitive or equivalent cross-request lock.
@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).
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 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. 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 assimilar_decisionson the response and do NOT gate the write. The chat-session layer must obtain explicit human approval for every add, update, or supersede before firingpropose_decision. After that call, the kernel writes on structural success without a second confirmation step. - 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), 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, andresetare 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 — 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 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_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 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_decisionand 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; theconstants.pyDECISION_TYPES tuple includes library_choice (not in the enum) and orders differently. Document the enum, not the tuple.