exegete 0.14.1a0.dev1__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.
@@ -0,0 +1,1082 @@
1
+ # SPDX-License-Identifier: LGPL-3.0-or-later
2
+ """Per-project settings this server keeps beside data.qda (v0.12, D7).
3
+
4
+ One setting lives here so far: the AI coder name every row this server
5
+ writes into a project is attributed to. It is stored in a small JSON
6
+ sidecar, `exegete.json` (until 0.14.0, `qualcoder_mcp.json`), in the
7
+ project folder next to `data.qda`, because the setting belongs to the
8
+ PROJECT and must travel with it:
9
+ backups, workspace copies, a synced folder and a second machine all carry
10
+ it, and two hosts talking to one project agree on it without a shared
11
+ machine-level store.
12
+
13
+ Why the project folder is safe for a file of our own (verified at the
14
+ pinned clone, master 9bddf17): QualCoder never enumerates the project
15
+ root looking for strangers, its own cleanup deletes only `*_BKUP_*`
16
+ folders and its lock file, its backup copies the whole tree with an
17
+ ignore set that does not match this name (`app.py:1619-1631`), and a
18
+ project merge merges INTO the open project (`merge_projects.py:200`), so
19
+ the destination keeps its sidecar.
20
+
21
+ The file's new name (v0.14.1, the owner's ruling 41, decision 7):
22
+
23
+ - A read takes `exegete.json` when it exists, in any state (an
24
+ unreadable one is reported as unreadable, never passed over), and
25
+ otherwise the earlier `qualcoder_mcp.json`.
26
+ - The first write of the name in a project that has only the earlier
27
+ file copies its name, history and other keys into `exegete.json` (the
28
+ one atomic write below), and then rewrites the earlier file with
29
+ format_version 2 and `moved_to: "exegete.json"`. Versions 0.12 to 0.14
30
+ read only the earlier file and refuse to write one whose version is
31
+ above 1 ("written by a newer version"), so a second host still on
32
+ them stops rather than writing rows under a name the researcher has
33
+ since changed. The earlier file keeps the name it held at the move,
34
+ so their reads still recognise the rows it named.
35
+ - A restore of a backup made before the move brings back the earlier
36
+ file alone, unmarked: the read falls back to it and the next write
37
+ moves it again. Messages name the file in use.
38
+ - A MARKED earlier file alone (exegete.json removed or lost, as the
39
+ messages below invite for a damaged one) reads as "no name set",
40
+ keeping its history: the name it holds is the one from before the
41
+ move, and every change since lived in exegete.json only, so the next
42
+ write asks, as the messages promise, and never goes back to it.
43
+ - A project Exegete names first gets an earlier file too, written
44
+ already marked and holding no name, so that 0.12 to 0.14 refuse and
45
+ say to upgrade rather than ask for a name of their own and write under
46
+ it beside Exegete. Until v1.0, like the other support for the old
47
+ name.
48
+ - The mark is reported only when made. One that fails (a locked,
49
+ read-only or synced file) is said, and every owner-bearing write
50
+ tries again (`settle_earlier_file`); a name an older copy stored in
51
+ an unmarked earlier file beside exegete.json joins its history first.
52
+ - An earlier file that cannot be read is never rewritten (its bytes may
53
+ be the only history), and neither is one of a version above 2.
54
+
55
+ Restart resilience: nothing here is cached. Every read goes to disk, so a
56
+ host that recycles the server process between turns sees the same answer,
57
+ and so does a second host editing the same project.
58
+ """
59
+
60
+ import json
61
+ import logging
62
+ import os
63
+ import stat
64
+ import tempfile
65
+ import unicodedata
66
+ from datetime import datetime
67
+ from pathlib import Path
68
+ from typing import Any, Dict, List, NamedTuple, Optional, Tuple
69
+
70
+ from . import env_settings, names
71
+ from .database import (KNOWN_AI_ASSISTANT_OWNER, validate_coder_name,
72
+ validate_coder_note)
73
+
74
+ logger = logging.getLogger(__name__)
75
+
76
+ # The sidecar. Only these exact names are ever treated as one. The
77
+ # format marker and version are shared by both files: every version since
78
+ # 0.12 writes version 1.
79
+ SIDECAR_NAME = "exegete.json"
80
+ OLD_SIDECAR_NAME = "qualcoder_mcp.json"
81
+ SIDECAR_FORMAT = "qualcoder-mcp-project"
82
+ SIDECAR_FORMAT_VERSION = 1
83
+ # The earlier file after the move: a version 0.12 to 0.14 refuse to write,
84
+ # and the key naming the file that now holds the name.
85
+ OLD_SIDECAR_MOVED_VERSION = 2
86
+ MOVED_TO_KEY = "moved_to"
87
+ # The payload is a few hundred bytes with a full history; anything past
88
+ # this is not ours (the MRU reader's rationale, server.py:144-170).
89
+ SIDECAR_READ_MAX_BYTES = 64 * 1024
90
+ # History is capped on write, oldest first; the current entry is never
91
+ # dropped. Twenty entries are echoed into the conversation (ruling 11).
92
+ HISTORY_CAP = 200
93
+ HISTORY_ECHO = 20
94
+ # The write cap, in the SAME UNIT as the read cap. The two used to
95
+ # disagree: writes were capped at 200 ENTRIES and reads at 64 KiB, and
96
+ # two things inflate the file between them, the indent=2 formatting and
97
+ # the unknown top-level keys a write deliberately preserves. Ninety-two
98
+ # ordinary name changes were enough to produce a file this server had
99
+ # just written and would then refuse to read, after which every
100
+ # owner-bearing write was refused. A cap in entries cannot bound a file
101
+ # measured in bytes, so the byte budget is what holds and HISTORY_CAP is
102
+ # kept only as the cheap upper bound. The margin leaves room for the
103
+ # next write's own growth, so the file does not sit on the boundary
104
+ # (fix round 4).
105
+ SIDECAR_WRITE_MAX_BYTES = SIDECAR_READ_MAX_BYTES - 4096
106
+
107
+ # The host declaration (v0.11's machine-wide setting, re-purposed by D7)
108
+ # and this server's built-in default.
109
+ # Read under both spellings (v0.14.1), through env_settings.
110
+ AI_CODER_NAME_ENV = names.SETTINGS["ai_coder_name"][0]
111
+ DEFAULT_AI_CODER_NAME = "AI Coding Assistant"
112
+ # QualCoder 4.0's built-in assistant's owner string, re-exported from
113
+ # database.py (one definition, H3). Rows under it that this project has
114
+ # not adopted are another coder's work; `known_ai_assistant` is a
115
+ # heuristic label for them, never a fact about who typed.
116
+ __all_known_ai = KNOWN_AI_ASSISTANT_OWNER
117
+ # The pre-0.11 import label. It is an owner on `source` and `case_text`
118
+ # rows, never a coding owner, so it is not an AI coder name and never
119
+ # enters the set below (Appendix A, R2).
120
+ LEGACY_IMPORT_OWNER = "MCP Import"
121
+
122
+ # The states read_sidecar can report.
123
+ SIDECAR_UNSET = "unset"
124
+ SIDECAR_SET = "project"
125
+ SIDECAR_UNREADABLE = "unreadable"
126
+ SIDECAR_NEWER_FORMAT = "newer_format"
127
+
128
+ _UNREADABLE = (
129
+ "The AI coder name file for this project ({file} in the "
130
+ "project folder) could not be read. Ask the user to repair or remove "
131
+ "it; the next write will then ask for the name again. Nothing was "
132
+ "written.")
133
+
134
+ _NEWER_FORMAT = (
135
+ "The AI coder name file for this project ({file} in the "
136
+ "project folder) was written by a newer version of this server "
137
+ f"({names.SERVER_NAME}, formerly qualcoder-mcp) and this one cannot "
138
+ f"write it safely. Upgrade {names.SERVER_NAME}, or ask the "
139
+ "user to move the file aside; the next write will then ask for the "
140
+ "name again. Nothing was written.")
141
+
142
+ _OVERSIZED = (
143
+ "The AI coder name file for this project ({file} in the "
144
+ "project folder) is too large to write: even with one history entry "
145
+ "it would be bigger than this server can read back. Ask the user to "
146
+ "remove the extra top-level keys in it, or to move the file aside; "
147
+ "the next write will then ask for the name again. Nothing was "
148
+ "written.")
149
+
150
+
151
+
152
+ def _naming(template: str, path: Any = None) -> str:
153
+ """A message naming the file in use (`path`), or the new file."""
154
+ return template.format(file=Path(path).name if path else SIDECAR_NAME)
155
+
156
+
157
+ def unreadable_message(path: Any = None) -> str:
158
+ return _naming(_UNREADABLE, path)
159
+
160
+
161
+ def newer_format_message(path: Any = None) -> str:
162
+ return _naming(_NEWER_FORMAT, path)
163
+
164
+
165
+ def oversized_message(path: Any = None) -> str:
166
+ return _naming(_OVERSIZED, path)
167
+
168
+
169
+ # The messages naming the new file, for callers with no path at hand.
170
+ UNREADABLE_MESSAGE = unreadable_message()
171
+ NEWER_FORMAT_MESSAGE = newer_format_message()
172
+ OVERSIZED_MESSAGE = oversized_message()
173
+
174
+ READ_ONLY_FOLDER_MESSAGE = (
175
+ "The project folder is not writable, so the AI coder name cannot be "
176
+ "stored with the project. Make the folder writable, or copy the "
177
+ "project to the workspace (copy_project_to_workspace) and work on the "
178
+ "copy.")
179
+
180
+ UNSET_HINT = (
181
+ "The first write will ask which name to store AI rows under; you can "
182
+ "set it now with set_project_ai_coder_name.")
183
+
184
+ EARLIER_MARKED_HINT = (
185
+ f"{SIDECAR_NAME} is missing from the project folder, and "
186
+ f"{OLD_SIDECAR_NAME} beside it is marked as moved: it holds only the "
187
+ f"names used before the move, so none of them is used now. "
188
+ + UNSET_HINT)
189
+
190
+
191
+ class SidecarWriteError(Exception):
192
+ """The sidecar could not be written; nothing on disk was changed."""
193
+
194
+
195
+ # What a write did about the earlier file (EarlierFile.status).
196
+ EARLIER_NOTHING = "nothing" # none to mark: absent, already marked,
197
+ # or not one this server rewrites
198
+ EARLIER_MARKED = "marked" # an unmarked version 1 file, marked now
199
+ EARLIER_NOT_MARKED = "not_marked" # one this write could not rewrite
200
+ EARLIER_CREATED = "created" # none there: written already marked
201
+ EARLIER_NOT_CREATED = "not_created" # none there, and none could be written
202
+
203
+
204
+ class EarlierFile(NamedTuple):
205
+ """What happened to `qualcoder_mcp.json` in one write."""
206
+ status: str
207
+ path: Path
208
+ held_name: Optional[str] = None # the name it holds, for messages
209
+ error: Optional[str] = None # why it was not marked (a type name)
210
+ # names an older copy stored in it after the move, added to
211
+ # exegete.json's history by the same write
212
+ names_added: Tuple[str, ...] = ()
213
+
214
+
215
+ class NameStored(NamedTuple):
216
+ """`store_ai_coder_name`'s answer: the entry and the earlier file."""
217
+ entry: Dict[str, Any]
218
+ earlier: EarlierFile
219
+
220
+
221
+ def _now_iso() -> str:
222
+ """Timezone-aware local time to the second.
223
+
224
+ The same aware clock QualCoder writes its own dates with
225
+ (`__main__.py:1865` at 9bddf17). The MRU file's naive local time is
226
+ fine for a single-machine hint; this file travels between machines,
227
+ so the offset is part of the value.
228
+ """
229
+ return datetime.now().astimezone().isoformat(timespec="seconds")
230
+
231
+
232
+ def _parse_iso(value: Any) -> Optional[str]:
233
+ """Return `value` when it parses as ISO 8601, else None.
234
+
235
+ Timestamps are echoed into the conversation, so a hand-edited file
236
+ must not be able to put arbitrary text there. A value that does not
237
+ parse is dropped (null), which never invalidates the file: the name
238
+ is what matters and the timestamp is informational.
239
+ """
240
+ if not isinstance(value, str) or not value.strip():
241
+ return None
242
+ text = value.strip()
243
+ try:
244
+ datetime.fromisoformat(text.replace("Z", "+00:00"))
245
+ except ValueError:
246
+ return None
247
+ return text
248
+
249
+
250
+ def _entry(name: str, set_at: Optional[str], note: str,
251
+ host_declaration: Optional[str]) -> Dict[str, Any]:
252
+ """One history entry / the current setting, in field order."""
253
+ return {"name": name, "set_at": set_at, "note": note,
254
+ "host_declaration": host_declaration}
255
+
256
+
257
+ def _validated_entry(raw: Any) -> Optional[Dict[str, Any]]:
258
+ """Validate one sidecar entry, or None when it is not usable.
259
+
260
+ Every field is validated on READ as well as on write, because the
261
+ file can be hand-edited or arrive from another machine: a name goes
262
+ through `validate_coder_name` so a tampered sidecar can never smuggle
263
+ a control character, a bidi override or a '#####' marker into an
264
+ `owner` column, and the note goes through `validate_coder_note` for
265
+ the same reason (it is echoed into the conversation). Unknown keys
266
+ inside an entry are dropped.
267
+ """
268
+ if not isinstance(raw, dict):
269
+ return None
270
+ try:
271
+ name = validate_coder_name(raw.get("name"), "ai_coder_name.name")
272
+ except ValueError:
273
+ return None
274
+ note_raw = raw.get("note")
275
+ if note_raw is None:
276
+ note_raw = ""
277
+ try:
278
+ note = validate_coder_note(note_raw, "ai_coder_name.note")
279
+ except ValueError:
280
+ return None
281
+ host = raw.get("host_declaration")
282
+ if host is not None:
283
+ try:
284
+ host = validate_coder_name(host, "ai_coder_name.host_declaration")
285
+ except ValueError:
286
+ return None
287
+ return _entry(name, _parse_iso(raw.get("set_at")), note, host)
288
+
289
+
290
+ class SidecarState:
291
+ """What the sidecar says, read fresh from disk.
292
+
293
+ `status` is one of "unset" (no file), "project" (a valid current
294
+ name), "unreadable" (present but not ours to read: not UTF-8, not
295
+ JSON, wrong format, oversized, a symlink, or a field that fails
296
+ validation) and "newer_format" (a format_version we do not write).
297
+ A "newer_format" file whose current entry validates still reports its
298
+ name, because reading it is safe; writing it is not. `earlier_marked`
299
+ is True for a marked earlier file read with no exegete.json beside
300
+ it: "unset", with the names it holds in `history`.
301
+ """
302
+
303
+ __slots__ = ("status", "entry", "history", "path", "earlier_marked")
304
+
305
+ def __init__(self, status: str, entry: Optional[Dict[str, Any]] = None,
306
+ history: Optional[List[Dict[str, Any]]] = None,
307
+ path: Optional[Path] = None, earlier_marked: bool = False):
308
+ self.status = status
309
+ self.entry = entry
310
+ self.history = list(history or [])
311
+ self.path = path
312
+ self.earlier_marked = earlier_marked
313
+
314
+ @property
315
+ def name(self) -> Optional[str]:
316
+ return self.entry["name"] if self.entry else None
317
+
318
+ @property
319
+ def host_declaration(self) -> Optional[str]:
320
+ return self.entry.get("host_declaration") if self.entry else None
321
+
322
+ @property
323
+ def is_set(self) -> bool:
324
+ return self.status == SIDECAR_SET and self.entry is not None
325
+
326
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
327
+ return f"SidecarState({self.status!r}, name={self.name!r})"
328
+
329
+
330
+ def sidecar_path(project_folder: Any) -> Path:
331
+ """The sidecar in use in a project folder.
332
+
333
+ `exegete.json` when it exists in any form (an unreadable one
334
+ included: it is reported, never passed over), else the earlier
335
+ `qualcoder_mcp.json` when that exists, else `exegete.json`, where
336
+ the next write puts the name.
337
+ """
338
+ new = Path(project_folder) / SIDECAR_NAME
339
+ if os.path.lexists(new):
340
+ return new
341
+ old = Path(project_folder) / OLD_SIDECAR_NAME
342
+ if os.path.lexists(old):
343
+ return old
344
+ return new
345
+
346
+
347
+ def _read_raw(path: Path) -> Optional[Dict[str, Any]]:
348
+ """The sidecar's JSON object, or None when it is not readable.
349
+
350
+ Refuses a symlink before opening (`lstat` first, the MRU reader's
351
+ discipline: a symlink at this name is either a mistake or a trap, and
352
+ following it would let a write land outside the project folder), caps
353
+ the read in BYTES so a multibyte payload cannot slip under a
354
+ character count, and decodes as `utf-8-sig`: BOM tolerance is
355
+ deliberate here and here only, because this file is meant to be
356
+ hand-editable and Windows editors add one (ruling 12).
357
+ """
358
+ try:
359
+ st = os.lstat(path)
360
+ except (OSError, ValueError):
361
+ return None
362
+ if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode):
363
+ return None
364
+ try:
365
+ with open(path, "rb") as f:
366
+ raw = f.read(SIDECAR_READ_MAX_BYTES + 1)
367
+ except OSError:
368
+ return None
369
+ if len(raw) > SIDECAR_READ_MAX_BYTES:
370
+ return None
371
+ try:
372
+ data = json.loads(raw.decode("utf-8-sig"))
373
+ except Exception:
374
+ # Deliberately the whole class, not a list of the failures we
375
+ # thought of. `read_sidecar` promises it never raises, and the
376
+ # list did not hold: CPython's JSON scanner raises RecursionError
377
+ # on deep nesting, which is a RuntimeError and not a ValueError,
378
+ # and 64 KiB of '[' is about 30,000 levels, under the size cap.
379
+ # The damage was not here but in restore_backup, which reads the
380
+ # sidecar again AFTER the folder swap: a COMPLETED restore was
381
+ # reported as a bare internal error with no success flag and no
382
+ # safety-backup path, which is the researcher's only way back.
383
+ # Over-catching costs one thing, classifying an odd file as
384
+ # unreadable, which is the documented fallback for every other
385
+ # malformed shape and names the file in the message (fix round 4).
386
+ return None
387
+ return data if isinstance(data, dict) else None
388
+
389
+
390
+ def read_sidecar(project_folder: Any) -> SidecarState:
391
+ """Read the project's sidecar. Never raises, never caches, never writes.
392
+
393
+ Called on every use: a host that recycled the process, and a second
394
+ host editing the same project, both see what is on disk now.
395
+ """
396
+ path = sidecar_path(project_folder)
397
+ if not os.path.lexists(path):
398
+ return SidecarState(SIDECAR_UNSET, path=path)
399
+ data = _read_raw(path)
400
+ if data is None or data.get("format") != SIDECAR_FORMAT:
401
+ return SidecarState(SIDECAR_UNREADABLE, path=path)
402
+ version = data.get("format_version")
403
+ # The earlier file may be at version 2, the move's mark; a newer one
404
+ # is refused as any newer file is.
405
+ newest = (OLD_SIDECAR_MOVED_VERSION if path.name == OLD_SIDECAR_NAME
406
+ else SIDECAR_FORMAT_VERSION)
407
+ if not isinstance(version, int) or isinstance(version, bool) or version < 1:
408
+ return SidecarState(SIDECAR_UNREADABLE, path=path)
409
+ entry = _validated_entry(data.get("ai_coder_name"))
410
+ if data.get("ai_coder_name") is not None and entry is None:
411
+ # A current setting we cannot validate makes the FILE unreadable:
412
+ # we never repair it silently, because it may hold the history
413
+ # the researcher cares about.
414
+ return SidecarState(SIDECAR_UNREADABLE, path=path)
415
+ # A malformed history never costs the current name: it is
416
+ # informational, and the next set rebuilds it (B1.3).
417
+ history = _validated_history(data.get("ai_coder_name_history"))
418
+ if version > newest:
419
+ return SidecarState(SIDECAR_NEWER_FORMAT, entry, history, path)
420
+ if path.name == OLD_SIDECAR_NAME and _is_marked(data):
421
+ # A marked earlier file alone: exegete.json, which held every
422
+ # name since the move, was removed or lost. The name here is the
423
+ # one from before the move, so it is never used again; the
424
+ # project reads as unset, and the next write asks, as the
425
+ # messages for a damaged exegete.json promise. Its names stay in
426
+ # the history, so rows under them still count as this project's
427
+ # AI work, and the next set carries them into exegete.json.
428
+ return SidecarState(SIDECAR_UNSET, None, _held_entries(entry, history),
429
+ path, earlier_marked=True)
430
+ if entry is None:
431
+ return SidecarState(SIDECAR_UNSET, None, history, path)
432
+ return SidecarState(SIDECAR_SET, entry, history, path)
433
+
434
+
435
+ def _validated_history(raw: Any) -> List[Dict[str, Any]]:
436
+ """A history, every entry validated; empty when any entry fails."""
437
+ history: List[Dict[str, Any]] = []
438
+ if isinstance(raw, list):
439
+ for item in raw:
440
+ validated = _validated_entry(item)
441
+ if validated is None:
442
+ return []
443
+ history.append(validated)
444
+ return history
445
+
446
+
447
+ def _is_marked(data: Dict[str, Any]) -> bool:
448
+ """Whether an earlier file carries the move's mark (version 2 or
449
+ `moved_to`)."""
450
+ return (data.get("format_version") == OLD_SIDECAR_MOVED_VERSION
451
+ or MOVED_TO_KEY in data)
452
+
453
+
454
+ def _held_entries(entry: Optional[Dict[str, Any]],
455
+ history: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
456
+ """A file's history with its current entry last, when the history
457
+ lacks it (a hand-edited file, or a malformed history read as empty)."""
458
+ held = list(history)
459
+ if entry is not None and entry not in held:
460
+ held.append(entry)
461
+ return held
462
+
463
+
464
+ def _encoded_payload(payload: Dict[str, Any]) -> bytes:
465
+ """The exact bytes a write would put on disk.
466
+
467
+ The trim below measures THIS, not an entry count, because it is what
468
+ the reader measures: `_read_raw` caps at SIDECAR_READ_MAX_BYTES of
469
+ file, and `indent=2` plus preserved unknown keys are what grow the
470
+ file between the two caps.
471
+ """
472
+ return json.dumps(payload, ensure_ascii=False,
473
+ indent=2).encode("utf-8")
474
+
475
+
476
+ def _inherit_mode_bits(tmp_path: Path, project_folder: Path) -> None:
477
+ """Give the sidecar `data.qda`'s permission bits when we can read them.
478
+
479
+ mkstemp creates at 0600, which would make a sidecar that a shared
480
+ project's other users cannot read while the database beside it is
481
+ group-readable. Masked to 0o666 so no execute bit is ever set, and a
482
+ no-op on Windows, where chmod only moves the read-only flag
483
+ (ruling 8).
484
+ """
485
+ if os.name == "nt":
486
+ return
487
+ try:
488
+ mode = stat.S_IMODE(os.stat(project_folder / "data.qda").st_mode)
489
+ except OSError:
490
+ return
491
+ try:
492
+ os.chmod(tmp_path, mode & 0o666)
493
+ except OSError:
494
+ pass
495
+
496
+
497
+ def write_ai_coder_name(project_folder: Any, name: str, note: str = "",
498
+ host_declaration: Optional[str] = None,
499
+ now: Optional[str] = None) -> Dict[str, Any]:
500
+ """Store `name` as the project's AI coder name; return the new entry.
501
+
502
+ `store_ai_coder_name` without the earlier file's report.
503
+
504
+ Raises:
505
+ SidecarWriteError: As `store_ai_coder_name`.
506
+ """
507
+ return store_ai_coder_name(project_folder, name, note=note,
508
+ host_declaration=host_declaration,
509
+ now=now).entry
510
+
511
+
512
+ def store_ai_coder_name(project_folder: Any, name: str, note: str = "",
513
+ host_declaration: Optional[str] = None,
514
+ now: Optional[str] = None) -> NameStored:
515
+ """Store `name` as the project's AI coder name.
516
+
517
+ Returns the new entry and what happened to the earlier file, which
518
+ the setter reports: whether it was marked, and when it could not be,
519
+ why and under which name an older copy would go on writing.
520
+
521
+ The MRU write discipline (`_open_mru_tmp`, server.py:80-120), with
522
+ one addition: a single `fsync` before the replace, because this file
523
+ is the only record of a choice the researcher made and re-creating it
524
+ means asking them again. `tempfile.mkstemp` opens O_CREAT|O_EXCL at
525
+ an unpredictable name inside the project folder, so two servers can
526
+ never share a temp name and a symlink pre-planted at a would-be name
527
+ is refused rather than written through; `os.replace` is atomic, so a
528
+ reader sees the old file or the new one, never a partial one; a
529
+ failure unlinks the temp and leaves the existing file byte-identical.
530
+
531
+ Temp litter from a crash (`exegete.json.<random>.tmp`) is never
532
+ reopened and never enumerated by this server; it is harmless and a
533
+ user may delete it.
534
+
535
+ The name is always written to `exegete.json`. When the project still
536
+ has the earlier `qualcoder_mcp.json` (the first write after 0.14, or
537
+ a restore of an older backup), its name, history and other keys are
538
+ carried into the new file, and the earlier file is then marked as
539
+ moved (`_mark_earlier_file`), so that 0.12 to 0.14 refuse to write
540
+ it. A failure to mark it is logged and reported in the answer, and
541
+ every later owner-bearing write tries again (`settle_earlier_file`);
542
+ the name itself is already stored.
543
+
544
+ Raises:
545
+ SidecarWriteError: With a message for the caller to return. The
546
+ files on disk are unchanged.
547
+ """
548
+ folder = Path(project_folder)
549
+ path = sidecar_path(folder)
550
+ target = folder / SIDECAR_NAME
551
+ if os.path.lexists(path):
552
+ try:
553
+ st = os.lstat(path)
554
+ except OSError as e:
555
+ raise SidecarWriteError(unreadable_message(path)) from e
556
+ if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode):
557
+ raise SidecarWriteError(unreadable_message(path))
558
+ existing = _read_raw(path) if os.path.lexists(path) else {}
559
+ if existing is None:
560
+ raise SidecarWriteError(unreadable_message(path))
561
+
562
+ state = read_sidecar(folder)
563
+ if state.status == SIDECAR_NEWER_FORMAT:
564
+ raise SidecarWriteError(newer_format_message(state.path))
565
+ if state.status == SIDECAR_UNREADABLE:
566
+ raise SidecarWriteError(unreadable_message(state.path))
567
+
568
+ # Validated HERE as well as at the tool layer, because this writes
569
+ # the string every later AI row is attributed to: one gate on the
570
+ # way in, one on the way out (_validated_entry), so no path can put
571
+ # a name in an owner column that the validator would refuse
572
+ # (fix round 4).
573
+ name = validate_coder_name(name, "name")
574
+ note = validate_coder_note(note, "note")
575
+ entry = _entry(name, now or _now_iso(), note, host_declaration)
576
+ added: List[Dict[str, Any]] = []
577
+ if path.name == OLD_SIDECAR_NAME:
578
+ # The move: the name the earlier file holds stays in the history
579
+ # even when its own history lacks it, so its rows still count.
580
+ history = _held_entries(state.entry, state.history)
581
+ else:
582
+ # A file an older copy wrote beside exegete.json: the names it
583
+ # holds join the history in this write, before it is marked.
584
+ history = list(state.history)
585
+ added = _missing(_entries_beside(folder, state), history)
586
+ history += added
587
+ history.append(entry)
588
+ if len(history) > HISTORY_CAP:
589
+ # Oldest first, and never the entry we are writing.
590
+ history = history[len(history) - HISTORY_CAP:]
591
+
592
+ # Unknown top-level keys are preserved: a later feature, or a
593
+ # researcher's own annotation, survives a name change (the
594
+ # "keeping other keys intact" pattern of view_av.py:1369-1376).
595
+ payload: Dict[str, Any] = dict(existing) if isinstance(existing, dict) else {}
596
+ # Carried over from a marked earlier file, the pointer would point at
597
+ # the file itself.
598
+ payload.pop(MOVED_TO_KEY, None)
599
+ payload["format"] = SIDECAR_FORMAT
600
+ payload["format_version"] = SIDECAR_FORMAT_VERSION
601
+ payload["written_by"] = f"{names.DISTRIBUTION} {_package_version()}"
602
+ payload["updated"] = entry["set_at"]
603
+ payload["ai_coder_name"] = entry
604
+ payload["ai_coder_name_history"] = history
605
+
606
+ encoded = _trimmed_encoding(payload, path)
607
+ try:
608
+ _replace_atomically(folder, target, encoded)
609
+ except OSError as e:
610
+ raise SidecarWriteError(
611
+ f"The AI coder name could not be stored with the project "
612
+ f"({type(e).__name__}). Nothing was changed.") from e
613
+ earlier = _mark_earlier_file(folder)
614
+ return NameStored(entry, earlier._replace(names_added=_names(added)))
615
+
616
+
617
+ def _trimmed_encoding(payload: Dict[str, Any], path: Path) -> bytes:
618
+ """The bytes to write, the history trimmed to the byte budget.
619
+
620
+ Trim in the unit the READER measures, on the bytes that will
621
+ actually be written. Oldest first, and never the last entry, the one
622
+ being written: a name change must not be refused because the
623
+ project has a long past.
624
+
625
+ Raises:
626
+ SidecarWriteError: When a one-entry history still does not fit.
627
+ """
628
+ encoded = _encoded_payload(payload)
629
+ while (len(encoded) > SIDECAR_WRITE_MAX_BYTES
630
+ and len(payload["ai_coder_name_history"]) > 1):
631
+ payload["ai_coder_name_history"] = \
632
+ payload["ai_coder_name_history"][1:]
633
+ encoded = _encoded_payload(payload)
634
+ if len(encoded) > SIDECAR_WRITE_MAX_BYTES:
635
+ # A one-entry history still does not fit, so the bulk is in the
636
+ # unknown top-level keys this write preserves. Refuse rather than
637
+ # write a file the reader will reject, and say where the size is.
638
+ raise SidecarWriteError(oversized_message(path))
639
+ return encoded
640
+
641
+
642
+ def _entries_beside(folder: Path, state: SidecarState) -> List[Dict[str, Any]]:
643
+ """The entries an unmarked earlier file beside exegete.json holds.
644
+
645
+ Empty unless exegete.json is in use and readable at this version and
646
+ the earlier file is one to mark (`_unmarked_earlier_file`): then its
647
+ validated history, and its current name when that history lacks it.
648
+ """
649
+ if not _earlier_file_to_settle(folder, state):
650
+ return []
651
+ data = _unmarked_earlier_file(folder)
652
+ if data is None:
653
+ return []
654
+ return _held_entries(_validated_entry(data.get("ai_coder_name")),
655
+ _validated_history(data.get(
656
+ "ai_coder_name_history")))
657
+
658
+
659
+ def _missing(entries: List[Dict[str, Any]],
660
+ history: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
661
+ """The entries not already in `history`, in their order (an entry
662
+ carried at the move is equal to its copy there, field for field)."""
663
+ return [e for i, e in enumerate(entries)
664
+ if e not in history and e not in entries[:i]]
665
+
666
+
667
+ def _names(entries: List[Dict[str, Any]]) -> Tuple[str, ...]:
668
+ """The distinct names in `entries`, in order."""
669
+ return tuple(dict.fromkeys(e["name"] for e in entries))
670
+
671
+
672
+ def _add_to_history(folder: Path) -> List[Dict[str, Any]]:
673
+ """Add the names an unmarked earlier file holds to exegete.json's
674
+ history, keeping its current name; return the entries added.
675
+
676
+ Read fresh, and written as a name change is (the same byte budget and
677
+ atomic replace), so a failure leaves exegete.json as it was.
678
+
679
+ Raises:
680
+ SidecarWriteError: exegete.json could not be read or written.
681
+ """
682
+ target = folder / SIDECAR_NAME
683
+ state = read_sidecar(folder)
684
+ added = _missing(_entries_beside(folder, state), state.history)
685
+ if not added:
686
+ return []
687
+ existing = _read_raw(target)
688
+ if existing is None:
689
+ raise SidecarWriteError(unreadable_message(target))
690
+ payload = dict(existing)
691
+ payload["written_by"] = f"{names.DISTRIBUTION} {_package_version()}"
692
+ payload["ai_coder_name_history"] = list(state.history) + added
693
+ encoded = _trimmed_encoding(payload, target)
694
+ try:
695
+ _replace_atomically(folder, target, encoded)
696
+ except OSError as e:
697
+ raise SidecarWriteError(
698
+ f"{SIDECAR_NAME} could not be written "
699
+ f"({type(e).__name__}).") from e
700
+ return added
701
+
702
+
703
+ def _replace_atomically(folder: Path, target: Path, encoded: bytes) -> None:
704
+ """Write `encoded` at `target` in one `os.replace`, or not at all.
705
+
706
+ Raises OSError after removing its temp file; `target` is then
707
+ byte-identical to what it was.
708
+ """
709
+ tmp: Optional[Path] = None
710
+ try:
711
+ fd, tmp_name = tempfile.mkstemp(dir=str(folder),
712
+ prefix=f"{target.name}.",
713
+ suffix=".tmp")
714
+ tmp = Path(tmp_name)
715
+ # The descriptor mkstemp returned belongs to nobody until
716
+ # `os.fdopen` takes it. If that call raises, POSIX still lets
717
+ # the cleanup unlink the open temp file, but Windows refuses,
718
+ # so the sidecar folder keeps the litter (fix round 5).
719
+ try:
720
+ handle = os.fdopen(fd, "wb")
721
+ except BaseException:
722
+ os.close(fd)
723
+ raise
724
+ with handle as f:
725
+ f.write(encoded)
726
+ f.flush()
727
+ os.fsync(f.fileno())
728
+ _inherit_mode_bits(tmp, folder)
729
+ os.replace(str(tmp), str(target))
730
+ except OSError:
731
+ if tmp is not None:
732
+ try:
733
+ tmp.unlink()
734
+ except OSError:
735
+ pass
736
+ raise
737
+
738
+
739
+ def _unmarked_earlier_file(folder: Path) -> Optional[Dict[str, Any]]:
740
+ """The earlier file's contents when it is one to mark, else None.
741
+
742
+ That is a regular file this server can read, in the shared format, at
743
+ version 1, which is what 0.12 to 0.14 write. A file already marked,
744
+ an unreadable one (its bytes may be the only history), a newer one
745
+ and anything that is not a regular file are never rewritten.
746
+ """
747
+ old = folder / OLD_SIDECAR_NAME
748
+ try:
749
+ st = os.lstat(old)
750
+ except (OSError, ValueError):
751
+ return None
752
+ if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode):
753
+ return None
754
+ data = _read_raw(old)
755
+ if (data is None or data.get("format") != SIDECAR_FORMAT
756
+ or data.get("format_version") != SIDECAR_FORMAT_VERSION
757
+ or isinstance(data.get("format_version"), bool)):
758
+ return None
759
+ return data
760
+
761
+
762
+ def _held_name(data: Dict[str, Any]) -> Optional[str]:
763
+ """The name a sidecar's current entry holds, when it validates."""
764
+ entry = _validated_entry(data.get("ai_coder_name"))
765
+ return entry["name"] if entry else None
766
+
767
+
768
+ def _mark_earlier_file(folder: Path) -> EarlierFile:
769
+ """Mark the earlier `qualcoder_mcp.json` as moved; say what happened.
770
+
771
+ With nothing at that name (a project Exegete names first, or a file
772
+ removed since), one is written already marked, holding no name
773
+ (`_write_marker`), so that 0.12 to 0.14 refuse and say to upgrade
774
+ rather than ask for a name of their own. Until v1.0, like the other
775
+ support for the old name.
776
+
777
+ Only a file `_unmarked_earlier_file` returns is rewritten: its keys
778
+ are kept (the name it held at the move included, so 0.12 to 0.14
779
+ still recognise the rows it named), and it gains format_version 2
780
+ and `moved_to`, which those versions refuse to write. When the
781
+ rewrite fails (the file locked in the Finder, read-only on Windows,
782
+ held by a sync program), the answer says so, with the name an older
783
+ copy would go on writing under, and nothing else changes.
784
+ """
785
+ old = folder / OLD_SIDECAR_NAME
786
+ if not os.path.lexists(old):
787
+ return _write_marker(folder)
788
+ data = _unmarked_earlier_file(folder)
789
+ if data is None:
790
+ return EarlierFile(EARLIER_NOTHING, old)
791
+ held = _held_name(data)
792
+ data["format_version"] = OLD_SIDECAR_MOVED_VERSION
793
+ data[MOVED_TO_KEY] = SIDECAR_NAME
794
+ data["written_by"] = f"{names.DISTRIBUTION} {_package_version()}"
795
+ try:
796
+ _replace_atomically(folder, old, _encoded_payload(data))
797
+ except OSError as e:
798
+ logger.warning("%s could not be marked as moved (%s); every "
799
+ "write that stores a name tries again",
800
+ OLD_SIDECAR_NAME, type(e).__name__)
801
+ return EarlierFile(EARLIER_NOT_MARKED, old, held, type(e).__name__)
802
+ return EarlierFile(EARLIER_MARKED, old, held)
803
+
804
+
805
+ def _write_marker(folder: Path) -> EarlierFile:
806
+ """Write `qualcoder_mcp.json` already marked, holding no name.
807
+
808
+ Version 2 and `moved_to`, and nothing else of the format's: 0.12 to
809
+ 0.14 check the version first, so they report the file as written by
810
+ a newer version and refuse to write it or any row, and Exegete never
811
+ takes a name from it (a marked file alone reads as unset). Never
812
+ written over a file that appeared meanwhile (`_create_exclusively`).
813
+ """
814
+ old = folder / OLD_SIDECAR_NAME
815
+ marker = {"format": SIDECAR_FORMAT,
816
+ "format_version": OLD_SIDECAR_MOVED_VERSION,
817
+ MOVED_TO_KEY: SIDECAR_NAME,
818
+ "written_by": f"{names.DISTRIBUTION} {_package_version()}"}
819
+ try:
820
+ created = _create_exclusively(folder, old, _encoded_payload(marker))
821
+ except OSError as e:
822
+ logger.warning("%s could not be written beside %s (%s); every "
823
+ "write that stores a name tries again",
824
+ OLD_SIDECAR_NAME, SIDECAR_NAME, type(e).__name__)
825
+ return EarlierFile(EARLIER_NOT_CREATED, old, None, type(e).__name__)
826
+ return EarlierFile(EARLIER_CREATED if created else EARLIER_NOTHING, old)
827
+
828
+
829
+ def _create_exclusively(folder: Path, target: Path, encoded: bytes) -> bool:
830
+ """Write `encoded` at `target` only if nothing is there; True if so.
831
+
832
+ The temp file of `_replace_atomically`, then a hard link to the
833
+ target's name, which fails rather than replace a file an older copy
834
+ wrote in the same instant. Where the file system has no hard links
835
+ (some shared and removable drives), a replace after a last look, and
836
+ only while the name is still free.
837
+
838
+ Raises:
839
+ OSError: The file could not be written; nothing is left behind.
840
+ """
841
+ tmp: Optional[Path] = None
842
+ try:
843
+ fd, tmp_name = tempfile.mkstemp(dir=str(folder),
844
+ prefix=f"{target.name}.",
845
+ suffix=".tmp")
846
+ tmp = Path(tmp_name)
847
+ try:
848
+ handle = os.fdopen(fd, "wb")
849
+ except BaseException:
850
+ os.close(fd)
851
+ raise
852
+ with handle as f:
853
+ f.write(encoded)
854
+ f.flush()
855
+ os.fsync(f.fileno())
856
+ _inherit_mode_bits(tmp, folder)
857
+ try:
858
+ os.link(str(tmp), str(target))
859
+ except FileExistsError:
860
+ return False
861
+ except OSError:
862
+ if os.path.lexists(target):
863
+ return False
864
+ os.replace(str(tmp), str(target))
865
+ tmp = None
866
+ return True
867
+ finally:
868
+ if tmp is not None:
869
+ try:
870
+ tmp.unlink()
871
+ except OSError:
872
+ pass
873
+
874
+
875
+ def _earlier_file_to_settle(folder: Path, state: SidecarState) -> bool:
876
+ """Whether exegete.json is in use and readable at this version, the
877
+ one case in which the earlier file beside it is kept marked."""
878
+ return (state.path is not None and state.path.name == SIDECAR_NAME
879
+ and state.status in (SIDECAR_SET, SIDECAR_UNSET))
880
+
881
+
882
+ def settle_earlier_file(project_folder: Any,
883
+ state: Optional[SidecarState] = None) -> EarlierFile:
884
+ """Keep the earlier file beside exegete.json marked. Never raises.
885
+
886
+ The retry for a mark that failed, and the mark for a file an older
887
+ copy of the server wrote beside exegete.json after the move, whose
888
+ names first join exegete.json's history (in one write of it, before
889
+ the mark), so its rows count as this project's AI work. With nothing
890
+ at the old name, the marker is written (`_write_marker`). Called
891
+ before every owner-bearing write (`server._resolve_write_owner`),
892
+ after the checks that may refuse it, so a refused write still writes
893
+ nothing. Acts only while exegete.json is in use and readable at this
894
+ version (`state`, read fresh when not given).
895
+ """
896
+ folder = Path(project_folder)
897
+ old = folder / OLD_SIDECAR_NAME
898
+ try:
899
+ if state is None:
900
+ state = read_sidecar(folder)
901
+ if not _earlier_file_to_settle(folder, state):
902
+ return EarlierFile(EARLIER_NOTHING, old)
903
+ added: List[Dict[str, Any]] = []
904
+ if _missing(_entries_beside(folder, state), state.history):
905
+ try:
906
+ added = _add_to_history(folder)
907
+ except SidecarWriteError as e:
908
+ # Not marked either: marked, the file would never be
909
+ # read for its names again. The next write tries both.
910
+ logger.warning("The names in %s could not be added to "
911
+ "%s; it is left unmarked for now",
912
+ OLD_SIDECAR_NAME, SIDECAR_NAME)
913
+ return EarlierFile(EARLIER_NOT_MARKED, old, None,
914
+ type(e.__cause__ or e).__name__)
915
+ return _mark_earlier_file(folder)._replace(
916
+ names_added=_names(added))
917
+ except Exception as e: # noqa: BLE001
918
+ # A write must never fail on this: the name it uses is right, and
919
+ # the next write tries again.
920
+ logger.warning("%s could not be checked (%s)", OLD_SIDECAR_NAME,
921
+ type(e).__name__)
922
+ return EarlierFile(EARLIER_NOT_MARKED, old, None, type(e).__name__)
923
+
924
+
925
+ def unmarked_earlier_file(project_folder: Any,
926
+ state: Optional[SidecarState] = None
927
+ ) -> Optional[EarlierFile]:
928
+ """The earlier file when exegete.json is in use and it is unmarked.
929
+
930
+ A read: it never writes. The project reads report it, so that a mark
931
+ that failed stays visible until a write makes it or the researcher
932
+ unlocks or removes the file.
933
+ """
934
+ folder = Path(project_folder)
935
+ if state is None:
936
+ state = read_sidecar(folder)
937
+ if not _earlier_file_to_settle(folder, state):
938
+ return None
939
+ data = _unmarked_earlier_file(folder)
940
+ if data is None:
941
+ return None
942
+ return EarlierFile(EARLIER_NOT_MARKED, folder / OLD_SIDECAR_NAME,
943
+ _held_name(data))
944
+
945
+
946
+ def _package_version() -> str:
947
+ from . import __version__
948
+ return __version__
949
+
950
+
951
+ def mismatch(env: Optional[str], current: Optional[Dict[str, Any]]) -> bool:
952
+ """True when this host's declaration conflicts with the project (c2).
953
+
954
+ Rule c2 (ruling 1): a write is refused if and only if this host
955
+ declares a name AND the declaration is neither the project's current
956
+ name nor the declaration recorded when that name was set. So an
957
+ undeclared host never nags, a host whose declaration was acknowledged
958
+ once never nags again, and a host that declares a different name from
959
+ the one the project uses asks the user which to use.
960
+ """
961
+ if env is None:
962
+ return False
963
+ if current is None:
964
+ return False
965
+ if env == current.get("name"):
966
+ return False
967
+ if env == current.get("host_declaration"):
968
+ return False
969
+ return True
970
+
971
+
972
+ def host_declaration(environ: Optional[Dict[str, str]] = None) -> Optional[str]:
973
+ """This host's declared AI coder name, or None.
974
+
975
+ Invalid values are treated as absent here: `main()` validates the
976
+ variable at start-up and refuses to start on a bad one, so a bad
977
+ value reaching this function means the server was started another way
978
+ (a test, an embedded use); a declaration we would refuse to write is
979
+ not one to compare against either.
980
+ """
981
+ reading = env_settings.read("ai_coder_name", environ)
982
+ raw = reading.value
983
+ if raw is None:
984
+ return None
985
+ try:
986
+ return validate_coder_name(raw, reading.name)
987
+ except ValueError:
988
+ return None
989
+
990
+
991
+ def ai_coder_names_for_project(
992
+ project_folder: Any,
993
+ environ: Optional[Dict[str, str]] = None) -> Tuple[str, ...]:
994
+ """The names this server treats as its own AI work in this project.
995
+
996
+ One definition, used by every feature that needs to tell this
997
+ server's rows from another coder's (H3): the collateral warnings of a
998
+ cascade preview, the coder roles of a comparison, and the flagship's
999
+ pseudonymisation next. Ordered, no duplicates:
1000
+
1001
+ 1. the project's current AI coder name, when set;
1002
+ 2. every name in the project's history, oldest first, then the
1003
+ names in a qualcoder_mcp.json an older copy wrote beside
1004
+ exegete.json and no write has marked yet (v0.14.1);
1005
+ 3. `DEFAULT_AI_CODER_NAME`, always, because every pre-0.12 row this
1006
+ server wrote carries it and a name change never re-attributes
1007
+ rows;
1008
+ 4. this host's declaration, when set and valid, because v0.11 wrote
1009
+ under it machine-wide.
1010
+
1011
+ "AI Agent" enters the set only through those rules, that is, when the
1012
+ researcher chose it for this project or this host declares it; it is
1013
+ never included on its own. "MCP Import" is an import label, not a
1014
+ coding owner, and is never included.
1015
+
1016
+ Never refuses and never asks: reads do not ask, so an unset or
1017
+ unreadable sidecar yields the built-in default (plus the declaration
1018
+ when there is one).
1019
+ """
1020
+ names: List[str] = []
1021
+
1022
+ def add(value: Optional[str]) -> None:
1023
+ if value and value not in names:
1024
+ names.append(value)
1025
+
1026
+ state = read_sidecar(project_folder)
1027
+ add(state.name)
1028
+ for item in state.history:
1029
+ add(item.get("name"))
1030
+ # A file an older copy wrote beside exegete.json, not yet marked:
1031
+ # its names are this project's AI work before any write adds them
1032
+ for item in _entries_beside(Path(project_folder), state):
1033
+ add(item.get("name"))
1034
+ add(DEFAULT_AI_CODER_NAME)
1035
+ add(host_declaration(environ))
1036
+ return tuple(names)
1037
+
1038
+
1039
+ def known_ai_set(project_folder: Any,
1040
+ environ: Optional[Dict[str, str]] = None) -> Tuple[str, ...]:
1041
+ """The names the ask may propose and probe for, ordered.
1042
+
1043
+ Narrower than `ai_coder_names_for_project`: the migration probe of
1044
+ B1.12 asks whether a project already holds AI rows, and the answer
1045
+ may only ever name the built-in default, QualCoder 4.0's assistant
1046
+ string and this host's declaration. It never selects distinct owners,
1047
+ so it can never enumerate a human or a hidden coder.
1048
+ """
1049
+ names: List[str] = [DEFAULT_AI_CODER_NAME, KNOWN_AI_ASSISTANT_OWNER]
1050
+ declared = host_declaration(environ)
1051
+ if declared and declared not in names:
1052
+ names.append(declared)
1053
+ return tuple(names)
1054
+
1055
+
1056
+ def folder_is_writable(project_folder: Any) -> bool:
1057
+ """Whether a sidecar can be created in this folder (ruling 2).
1058
+
1059
+ `os.access` answers with the real uid, which is what mkstemp will
1060
+ use. On Windows it reports the read-only attribute only, which is the
1061
+ same answer NTFS permissions would give for the common cases; a
1062
+ genuine ACL refusal surfaces as the write error instead.
1063
+ """
1064
+ return os.access(str(project_folder), os.W_OK | os.X_OK)
1065
+
1066
+
1067
+ def echoed_history(state: SidecarState) -> List[Dict[str, Any]]:
1068
+ """The last HISTORY_ECHO entries, newest last (ruling 11)."""
1069
+ return state.history[-HISTORY_ECHO:]
1070
+
1071
+
1072
+ def normalise_for_case_compare(value: str) -> str:
1073
+ """Casefold plus NFC, for the setter's case-only WARNING alone.
1074
+
1075
+ Coder names are compared EXACTLY everywhere a decision depends on
1076
+ them (X2): `coder_names.name` is TEXT UNIQUE under SQLite's BINARY
1077
+ collation (app.py:1470-1475) and every upstream owner match is exact
1078
+ (ai_mcp_server.py:1603-1604, :3235-3240). This helper exists only so
1079
+ the setter can TELL the user that two names differ by letter case;
1080
+ it never decides anything.
1081
+ """
1082
+ return unicodedata.normalize("NFC", value).casefold()