knodin 0.10.4 → 0.10.5

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.
package/dist/bin/cli.js CHANGED
@@ -174,7 +174,13 @@ function formatIndexHuman(result) {
174
174
  function formatIndexVerificationError(result) {
175
175
  const firstIssue = result.verification.missing.files[0] ?? result.verification.missing.records[0];
176
176
  const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
177
- return `knodin index: requested work completed, but ${result.verification.issueCount.toLocaleString()} graph issue(s) remain.${detail} Run \`knodin repair\`.\n`;
177
+ // Describes whichever condition actually gated. Printing the issue count
178
+ // unconditionally produced "0 graph issue(s) remain" as a failure reason —
179
+ // a sentence that reports no problem and then recommends repair (KNODIN-7).
180
+ const condition = result.verification.issueCount > 0
181
+ ? `${result.verification.issueCount.toLocaleString()} graph issue(s) remain`
182
+ : `graph verification reported status "${result.verification.status}"`;
183
+ return `knodin index: requested work completed, but ${condition}.${detail} Run \`knodin repair\`.\n`;
178
184
  }
179
185
  /**
180
186
  * The hooks/lifecycle line of `status`.
@@ -2256,11 +2262,39 @@ async function main() {
2256
2262
  result = indexResult;
2257
2263
  const indexedCheck = checkIndexed([...indexResult.indexed, ...indexResult.unchanged], plan.repo);
2258
2264
  if (!indexedCheck.ok) {
2265
+ // Same obligation as the verification gate below: `--json` must mean
2266
+ // parseable output on every exit path, and this one wrote zero bytes
2267
+ // to stdout and a bare sentence to stderr (KNODIN-5).
2268
+ if (jsonOutput)
2269
+ process.stdout.write(`${JSON.stringify({
2270
+ available: false,
2271
+ status: "unavailable",
2272
+ state: "unknown",
2273
+ error: indexedCheck.error,
2274
+ })}\n`);
2259
2275
  process.stderr.write(`${indexedCheck.error}\n`);
2260
2276
  await engine.close();
2261
2277
  process.exit(1);
2262
2278
  }
2263
- if (indexResult.verification.status !== "healthy") {
2279
+ // Gated on damage, not on `status !== "healthy"`. That older condition
2280
+ // rejected `stale`, and a freshly promoted graph is routinely stale
2281
+ // because freshness has not been probed since the index wrote — so the
2282
+ // FIRST clean index of any repository exited 1 while its own progress
2283
+ // output said "Promoted graph has 0 issue(s)" and its error said "0
2284
+ // graph issue(s) remain. Run `knodin repair`", recommending repair for
2285
+ // a graph it had just verified as undamaged (KNODIN-7).
2286
+ //
2287
+ // `repair-needed` and a non-zero issue count keep every real failure.
2288
+ // Staleness is a freshness fact that `status` reports and ordinary use
2289
+ // reconciles; it is not a verification result, and treating it as one
2290
+ // made a new repository's first command fail for doing nothing wrong.
2291
+ if (indexResult.verification.status === "repair-needed" ||
2292
+ indexResult.verification.issueCount > 0) {
2293
+ // Emitted before exiting so `--json` is not silently empty on this
2294
+ // path: it previously wrote zero bytes to stdout and a human
2295
+ // sentence to stderr, which is the same defect as KNODIN-5.
2296
+ if (jsonOutput)
2297
+ process.stdout.write(`${JSON.stringify(indexResult)}\n`);
2264
2298
  process.stderr.write(formatIndexVerificationError(indexResult));
2265
2299
  await engine.close();
2266
2300
  process.exit(1);
@@ -2484,14 +2518,38 @@ async function main() {
2484
2518
  stripEmbeddings: !rest.includes("--keep-embeddings"),
2485
2519
  includeExcluded: rest.includes("--include-excluded"),
2486
2520
  });
2521
+ // `ok: false` and exit 0 are contradictory claims from one command, and
2522
+ // the exit code is what every shell idiom reads. `knodin seal --output X
2523
+ // && upload X` ran the upload after a refusal (KNODIN-4).
2524
+ //
2525
+ // seal already fails closed on content — it writes no file when it
2526
+ // refuses — so this closes the last gap rather than papering over one.
2527
+ if (result.ok === false)
2528
+ process.exitCode = 1;
2487
2529
  break;
2488
2530
  }
2489
2531
  case "sealed": {
2490
- if (!rest[0])
2532
+ // Positionals are extracted rather than read off `rest` by index.
2533
+ // `rest[1]` took whatever token followed the artifact, so
2534
+ // `sealed <artifact> --tokens 200` explained a symbol literally named
2535
+ // "--tokens" — and at that budget the fabricated empty explain block
2536
+ // evicted commit, ref and sealedAt, which are the fields an
2537
+ // inspect-only call exists to return (KNODIN-3).
2538
+ //
2539
+ // `extractPositionals` already knows which flags consume the token
2540
+ // after them, so `200` is not mistaken for a symbol either.
2541
+ const positionals = extractPositionals(rest);
2542
+ if (!positionals[0])
2491
2543
  throw new Error("knodin sealed requires an artifact path");
2492
- result = await runSealedQuery(rest[0], rest[1], {
2544
+ result = await runSealedQuery(positionals[0], positionals[1], {
2493
2545
  strictCompat: rest.includes("--strict-compat"),
2494
2546
  });
2547
+ // Same contradiction as `seal`, found while testing the fix for it:
2548
+ // `sealed <missing-artifact>` reported ok: false and exited 0, so a
2549
+ // caller checking $? saw success (KNODIN-4 R2 — audit the other
2550
+ // commands emitting an `ok` field).
2551
+ if (result.ok === false)
2552
+ process.exitCode = 1;
2495
2553
  break;
2496
2554
  }
2497
2555
  case "pack": {
@@ -3038,7 +3096,32 @@ catch (err) {
3038
3096
  error: err,
3039
3097
  });
3040
3098
  const correlation = diagnostic.recorded ? ` [diagnostic ${diagnostic.correlationId}]` : "";
3041
- console.error(`${describeThrown(err)}${correlation}`);
3099
+ const message = describeThrown(err);
3100
+ // `--json` has to mean parseable output on EVERY exit path, not only the
3101
+ // successful one. A bare string here makes a consumer's parser throw a
3102
+ // decode error, which gets triaged as a malformed response or a version
3103
+ // mismatch — while the string it failed to parse already named the cause
3104
+ // (KNODIN-5, found as "attempt to write a readonly database" from
3105
+ // `knodin --json sealed` against a read-only artifact).
3106
+ //
3107
+ // Shaped like the unavailability envelope the graph-backed paths already
3108
+ // return for the same class of condition, so a caller can handle both
3109
+ // surfaces with one branch. Written to stdout because that is where a
3110
+ // caller reading `--json` is looking; the human path keeps stderr.
3111
+ // The envelope is ADDITIONAL, not a replacement. stdout is the JSON
3112
+ // channel and stderr is the human one, so writing the envelope instead of
3113
+ // the message silently emptied stderr for every existing caller that reads
3114
+ // diagnostics there — `configure --json` asserts its refusal on stderr, and
3115
+ // it went blank.
3116
+ if (argv.includes("--json"))
3117
+ process.stdout.write(`${JSON.stringify({
3118
+ available: false,
3119
+ status: "unavailable",
3120
+ state: "unknown",
3121
+ error: message,
3122
+ ...(diagnostic.recorded ? { diagnosticId: diagnostic.correlationId } : {}),
3123
+ })}\n`);
3124
+ console.error(`${message}${correlation}`);
3042
3125
  process.exit(1);
3043
3126
  }
3044
3127
  }
@@ -200,6 +200,30 @@ export function promoteCandidateFile(repo, candidate) {
200
200
  fsyncDirectory(stateDir);
201
201
  fs.rmSync(marker, { force: true });
202
202
  fsyncDirectory(stateDir);
203
+ // The rename above moves the database out and nothing else, and the
204
+ // candidate directory is NOT empty at this point: the clean-index audit
205
+ // writes `status-audit-v1.json` beside the candidate database. So every
206
+ // SUCCESSFUL promotion left a directory behind — and `listCandidates`
207
+ // skips directories without a database, so the only cleanup path could
208
+ // never see them (KNODIN-2). Twelve had accumulated in this repository
209
+ // since 11 Aug and thirty in a reporting checkout, where they were
210
+ // reasonably read as promotions that had failed.
211
+ //
212
+ // Removed after the marker, so an interruption before this point still
213
+ // recovers through the marker rather than losing a candidate it needed.
214
+ //
215
+ // Swallowed rather than left to the enclosing `catch`: the promotion is
216
+ // already committed here — the marker is gone and the new database is
217
+ // fsynced into place — so a failure to delete leftover residue (a locked
218
+ // file, EPERM on a read-only parent) must not reach the rollback below,
219
+ // which would move the just-promoted database back into the candidate
220
+ // directory and restore the old graph over a promotion that succeeded.
221
+ try {
222
+ fs.rmSync(path.dirname(candidate.databasePath), { recursive: true, force: true });
223
+ }
224
+ catch {
225
+ // Residue only. `sweepAbandonedCandidates` collects it on the next run.
226
+ }
203
227
  return fs.existsSync(backupPath) ? backupPath : null;
204
228
  }
205
229
  catch (error) {
@@ -214,6 +238,50 @@ export function promoteCandidateFile(repo, candidate) {
214
238
  throw error;
215
239
  }
216
240
  }
241
+ /**
242
+ * Remove candidate directories that hold no database, and report how many.
243
+ *
244
+ * `listCandidates` deliberately answers "what could be resumed", so it skips a
245
+ * directory with no `db.sqlite`. Cleanup used to run only over that list, which
246
+ * meant the one kind of directory guaranteed to have no database — the residue a
247
+ * successful promotion leaves once the database is renamed out — was the one
248
+ * kind nothing could ever delete (KNODIN-2).
249
+ *
250
+ * Promotion now removes its own directory, so this exists for two other cases:
251
+ * residue already accumulated by earlier versions, and directories left by a
252
+ * process that died between `allocateCandidate` and writing a database.
253
+ *
254
+ * Returns the count rather than nothing so callers can say what they swept
255
+ * instead of tidying up silently.
256
+ */
257
+ export function sweepAbandonedCandidates(repo) {
258
+ const resolvedRepo = path.resolve(repo);
259
+ const root = candidateRoot(resolvedRepo);
260
+ let entries;
261
+ try {
262
+ entries = fs.readdirSync(root, { withFileTypes: true });
263
+ }
264
+ catch {
265
+ return 0;
266
+ }
267
+ let removed = 0;
268
+ for (const entry of entries) {
269
+ if (!entry.isDirectory() || !entry.name.startsWith("candidate-"))
270
+ continue;
271
+ const directory = path.join(root, entry.name);
272
+ if (fs.existsSync(path.join(directory, "db.sqlite")))
273
+ continue;
274
+ try {
275
+ fs.rmSync(directory, { recursive: true, force: true });
276
+ removed++;
277
+ }
278
+ catch {
279
+ // Best effort. Failing to remove residue must not stop the caller's
280
+ // actual work, which is what this is being cleaned up alongside.
281
+ }
282
+ }
283
+ return removed;
284
+ }
217
285
  export function discardCandidateFiles(repo, candidate) {
218
286
  assertCandidate(repo, candidate);
219
287
  fs.rmSync(path.dirname(candidate.databasePath), { recursive: true, force: true });
@@ -28,7 +28,7 @@ import { contentFingerprint, writeStructuralSnapshot, } from "../structural-snap
28
28
  import { acquireLifecycleCoordination } from "../update-coordination.js";
29
29
  import { KNODIN_VERSION } from "../version.js";
30
30
  import * as ann from "./ann-hnsw.js";
31
- import { allocateCandidate, assertCandidate, discardCandidateFiles, listCandidates, promoteCandidateFile, recoverInterruptedPromotion, } from "./candidate-database.js";
31
+ import { allocateCandidate, assertCandidate, discardCandidateFiles, listCandidates, promoteCandidateFile, recoverInterruptedPromotion, sweepAbandonedCandidates, } from "./candidate-database.js";
32
32
  import { computeSimilarity, generateEmbedding, generateEmbeddings, } from "./embeddings.js";
33
33
  import { walkRepoFiles } from "./file-walker.js";
34
34
  import { clearGitHistorySignalCache, collectGitHistorySignals, } from "./git-history.js";
@@ -41,7 +41,7 @@ import { isIndexablePath, makeWatchIgnorePredicate } from "./prune.js";
41
41
  import { reflinkCopyFile } from "./reflink-copy.js";
42
42
  import { readSarifLog, SARIF_DEFAULT_LIMITS, } from "./sarif-import.js";
43
43
  import { readScipIndex, SCIP_DEFAULT_LIMITS, } from "./scip-import.js";
44
- import { listSealedFiles, readSealedSource } from "./seal.js";
44
+ import { isSealedDatabase, listSealedFiles, readSealedSource } from "./seal.js";
45
45
  import { isIndexableSourcePath } from "./source-policy.js";
46
46
  import { Database } from "./sqlite.js";
47
47
  import { lookupMirror, mayWriteToRepository, resolveDbPath, resolveStateDir, } from "./state-paths.js";
@@ -7273,6 +7273,12 @@ function sumCounts(counts) {
7273
7273
  */
7274
7274
  function findResumableCandidate(repoPath) {
7275
7275
  const head = gitHead(repoPath) ?? "";
7276
+ // Swept first, because the loop below can only discard what `listCandidates`
7277
+ // returns and that deliberately excludes database-less directories — which is
7278
+ // exactly what a promoted candidate leaves behind (KNODIN-2). Without this,
7279
+ // the comment above about not accumulating abandoned copies was false for the
7280
+ // most common case.
7281
+ sweepAbandonedCandidates(repoPath);
7276
7282
  let resumable = null;
7277
7283
  for (const candidate of listCandidates(repoPath)) {
7278
7284
  let usable = false;
@@ -7683,8 +7689,54 @@ function recordIndexState(db, repoPath, relPath) {
7683
7689
  return 0;
7684
7690
  }
7685
7691
  }
7692
+ /**
7693
+ * Whether this database is a sealed artifact — one carrying its own source.
7694
+ *
7695
+ * Decided from the seal attestation, which is what `seal` writes to declare an
7696
+ * artifact sealed, rather than from a row count in `sealed_file`: an artifact of
7697
+ * a repository that indexed no files carries no `sealed_file` rows and is still
7698
+ * sealed, and a count answers "does it embed source for anything" instead of
7699
+ * "is this an artifact".
7700
+ *
7701
+ * Cached per database: this is asked once per file during reconciliation, and
7702
+ * the answer cannot change for the life of a connection.
7703
+ */
7704
+ const sealedArtifactDatabases = new WeakMap();
7705
+ function databaseIsSealedArtifact(db) {
7706
+ const known = sealedArtifactDatabases.get(db);
7707
+ if (known !== undefined)
7708
+ return known;
7709
+ // `isSealedDatabase` returns false for an ordinary working graph, including
7710
+ // one with no `meta` table at all, so no extra guard is needed here.
7711
+ const sealed = isSealedDatabase(db);
7712
+ sealedArtifactDatabases.set(db, sealed);
7713
+ return sealed;
7714
+ }
7715
+ /**
7716
+ * Whether a file missing from disk should be purged from this graph.
7717
+ *
7718
+ * For a working checkout, purging is right — the file really was deleted. For an
7719
+ * artifact whose source was materialized from `sealed_source`, a missing file
7720
+ * means the extraction was incomplete, not that the repository changed, and
7721
+ * purging silently rewrote the graph to match a truncated tree: 858 index_state
7722
+ * rows fell to 627 on one query while `sealed_file` still claimed 858, leaving
7723
+ * the artifact contradicting itself and answering confidently from the smaller
7724
+ * graph (KNODIN-6).
7725
+ *
7726
+ * Keeping every file-owned row makes the discrepancy visible instead: health
7727
+ * reports the file as indexed-but-missing, which is the truth, and a symbol
7728
+ * query against it can be told apart from a symbol that genuinely has no
7729
+ * callers. Guarding only the `index_state` row would be worse than not guarding
7730
+ * at all — the symbols, references and dependencies would still be gone, and
7731
+ * `index_state` would then claim coverage the graph no longer has.
7732
+ */
7733
+ function shouldPurgeMissingFile(db) {
7734
+ return !databaseIsSealedArtifact(db);
7735
+ }
7686
7736
  /** Drop a file's `index_state` row (used when a file is deleted). */
7687
7737
  function removeIndexState(db, relPath) {
7738
+ if (!shouldPurgeMissingFile(db))
7739
+ return;
7688
7740
  try {
7689
7741
  db.run("DELETE FROM index_state WHERE filePath = ?", [relPath]);
7690
7742
  }
@@ -7854,7 +7906,7 @@ async function reconcileIndex(repoPath, db, progress, skipEmbeddings = false) {
7854
7906
  reindexedPaths.push(rel);
7855
7907
  }
7856
7908
  }
7857
- else {
7909
+ else if (shouldPurgeMissingFile(db)) {
7858
7910
  // Deletion or source-policy retirement — purge every file-owned row.
7859
7911
  db.run("BEGIN TRANSACTION;");
7860
7912
  try {
@@ -8524,6 +8576,8 @@ function startFileWatcher(repoPath, db, watcherFileLimit = MAX_RECURSIVE_WATCH_F
8524
8576
  };
8525
8577
  watchQueues.set(resolvedRepoPath, queue);
8526
8578
  const purgeFile = (relativePath) => {
8579
+ if (!shouldPurgeMissingFile(db))
8580
+ return;
8527
8581
  db.run("BEGIN TRANSACTION;");
8528
8582
  try {
8529
8583
  deleteSymbolsForFile(db, relativePath);
@@ -11901,7 +11955,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
11901
11955
  await indexFile(absolute, file, resolved, db);
11902
11956
  recordIndexState(db, resolved, file);
11903
11957
  }
11904
- else {
11958
+ else if (shouldPurgeMissingFile(db)) {
11905
11959
  db.run("BEGIN TRANSACTION;");
11906
11960
  try {
11907
11961
  deleteSymbolsForFile(db, file);
@@ -13866,7 +13920,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
13866
13920
  await indexFile(absPath, relativePath, repoPath, db);
13867
13921
  recordIndexState(db, repoPath, relativePath);
13868
13922
  }
13869
- else {
13923
+ else if (shouldPurgeMissingFile(db)) {
13870
13924
  // File was deleted
13871
13925
  db.run("BEGIN TRANSACTION;");
13872
13926
  try {
package/dist/src/init.js CHANGED
@@ -20,7 +20,14 @@ export class InitializationHealthError extends Error {
20
20
  constructor(indexResult) {
21
21
  const firstIssue = indexResult.verification.missing.files[0] ?? indexResult.verification.missing.records[0];
22
22
  const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
23
- super(`knodin init: lifecycle hooks were installed, but ${indexResult.verification.issueCount.toLocaleString()} graph issue(s) remain.${detail} Run \`knodin repair\`.`);
23
+ // Describes whichever condition actually gated, for the same reason
24
+ // `formatIndexVerificationError` does: printing the issue count
25
+ // unconditionally produced "0 graph issue(s) remain" as a failure reason —
26
+ // a sentence that reports no problem and then recommends repair (KNODIN-7).
27
+ const condition = indexResult.verification.issueCount > 0
28
+ ? `${indexResult.verification.issueCount.toLocaleString()} graph issue(s) remain`
29
+ : `graph verification reported status "${indexResult.verification.status}"`;
30
+ super(`knodin init: lifecycle hooks were installed, but ${condition}.${detail} Run \`knodin repair\`.`);
24
31
  this.name = "InitializationHealthError";
25
32
  this.indexResult = indexResult;
26
33
  }
@@ -1385,9 +1392,16 @@ export async function initializeRepository(repo, options) {
1385
1392
  // Only a full index has verified the whole graph, so only a full index
1386
1393
  // may fail the run on graph health. A configuration-scoped pass saw a
1387
1394
  // handful of files and knows nothing about the rest.
1395
+ // Gated on damage, not on `status !== "healthy"` — the same correction
1396
+ // `knodin index` needed (KNODIN-7). A freshly written graph is routinely
1397
+ // `stale` because freshness has not been probed since the index wrote,
1398
+ // and an untracked file makes that the normal outcome, so the old
1399
+ // condition failed `knodin init` on the very first run in a new
1400
+ // repository while its own verification had found nothing wrong.
1388
1401
  if (!scopedIndexPaths &&
1389
1402
  isIndexResult(indexResult) &&
1390
- indexResult.verification.status !== "healthy") {
1403
+ (indexResult.verification.status === "repair-needed" ||
1404
+ indexResult.verification.issueCount > 0)) {
1391
1405
  throw new InitializationHealthError(indexResult);
1392
1406
  }
1393
1407
  }
@@ -0,0 +1,172 @@
1
+ # knodin 0.10.5
2
+
3
+ Six fixes, all found by treating knodin's own artifacts as something another
4
+ program has to consume. A verifier team spent a day characterizing sealed
5
+ artifacts and the `--repo` query path; every defect below came out of measuring
6
+ state before and after an operation rather than reading the code.
7
+
8
+ The theme is the one 0.10.2 through 0.10.4 have been working on: **a result that
9
+ looks complete while quietly being wrong**, and a command that reports success it
10
+ has not earned.
11
+
12
+ ## Candidate directories no longer accumulate
13
+
14
+ A checkout had thirty candidate directories under `.knodin/candidates/` going
15
+ back weeks, none promoted, while `knodin repair` exited 0. It read as clean
16
+ indexes silently failing to promote. This repository had twelve of its own.
17
+
18
+ **They were the residue of successful promotions, not failures.** Three facts
19
+ composed:
20
+
21
+ - Promotion renames the database out and leaves the directory.
22
+ - The directory is not empty: the clean-index audit writes
23
+ `status-audit-v1.json` beside the candidate database, so something always
24
+ remains after the database leaves.
25
+ - `listCandidates` skips directories without a database — correctly, since it
26
+ answers "what can be resumed" — and it is the only input to the sole cleanup
27
+ path. So the one kind of directory guaranteed to have no database was the one
28
+ kind nothing could ever delete.
29
+
30
+ Promotion now removes its own directory, and a sweep clears residue left by
31
+ earlier versions and by processes that died before writing a database. A
32
+ resumable candidate — one that still holds a database — is untouched, so
33
+ 0.10.3's resumable clean index is unaffected.
34
+
35
+ If you have accumulated directories, the next clean index removes them.
36
+
37
+ ## `seal` and `sealed` exit non-zero when they refuse
38
+
39
+ Both reported `ok: false` and exited **0**. The exit code carried no
40
+ information at all:
41
+
42
+ ```
43
+ $ knodin seal --output artifact.sqlite # in a directory with no index
44
+ seal:
45
+ ok: false
46
+ code: database-missing
47
+ $ echo $?
48
+ 0
49
+ ```
50
+
51
+ So `knodin seal --output X && upload X` ran the upload after a refusal. `sealed`
52
+ had the same contradiction on a missing artifact.
53
+
54
+ Both now exit non-zero when they report `ok: false`. Worth stating what was
55
+ already right: `seal` fails closed on content — it writes no file when it
56
+ refuses, so nothing broken was ever shipped by this. The failure simply arrived
57
+ one step late and attributed to the wrong command.
58
+
59
+ ## `--json` produces JSON on failure, not a bare string
60
+
61
+ `knodin --json sealed <artifact> <symbol>` against a read-only artifact printed:
62
+
63
+ ```
64
+ attempt to write a readonly database
65
+ ```
66
+
67
+ Not JSON. A consumer's parser throws a decode error, which gets triaged as a
68
+ malformed response or a version mismatch — while the string it failed to parse
69
+ already named the cause.
70
+
71
+ Thrown failures under `--json` now emit an envelope shaped like the one the
72
+ graph-backed paths already return for the same class of condition, so a caller
73
+ can handle both surfaces with one branch:
74
+
75
+ ```json
76
+ {"available": false, "status": "unavailable", "state": "unknown",
77
+ "error": "attempt to write a readonly database"}
78
+ ```
79
+
80
+ Without `--json` the human message on stderr is unchanged.
81
+
82
+ ## `sealed` no longer mistakes a flag for a symbol
83
+
84
+ `knodin sealed <artifact> --tokens 200` explained a symbol literally named
85
+ `--tokens`, because the symbol was read positionally as "whatever followed the
86
+ artifact". Under a tight budget the fabricated empty explain block then evicted
87
+ `commit`, `ref` and `sealedAt` — the fields an inspect-only call exists to
88
+ return.
89
+
90
+ Arguments are now extracted with the shared parser, which already knows which
91
+ flags consume the token after them, so neither `--tokens` nor its value `200` is
92
+ taken as a symbol.
93
+
94
+ ## A sealed artifact is no longer silently rewritten to match a truncated tree
95
+
96
+ The sharpest finding. A sealed artifact can be materialized — its embedded
97
+ source extracted next to it — and queried through `--repo`. If that extraction
98
+ is incomplete, knodin reconciled the graph to match:
99
+
100
+ ```
101
+ index_state: 858 → 627 persisted to disk
102
+ symbols: 5358 → 4791 567 symbols destroyed
103
+ sealed_file: 858 → 858 the artifact's own file list, untouched
104
+ ```
105
+
106
+ The artifact ends up **contradicting itself** and still answering confidently.
107
+ Querying a symbol that lived only in the missing files returned exit 0, an empty
108
+ result and a null identity — indistinguishable from a symbol that genuinely has
109
+ no callers. A file truncated mid-content behaved identically.
110
+
111
+ For a working checkout, pruning rows for deleted files is right: the files really
112
+ were deleted. For an artifact carrying its own source, a missing file means the
113
+ extraction was incomplete, not that the repository changed.
114
+
115
+ Every file-owned row — `index_state`, symbols, references, dependencies — is now
116
+ preserved on a database carrying a seal attestation. Health reports those files
117
+ as indexed-but-missing, which is the truth, instead of quietly forgetting them.
118
+
119
+ Preserving `index_state` alone would have been worse than preserving nothing: the
120
+ 567 symbols in the table above would still have been destroyed, and `index_state`
121
+ would then have claimed coverage the graph no longer had.
122
+
123
+ ## The first clean index of a repository no longer fails
124
+
125
+ Found while building a test fixture for the fix above, and it affects every new
126
+ checkout. With any untracked file present, the **first** `knodin index --clean`
127
+ exited 1 — after its own progress output said the work succeeded:
128
+
129
+ ```
130
+ [index:verifying] Promoted graph has 0 issue(s)
131
+ [index:completed] Clean index completed
132
+ knodin index: requested work completed, but 0 graph issue(s) remain. Run `knodin repair`.
133
+ ```
134
+
135
+ Zero issues, and a failure — recommending repair for a graph it had just
136
+ verified as undamaged. The second run exited 0, so the first run's work had been
137
+ fine all along.
138
+
139
+ The gate read `status !== "healthy"` while the message printed an issue count.
140
+ An untracked file makes freshness `stale-working-tree` and verification `stale`,
141
+ so a correct index was rejected for a fact about the working tree.
142
+
143
+ Failure is now gated on damage — `repair-needed`, or a non-zero issue count — and
144
+ the message names whichever condition actually gated instead of printing a count
145
+ that contradicts it. `--json` also emits the result on this path, which
146
+ previously wrote zero bytes to stdout.
147
+
148
+ `knodin init` carried the identical gate and the identical message, and it is the
149
+ first command anyone runs in a new repository — so it is corrected the same way.
150
+
151
+ Anything running `knodin init` or `knodin index --clean && …` on a fresh checkout
152
+ was failing on the first run, every time.
153
+
154
+ ## Known, not fixed here
155
+
156
+ - **Querying through `--repo` still writes to the database**, so a read-only
157
+ mount cannot be used. It fails loudly — `attempt to write a readonly database`
158
+ — rather than silently. `sealed` does not mutate the artifact at all, but still
159
+ requires a writable file for symbol queries.
160
+ - **Sealing the same commit twice produces different bytes**; `sealedAt` is the
161
+ only differing field, so a hash identifies an upload rather than a commit.
162
+ - **`sealAttestation` embeds the sealer's absolute local path**, git remote URL
163
+ and current branch, which travel with any artifact that crosses a trust
164
+ boundary.
165
+
166
+ The last three are recorded on KNODIN-6.
167
+
168
+ ## Compatibility
169
+
170
+ Compatible. No interface is removed or renamed. Exit codes change on paths that
171
+ previously reported success incorrectly — a script relying on `seal` exiting 0
172
+ after a refusal will now see the failure, which is the point.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knodin",
3
- "version": "0.10.4",
3
+ "version": "0.10.5",
4
4
  "knodin": {
5
5
  "compatibility": "compatible"
6
6
  },
@@ -76,6 +76,7 @@
76
76
  "docs/releases/0.10.2.md",
77
77
  "docs/releases/0.10.3.md",
78
78
  "docs/releases/0.10.4.md",
79
+ "docs/releases/0.10.5.md",
79
80
  "docs/assets/knodin-favicon.svg",
80
81
  "docs/SYSTEMS-AND-RELATIONSHIPS.md",
81
82
  "docs/TELEMETRY.md",