brainiac-cli 0.16.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. brain/__init__.py +39 -0
  2. brain/__main__.py +14 -0
  3. brain/_assets/AGENTS.md +564 -0
  4. brain/_assets/overlay/template/brand/brand-guide.md +24 -0
  5. brain/_assets/overlay/template/keywords/glossary.md +15 -0
  6. brain/_assets/overlay/template/people/roster.md +14 -0
  7. brain/_assets/overlay/template/voice/voice-profile.md +34 -0
  8. brain/_assets/routines/manifest.json +257 -0
  9. brain/_assets/scripts/brain-brief-mac.plist +59 -0
  10. brain/_assets/scripts/brain-brief.sh +74 -0
  11. brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
  12. brain/_assets/scripts/brain-synthesis.sh +214 -0
  13. brain/_assets/scripts/install-brief-mac.sh +152 -0
  14. brain/_assets/scripts/install-brief-windows.ps1 +97 -0
  15. brain/_assets/scripts/register_tasks.py +386 -0
  16. brain/_assets/templates/company.md +21 -0
  17. brain/_assets/templates/concept.md +27 -0
  18. brain/_assets/templates/daily.md +20 -0
  19. brain/_assets/templates/decision.md +42 -0
  20. brain/_assets/templates/meeting.md +33 -0
  21. brain/_assets/templates/person.md +20 -0
  22. brain/_assets/templates/project.md +23 -0
  23. brain/_assets/templates/state-moc.md +40 -0
  24. brain/_version.py +12 -0
  25. brain/anchor.py +121 -0
  26. brain/audit.py +422 -0
  27. brain/backup.py +210 -0
  28. brain/brief.py +417 -0
  29. brain/capture.py +117 -0
  30. brain/chunk.py +249 -0
  31. brain/classification.py +134 -0
  32. brain/cli.py +1906 -0
  33. brain/config.py +368 -0
  34. brain/connect.py +362 -0
  35. brain/context.py +108 -0
  36. brain/core.py +3018 -0
  37. brain/doctor.py +1161 -0
  38. brain/egress.py +148 -0
  39. brain/embed.py +857 -0
  40. brain/encryption.py +217 -0
  41. brain/frontmatter.py +102 -0
  42. brain/golden_probe.py +678 -0
  43. brain/graph.py +369 -0
  44. brain/graphify.py +352 -0
  45. brain/index.py +1576 -0
  46. brain/ingest/__init__.py +19 -0
  47. brain/ingest/handlers/__init__.py +43 -0
  48. brain/ingest/handlers/base.py +95 -0
  49. brain/ingest/handlers/docx.py +78 -0
  50. brain/ingest/handlers/email.py +228 -0
  51. brain/ingest/handlers/html.py +142 -0
  52. brain/ingest/handlers/image.py +91 -0
  53. brain/ingest/handlers/pdf.py +99 -0
  54. brain/ingest/handlers/pptx.py +69 -0
  55. brain/ingest/handlers/tables.py +41 -0
  56. brain/ingest/handlers/text.py +43 -0
  57. brain/ingest/handlers/xlsx.py +100 -0
  58. brain/ingest/handlers/zip.py +163 -0
  59. brain/ingest/pipeline.py +839 -0
  60. brain/ingest/transcript.py +158 -0
  61. brain/init.py +870 -0
  62. brain/maintenance.py +2266 -0
  63. brain/mcp_adapter.py +217 -0
  64. brain/multihop.py +232 -0
  65. brain/notes.py +195 -0
  66. brain/overlay.py +183 -0
  67. brain/projection.py +79 -0
  68. brain/rerank.py +425 -0
  69. brain/snapshot.py +231 -0
  70. brain/update.py +743 -0
  71. brain/vectors.py +225 -0
  72. brainiac_cli-0.16.0.dist-info/METADATA +306 -0
  73. brainiac_cli-0.16.0.dist-info/RECORD +77 -0
  74. brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
  75. brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
  76. brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
  77. brainiac_cli-0.16.0.dist-info/top_level.txt +1 -0
brain/capture.py ADDED
@@ -0,0 +1,117 @@
1
+ """Capture-path frontmatter enforcement (UX-01).
2
+
3
+ Both host-native clients (Codex/Claude Code/Gemini CLI) and sandboxed VM clients
4
+ (Cowork) route through enforce() to guarantee frontmatter before any write.
5
+ The host signs and indexes; the VM drops to capture-inbox/ unsigned and unindexed.
6
+
7
+ host: enforce() → write_note() → incremental sync → snapshot
8
+ VM: enforce() → draft_capture() (capture-inbox/; unsigned, unindexed)
9
+ host drain-on-invoke picks it up on the next brain run
10
+
11
+ No signing key is ever read or resolved here.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import datetime
16
+ import hashlib
17
+ from typing import Any
18
+
19
+ from . import frontmatter as fm
20
+
21
+ REQUIRED_KEYS: tuple[str, ...] = ("id", "type", "classification", "created")
22
+ _CAPTURE_CLASSIFICATION_DEFAULT = "Internal"
23
+ _CAPTURE_TYPE_DEFAULT = "note"
24
+
25
+
26
+ def _today() -> str:
27
+ return datetime.date.today().isoformat()
28
+
29
+
30
+ def _derive_id(body: str) -> str:
31
+ h = hashlib.sha256(body.encode("utf-8")).hexdigest()[:12]
32
+ return f"capture-{h}"
33
+
34
+
35
+ def enforce(content: str, *, override: dict[str, Any] | None = None) -> str:
36
+ """Return content with all required capture frontmatter guaranteed.
37
+
38
+ Rules:
39
+ - Existing keys are NEVER overwritten (additive only).
40
+ - ``override`` keys take precedence over both existing and defaults.
41
+ - Missing ``classification`` defaults to ``Internal`` so a captured note
42
+ is usable by default without requiring --max-tier elevation.
43
+ - Always sets ``status: draft`` and ``provenance.trust: untrusted`` so the
44
+ host drain treats it as untrusted input during ingest validation.
45
+ - Preserves all other existing frontmatter keys.
46
+
47
+ The result is STILL UNTRUSTED from the host's perspective until drain-on-invoke
48
+ signs it — the host promote step validates, signs, indexes, and updates status.
49
+ """
50
+ override = override or {}
51
+ meta, body = fm.parse_text(content)
52
+
53
+ nid = override.get("id") or meta.get("id") or _derive_id(body)
54
+ ntype = override.get("type") or meta.get("type") or _CAPTURE_TYPE_DEFAULT
55
+ ncls = (
56
+ override.get("classification")
57
+ or meta.get("classification")
58
+ or _CAPTURE_CLASSIFICATION_DEFAULT
59
+ )
60
+ ncreated = override.get("created") or meta.get("created") or _today()
61
+ nupdated = override.get("updated") or meta.get("updated") or _today()
62
+ ntitle = override.get("title") or meta.get("title") or str(nid)
63
+
64
+ block: dict[str, Any] = {
65
+ "id": nid,
66
+ "title": ntitle,
67
+ "type": ntype,
68
+ "classification": ncls,
69
+ "created": ncreated,
70
+ "updated": nupdated,
71
+ "status": "draft",
72
+ "provenance.trust": "untrusted",
73
+ }
74
+ # Preserve any other keys from the original frontmatter (non-clobbering).
75
+ for k, v in meta.items():
76
+ if k not in block:
77
+ block[k] = v
78
+
79
+ lines = ["---"]
80
+ for k, v in block.items():
81
+ sv = str(v)
82
+ # Quote values containing YAML-special characters.
83
+ if any(c in sv for c in (":", "#", "[", "]", "{", "}", ",")):
84
+ sv = f'"{sv}"'
85
+ lines.append(f"{k}: {sv}")
86
+ lines.append("---")
87
+ lines.append("")
88
+ lines.append(body.lstrip("\n"))
89
+
90
+ return "\n".join(lines)
91
+
92
+
93
+ def validate(content: str) -> list[str]:
94
+ """Return a list of validation errors (empty list = valid).
95
+
96
+ Called by the host drain before signing to validate untrusted capture
97
+ content. Checks that required keys are present and classification is a
98
+ known tier.
99
+ """
100
+ from .classification import TIERS
101
+
102
+ meta, _body = fm.parse_text(content)
103
+ errors: list[str] = []
104
+
105
+ if not meta:
106
+ errors.append("no frontmatter")
107
+ return errors
108
+
109
+ for key in REQUIRED_KEYS:
110
+ if key not in meta:
111
+ errors.append(f"missing required key: {key}")
112
+
113
+ cls_val = str(meta.get("classification", ""))
114
+ if cls_val and cls_val not in TIERS:
115
+ errors.append(f"unknown classification: {cls_val!r} (valid: {list(TIERS)})")
116
+
117
+ return errors
brain/chunk.py ADDED
@@ -0,0 +1,249 @@
1
+ """Block/section chunking + Anthropic-style in-language contextual prefix (IDX-02).
2
+
3
+ Two ideas, both lifting retrieval recall:
4
+
5
+ 1. **Chunk, don't embed whole notes.** A 4 KB note embedded as one vector blurs
6
+ every section together; a query that matches one paragraph competes against
7
+ the whole-note average. We split the body at Markdown section/block level
8
+ (headings first, then paragraph blocks, with a soft size budget) so each
9
+ chunk is a coherent unit.
10
+
11
+ 2. **Contextual prefix (Anthropic "Contextual Retrieval").** Each chunk is
12
+ prepended — BEFORE embedding — with a short blurb situating it inside its
13
+ note ("From note 'X' …; section: Y"). This restores the context a bare chunk
14
+ loses and sharply cuts misses. The blurb is written **in the note's own
15
+ language** (a Portuguese note gets a Portuguese blurb) so it does not pollute
16
+ the chunk's language; the cross-lingual bridge happens at *query* time in
17
+ Arctic-embed's shared multilingual vector space.
18
+
19
+ The canonical task prefix (``query:`` / ``passage:``) is a model control token
20
+ and is applied by the embedder (``brain.embed``), OUTSIDE this contextual prefix
21
+ — and is never translated. The contextual prefix here is content, not a token.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import re
27
+ from dataclasses import dataclass
28
+
29
+ # Soft target / hard ceiling for a chunk, in characters. Blocks are merged up to
30
+ # the target and a single oversized block is split at the ceiling.
31
+ TARGET_CHARS = 900
32
+ MAX_CHARS = 1400
33
+
34
+ # Dual-granularity threshold (RET-03) — DISABLED BY DEFAULT (= 0) after the S10
35
+ # A/B falsified it for this corpus + model. The hypothesis: a short curated note
36
+ # split per-section scatters its cross-lingual signal across competing vectors, so
37
+ # indexing it as ONE whole-note chunk should restore the edge Smart Connections
38
+ # (whole-note, same e5-small) has on monolingual PT. EMPIRICAL RESULT (full e5-small
39
+ # re-index, 2026-06-28): it did NOT help and slightly REGRESSED — monolingual_pt
40
+ # 0.653 → 0.611, overall 0.573 → 0.553 vs the chunk index at the same zone weight.
41
+ # Mashing a note's sections into one 450-token vector DILUTES the specific matching
42
+ # section rather than concentrating it (the opposite of the literature's claim for
43
+ # small models), and the corpus had few multi-section short notes anyway (only ~824
44
+ # of 83k chunks merged). Kept as an env-gated capability for other corpora, but the
45
+ # DEFAULT IS 0 (pure section chunking) because it is the better config here.
46
+ # Set BRAIN_WHOLENOTE_MAX_CHARS=<chars> to re-enable. Evidence:
47
+ # docs/operations/s10-agentic-retrieval-analysis.md.
48
+ WHOLE_NOTE_MAX_CHARS = 0
49
+
50
+
51
+ def _whole_note_max() -> int:
52
+ try:
53
+ return int(os.environ.get("BRAIN_WHOLENOTE_MAX_CHARS", "") or WHOLE_NOTE_MAX_CHARS)
54
+ except ValueError:
55
+ return WHOLE_NOTE_MAX_CHARS
56
+
57
+ _HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
58
+
59
+
60
+ @dataclass
61
+ class Chunk:
62
+ ordinal: int
63
+ heading: str # nearest enclosing heading text ("" if none / preamble)
64
+ text: str # the raw chunk body (no prefixes)
65
+ lang: str # detected language of the chunk: "pt" | "es" | "en"
66
+
67
+ def embed_input(self, title: str, zone: str, doc_context: str = "") -> str:
68
+ """The exact string handed to the embedder for this chunk: the
69
+ in-language contextual prefix + a blank line + the chunk text.
70
+
71
+ ``doc_context`` (UPG-04, optional) is an LLM-generated ≤1-sentence
72
+ document-level summary that situates this chunk within its note. When
73
+ non-empty it is prepended (after the template prefix, before the chunk
74
+ text) so the embedding sees the note's overall meaning alongside the
75
+ chunk's specific content. Empty string = the S10 template-only path.
76
+ """
77
+ prefix = contextual_prefix(title, zone, self.heading, self.lang)
78
+ if doc_context:
79
+ prefix = prefix + " " + doc_context
80
+ return prefix + "\n\n" + self.text
81
+
82
+
83
+ # --- language detection (lightweight, dependency-free) ---------------------
84
+ # Stopword sets chosen to be discriminative between EN / PT / ES. Not a full
85
+ # language ID — good enough to pick the contextual-prefix language.
86
+ _PT = {
87
+ "de", "que", "não", "uma", "para", "com", "como", "mais", "está", "são",
88
+ "também", "já", "nós", "sobre", "será", "foi", "ção", "às", "então", "porque",
89
+ }
90
+ _ES = {
91
+ "de", "que", "no", "una", "para", "con", "como", "más", "está", "son",
92
+ "también", "ya", "nosotros", "sobre", "será", "fue", "ción", "pero", "porque",
93
+ "el", "los", "las", "una",
94
+ }
95
+ _EN = {
96
+ "the", "and", "of", "to", "in", "is", "are", "for", "with", "that", "this",
97
+ "as", "be", "on", "by", "an", "we", "it", "from", "will",
98
+ }
99
+ _WORD = re.compile(r"[A-Za-zÀ-ÿ]+")
100
+
101
+
102
+ def detect_language(text: str) -> str:
103
+ """Return 'pt', 'es', or 'en' by discriminative-stopword frequency.
104
+
105
+ Defaults to 'en' on a tie or when there is too little signal. Accent marks
106
+ (ç, ã, õ, á, é) strongly bias toward PT/ES.
107
+ """
108
+ words = [w.lower() for w in _WORD.findall(text)]
109
+ if not words:
110
+ return "en"
111
+ scores = {
112
+ "pt": sum(1 for w in words if w in _PT),
113
+ "es": sum(1 for w in words if w in _ES),
114
+ "en": sum(1 for w in words if w in _EN),
115
+ }
116
+ # Portuguese-specific orthography (ã, õ, ç) is a strong PT signal.
117
+ if re.search(r"[ãõç]", text):
118
+ scores["pt"] += 3
119
+ # Spanish-specific (ñ, ¿, ¡)
120
+ if re.search(r"[ñ¿¡]", text):
121
+ scores["es"] += 3
122
+ best = max(scores, key=lambda k: scores[k])
123
+ # Require the winner to actually beat English by a margin, else default EN
124
+ # (avoids flipping an English chunk to PT on a couple of shared tokens).
125
+ if best != "en" and scores[best] <= scores["en"]:
126
+ return "en"
127
+ return best
128
+
129
+
130
+ # --- in-language contextual prefix -----------------------------------------
131
+ # Per-language templates. {title}=note title, {zone}=para zone, {heading}=section.
132
+ _TEMPLATES = {
133
+ "en": "Context: from the note '{title}' (zone: {zone}).{section}",
134
+ "pt": "Contexto: da nota '{title}' (zona: {zone}).{section}",
135
+ "es": "Contexto: de la nota '{title}' (zona: {zone}).{section}",
136
+ }
137
+ _SECTION = {
138
+ "en": " Section: {heading}.",
139
+ "pt": " Secção: {heading}.",
140
+ "es": " Sección: {heading}.",
141
+ }
142
+
143
+
144
+ def contextual_prefix(title: str, zone: str, heading: str, lang: str) -> str:
145
+ lang = lang if lang in _TEMPLATES else "en"
146
+ section = _SECTION[lang].format(heading=heading) if heading else ""
147
+ return _TEMPLATES[lang].format(title=title or "(untitled)", zone=zone or "brain", section=section)
148
+
149
+
150
+ # --- the chunker ------------------------------------------------------------
151
+ def _split_blocks(body: str) -> list[tuple[str, str]]:
152
+ """Walk the body line by line, tracking the nearest heading, and group
153
+ contiguous non-heading lines into blocks. Returns [(heading, block_text)]."""
154
+ blocks: list[tuple[str, str]] = []
155
+ cur_heading = ""
156
+ buf: list[str] = []
157
+
158
+ def flush() -> None:
159
+ text = "\n".join(buf).strip()
160
+ if text:
161
+ blocks.append((cur_heading, text))
162
+ buf.clear()
163
+
164
+ for line in body.splitlines():
165
+ m = _HEADING.match(line)
166
+ if m:
167
+ flush()
168
+ cur_heading = m.group(2).strip()
169
+ elif line.strip() == "" and buf:
170
+ # paragraph boundary inside a section
171
+ flush()
172
+ else:
173
+ buf.append(line)
174
+ flush()
175
+ return blocks
176
+
177
+
178
+ def _pack(heading: str, text: str, ordinal_start: int, lang_of) -> list[Chunk]:
179
+ """Split one oversized block at the char ceiling into sub-chunks."""
180
+ out: list[Chunk] = []
181
+ o = ordinal_start
182
+ if len(text) <= MAX_CHARS:
183
+ out.append(Chunk(o, heading, text, lang_of(text)))
184
+ return out
185
+ # greedy word-wrap into <= MAX_CHARS slices
186
+ words = text.split()
187
+ cur: list[str] = []
188
+ size = 0
189
+ for w in words:
190
+ if size + len(w) + 1 > MAX_CHARS and cur:
191
+ piece = " ".join(cur)
192
+ out.append(Chunk(o, heading, piece, lang_of(piece)))
193
+ o += 1
194
+ cur, size = [], 0
195
+ cur.append(w)
196
+ size += len(w) + 1
197
+ if cur:
198
+ piece = " ".join(cur)
199
+ out.append(Chunk(o, heading, piece, lang_of(piece)))
200
+ return out
201
+
202
+
203
+ def chunk_text(body: str, *, lang_of=detect_language) -> list[Chunk]:
204
+ """Chunk a note body at section/block level with a soft size budget.
205
+
206
+ Strategy: split into heading-scoped blocks, then greedily merge adjacent
207
+ blocks under the same heading up to TARGET_CHARS, splitting any single block
208
+ over MAX_CHARS. Each chunk's language is detected on its own text.
209
+
210
+ Dual-granularity (RET-03): a short note (whole body ≤ ``_whole_note_max()``)
211
+ is returned as ONE whole-note chunk — its sections are NOT split — so its
212
+ cross-lingual signal stays concentrated in a single vector. See
213
+ ``WHOLE_NOTE_MAX_CHARS``.
214
+ """
215
+ whole_max = _whole_note_max()
216
+ stripped = body.strip()
217
+ if whole_max > 0 and stripped and len(stripped) <= whole_max:
218
+ return [Chunk(0, "", stripped, lang_of(stripped))]
219
+ blocks = _split_blocks(body)
220
+ if not blocks:
221
+ return []
222
+ chunks: list[Chunk] = []
223
+ ordinal = 0
224
+ pending_heading = blocks[0][0]
225
+ pending_text = ""
226
+
227
+ def emit(heading: str, text: str) -> None:
228
+ nonlocal ordinal
229
+ for ch in _pack(heading, text, ordinal, lang_of):
230
+ ch.ordinal = ordinal
231
+ chunks.append(ch)
232
+ ordinal += 1
233
+
234
+ for heading, text in blocks:
235
+ if heading != pending_heading:
236
+ if pending_text:
237
+ emit(pending_heading, pending_text)
238
+ pending_heading, pending_text = heading, text
239
+ continue
240
+ if not pending_text:
241
+ pending_text = text
242
+ elif len(pending_text) + len(text) + 2 <= TARGET_CHARS:
243
+ pending_text = pending_text + "\n\n" + text
244
+ else:
245
+ emit(pending_heading, pending_text)
246
+ pending_text = text
247
+ if pending_text:
248
+ emit(pending_heading, pending_text)
249
+ return chunks
@@ -0,0 +1,134 @@
1
+ """Classification tiers + the deny-by-default egress filter (CORE-02).
2
+
3
+ This is the *egress-decision mechanism*, NOT containment. It only decides what
4
+ the cooperative `brain` CLI is willing to print to stdout. Any file-capable
5
+ harness can read the Markdown directly and bypass it entirely — that is why real
6
+ containment of sensitive tiers is **workspace projection** (see
7
+ ``brain.projection``) plus the host/VM trust split, not this filter. The
8
+ consensus-hardening tests (tests/test_direct_file_read.py) prove this distinction.
9
+
10
+ Tiers, low -> high sensitivity:
11
+ Public < Internal < Confidential < Restricted < MNPI
12
+
13
+ Default-deny (load-bearing): a note whose ``classification`` is missing, empty,
14
+ or unrecognised is treated as MNPI (rank 4, most restrictive) at every surfacing
15
+ boundary — fail-closed, never fail-open.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from dataclasses import dataclass
21
+ from typing import Iterable, Sequence
22
+
23
+ TIERS: tuple[str, ...] = ("Public", "Internal", "Confidential", "Restricted", "MNPI")
24
+ RANK: dict[str, int] = {t: i for i, t in enumerate(TIERS)}
25
+
26
+ # The tier an unlabelled / unrecognised note is treated as (most restrictive).
27
+ DEFAULT_DENY_TIER = "MNPI"
28
+ DEFAULT_DENY_RANK = RANK[DEFAULT_DENY_TIER]
29
+
30
+ # Default egress cap on the TRUSTED HOST: the full vault (owner decision,
31
+ # 2026-07-10 — the old Internal default starved every real query: a curated
32
+ # vault keeps its load-bearing notes at Confidential/Restricted, so the host
33
+ # surface answered from stale low-tier scraps while competitors read
34
+ # everything). $BRAIN_DEFAULT_MAX_TIER narrows it back (e.g. "Internal") for
35
+ # deployments that want the conservative gate; an unrecognised value falls
36
+ # back to the full-vault default. The untrusted VM leg does NOT inherit this:
37
+ # role=vm resolves to VM_DEFAULT_MAX_TIER below (the trifecta break lives at
38
+ # the role boundary, not on the owner's own host).
39
+ DEFAULT_MAX_TIER = os.environ.get("BRAIN_DEFAULT_MAX_TIER", "MNPI")
40
+ if DEFAULT_MAX_TIER not in RANK:
41
+ DEFAULT_MAX_TIER = "MNPI"
42
+
43
+ # Conservative default for the untrusted read+draft leg (role=vm): surface
44
+ # Public + Internal only unless a human explicitly elevates with --max-tier.
45
+ VM_DEFAULT_MAX_TIER = "Internal"
46
+
47
+
48
+ def normalize(value: object) -> str:
49
+ """Map a raw frontmatter value to a recognised tier, default-deny on miss."""
50
+ if isinstance(value, str) and value.strip() in RANK:
51
+ return value.strip()
52
+ return DEFAULT_DENY_TIER
53
+
54
+
55
+ def rank(value: object) -> int:
56
+ """Effective sensitivity rank, default-deny (unlabelled -> MNPI rank)."""
57
+ return RANK[normalize(value)]
58
+
59
+
60
+ def is_default_denied(value: object) -> bool:
61
+ """True iff the raw value would be coerced to the default-deny tier."""
62
+ return not (isinstance(value, str) and value.strip() in RANK)
63
+
64
+
65
+ # Lowercased tier -> canonical, for detecting casing mistakes (F-04).
66
+ _CANON_BY_LOWER = {t.lower(): t for t in TIERS}
67
+
68
+
69
+ def casing_mismatch(value: object) -> str | None:
70
+ """If ``value`` is a KNOWN tier in the wrong case (e.g. 'internal'), return
71
+ its canonical form; else None.
72
+
73
+ DESIGN DECISION (F-04): the filter keeps STRICT matching — a non-canonical
74
+ value is default-denied (fail-closed), never silently up-ranked (which would
75
+ be fail-OPEN). But a wrong-case known tier is almost always an authoring slip
76
+ that would make the note invisible forever, so we surface it as a diagnostic
77
+ here (and in redaction_report) instead of letting it vanish silently. The
78
+ fix-at-source is tools/validate.py, which flags non-canonical casing.
79
+ """
80
+ if isinstance(value, str):
81
+ v = value.strip()
82
+ if v not in RANK and v.lower() in _CANON_BY_LOWER:
83
+ return _CANON_BY_LOWER[v.lower()]
84
+ return None
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class ClassificationFilter:
89
+ """Deny-by-default egress filter applied as the FINAL stage before stdout.
90
+
91
+ A note is surfaceable iff its effective rank <= the caller's max-tier rank.
92
+ Unlabelled/unrecognised -> MNPI -> only surfaceable when max_tier is MNPI
93
+ (the explicit human-gated path).
94
+ """
95
+
96
+ max_tier: str = DEFAULT_MAX_TIER
97
+
98
+ def __post_init__(self) -> None:
99
+ if self.max_tier not in RANK:
100
+ raise ValueError(
101
+ f"unknown max_tier {self.max_tier!r}; expected one of {TIERS}"
102
+ )
103
+
104
+ @property
105
+ def max_rank(self) -> int:
106
+ return RANK[self.max_tier]
107
+
108
+ def allows(self, classification: object) -> bool:
109
+ return rank(classification) <= self.max_rank
110
+
111
+ def filter(self, items: Iterable[dict], key: str = "classification") -> list[dict]:
112
+ """Drop any item whose classification exceeds the cap. Pure; no mutation."""
113
+ return [it for it in items if self.allows(it.get(key))]
114
+
115
+ def redaction_report(self, items: Sequence[dict], key: str = "classification") -> dict:
116
+ """How many items were withheld and why (for an honest CLI footer)."""
117
+ denied = [it for it in items if not self.allows(it.get(key))]
118
+ default_denied = sum(1 for it in denied if is_default_denied(it.get(key)))
119
+ # Surface wrong-case known tiers (F-04) so they don't vanish silently.
120
+ casing = sorted({
121
+ f"{it.get(key)!r}->{c}"
122
+ for it in items
123
+ if (c := casing_mismatch(it.get(key))) is not None
124
+ })
125
+ report = {
126
+ "total": len(items),
127
+ "surfaced": len(items) - len(denied),
128
+ "withheld": len(denied),
129
+ "withheld_unlabelled_default_deny": default_denied,
130
+ "max_tier": self.max_tier,
131
+ }
132
+ if casing:
133
+ report["casing_mismatch_warnings"] = casing
134
+ return report