From 2982b89fa36132e4c958278f672c970cf92617cf Mon Sep 17 00:00:00 2001 From: reference-week Date: Sat, 5 Sep 2026 08:21:01 +0200 Subject: [PATCH] fix(cli): a journal states the schema it was written in, and the reader reads it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook stamps `schema_version` on every `session_start` it writes (`record.cjs`'s own `SCHEMA_VERSION`, 2). The reader dropped the field on the floor: `parseSessionStart` never looked at it, and `RunJournalSessionStart` had nowhere to put it. So a journal written under a schema whose line shapes had changed was read as if it were this one — a silent misreading, which is the one thing a version field exists to prevent. The reader now carries the stated schema and refuses a journal stating one it does not read. Version 1 was a mutable record rather than this append-only line log, so its lines are another shape entirely, and a later version can change any line the same way; reading either would mean guessing that whatever lines this parser still recognises mean what they used to. Absence is not a disagreement. Every journal written before this reader looked at the field states no schema, and refusing those would drop attribution this reader has always given — "an unknown is never a zero", applied to the reader rather than to a figure. A refusal must not cost the fact that the file is there. Dropped from `list()` alone, a refused journal leaves `check` to fall through to a branch that is false about it: "the recorder is declared nowhere" claims no file exists, and "none carry a readable session_start" blames a torn write for a version disagreement. `RunJournalReader.listForeignSchemas` carries the stated versions across, and `hook-fired` gains `journal-in-another-schema`, read ahead of the anchorless one — a build that cannot read a journal's schema cannot tell a missing `session_start` from one shaped differently. Mutations run, all killed: the reader no longer refusing, absence read as a disagreement, and the new reason ordered after the anchorless one. Bundle budget 596 -> 598 KB, measured 594.3 -> 595.8, recorded beside the three raises before it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- cli/package.json | 2 +- cli/scripts/check-bundle-size.mjs | 3 + .../telemetry/diagnose-telemetry-use-case.ts | 1 + cli/src/domain/models/telemetry-claim.ts | 36 +++++- cli/src/domain/ports/run-journal-reader.ts | 13 ++ .../adapters/run-journal-reader-adapter.ts | 66 +++++++++- .../models/telemetry-claim.unit.test.ts | 40 ++++++ .../ports/in-memory-run-journal-reader.ts | 8 ++ cli/tests/helpers/telemetry-journal-hook.ts | 4 + ...journal-reader-adapter.integration.test.ts | 114 +++++++++++++++++- .../skills/02-check/actions/02-diagnose.md | 6 + 11 files changed, 284 insertions(+), 9 deletions(-) diff --git a/cli/package.json b/cli/package.json index ec6dfacea..a9b00d0cd 100644 --- a/cli/package.json +++ b/cli/package.json @@ -44,7 +44,7 @@ "qs": ">=6.15.2" } }, - "bundleBudgetKB": 596, + "bundleBudgetKB": 598, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index b23b8364a..d8298e3d3 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -16,6 +16,9 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // 596 was set when `by_agent` learned to tell a main thread from a tool that never // names an agent: measured 592.0 -> 593.8 KB across two changes, the flow axis's own // tool-stated row included. Same 2.2 KB headroom the raise before it left. +// 598 was set when the journal reader began reading the schema a journal states it was +// written under, and the diagnostic gained the reason for refusing one: measured +// 594.3 -> 595.8 KB. Same 2.2 KB headroom as the two raises before it. const budgetKB = pkg.bundleBudgetKB ?? 500; const budgetBytes = budgetKB * 1024; diff --git a/cli/src/application/use-cases/telemetry/diagnose-telemetry-use-case.ts b/cli/src/application/use-cases/telemetry/diagnose-telemetry-use-case.ts index b6d363ffe..a066c6021 100644 --- a/cli/src/application/use-cases/telemetry/diagnose-telemetry-use-case.ts +++ b/cli/src/application/use-cases/telemetry/diagnose-telemetry-use-case.ts @@ -266,6 +266,7 @@ export class DiagnoseTelemetryUseCase { hookTrust, recorderDeclared: recorderDeclaration.declared, recorderDeclarationReadable: recorderDeclaration.unreadable.length === 0, + foreignSchemaVersions: await this.runJournalReader.listForeignSchemas(), }; } diff --git a/cli/src/domain/models/telemetry-claim.ts b/cli/src/domain/models/telemetry-claim.ts index aa2b51f2c..ddb113b28 100644 --- a/cli/src/domain/models/telemetry-claim.ts +++ b/cli/src/domain/models/telemetry-claim.ts @@ -46,7 +46,8 @@ export type NoRunFileReason = | "recorder-declared-nowhere" | "recorder-declared-not-yet-fired" | "recorder-declaration-unreadable" - | "anchorless-run-file"; + | "anchorless-run-file" + | "journal-in-another-schema"; export type TelemetryClaimReason = | "session-anchored" @@ -132,6 +133,12 @@ export interface TelemetryEvidence { * `recorderDeclared` is `true`: a declaration found at one readable location is real * regardless of what else could not be read. */ readonly recorderDeclarationReadable: boolean; + /** The schema stated by every run file the journal reader refused, from + * `RunJournalReader.listForeignSchemas`. Carried separately from `journals` because a + * refused file is absent from that list while being present on disk: without this, the + * one fact actually known about it — that it states a schema this build does not read — + * is invisible, and the claim below falls through to a branch that is false about it. */ + readonly foreignSchemaVersions: readonly number[]; } function sessionJournalsOf( @@ -243,14 +250,36 @@ function anchorlessRunFileClaim(runsDirLabel: string, fileCount: number): Teleme }; } +// The schema a journal states is the writer's own statement about its shape, so a build +// that does not read that schema knows exactly one thing about the file: not what its lines +// mean. Ahead of `anchorlessRunFileClaim` for that reason — "none carry a readable +// session_start" is a claim about the file's contents, which is the claim this build has +// just said it cannot make. +function foreignSchemaClaim(runsDirLabel: string, stated: readonly number[]): TelemetryClaim { + const versions = [...new Set(stated)].sort((left, right) => left - right).join(", "); + return { + claim: "hook-fired", + verdict: "fail", + reason: "journal-in-another-schema", + detail: + `${stated.length} run file(s) in ${runsDirLabel} written under a schema this build does ` + + `not read (${versions}) — a journal from another version of the plugin, never a hook ` + + "that did not fire", + }; +} + function noRunFileClaim( runsDirLabel: string, hookTrust: TelemetryCodexHookTrust | undefined, recorderDeclared: boolean, recorderDeclarationReadable: boolean, - anchorlessFileCount: number + anchorlessFileCount: number, + foreignSchemaVersions: readonly number[] ): TelemetryClaim { if (hookTrust && trustExplainsAbsence(hookTrust)) return untrustedHookClaim(hookTrust); + if (foreignSchemaVersions.length > 0) { + return foreignSchemaClaim(runsDirLabel, foreignSchemaVersions); + } if (anchorlessFileCount > 0) return anchorlessRunFileClaim(runsDirLabel, anchorlessFileCount); if (!recorderDeclarationReadable) return recorderDeclarationUnreadableClaim(runsDirLabel); if (recorderDeclared) return recorderDeclaredNotYetFiredClaim(runsDirLabel); @@ -308,7 +337,8 @@ function noSessionJournalClaim(evidence: TelemetryEvidence): TelemetryClaim { hookTrust, evidence.recorderDeclared, evidence.recorderDeclarationReadable, - journals.length + journals.length, + evidence.foreignSchemaVersions ); } diff --git a/cli/src/domain/ports/run-journal-reader.ts b/cli/src/domain/ports/run-journal-reader.ts index f5043a352..ee77f7b60 100644 --- a/cli/src/domain/ports/run-journal-reader.ts +++ b/cli/src/domain/ports/run-journal-reader.ts @@ -48,6 +48,12 @@ export type RunJournalBoundary = RunJournalStepStart | RunJournalTurnEnd | RunJo export interface RunJournalSessionStart { readonly type: "session_start"; readonly at: string; + /** The schema the hook stamped this journal with, absent for a journal written before + * this reader looked at the field. Read and carried, never derived: which schema a file + * was written under is the writer's statement about it, and a reader that infers one + * from the shapes it happens to recognise is exactly the silent misreading the field + * exists to prevent. */ + readonly schema_version?: number; readonly run_id: string; readonly tool: string; readonly vendor_id: string; @@ -143,6 +149,13 @@ export interface RunJournalReader { * throws; a missing or unreadable runs directory answers an empty list, the same * failure direction as `list()`. */ listRunFiles(): Promise; + /** The schema stated by every journal this reader refused to read, one entry per file. + * `list()` drops such a journal outright — reading it would mean guessing that whatever + * lines this parser still recognises mean what they used to — and a caller shown only + * that emptiness would report a missing or torn file about one whose header it parsed + * perfectly well. Empty is the ordinary answer: every journal on disk states the schema + * this build reads, or states none at all. Never throws, like everything else here. */ + listForeignSchemas(): Promise; } /** diff --git a/cli/src/infrastructure/adapters/run-journal-reader-adapter.ts b/cli/src/infrastructure/adapters/run-journal-reader-adapter.ts index 33f1a823d..20b4b893b 100644 --- a/cli/src/infrastructure/adapters/run-journal-reader-adapter.ts +++ b/cli/src/infrastructure/adapters/run-journal-reader-adapter.ts @@ -12,6 +12,19 @@ import type { import { isBareFileName } from "../confined-file-name.js"; import { repositoryRootAbove } from "../repository-root.js"; +/** + * The one schema this reader knows how to read, mirroring `record.cjs`'s own + * `SCHEMA_VERSION` — the same kind of mirror `sanitizePathSegment` above is, and pinned the + * same way: `run-journal-reader-adapter.integration.test.ts` compares this against the + * hook's own exported constant rather than against a second copy of the number. + * + * Version 1 was a mutable record, not this append-only line log, so its lines are another + * shape entirely; a later version can change any line's shape the same way. A journal + * stating either is refused rather than read, since reading it would mean guessing that + * whatever lines this parser still recognises mean what they used to. + */ +export const READABLE_JOURNAL_SCHEMA_VERSION = 2; + const ULID_LENGTH = 26; // encodeTime(10) + encodeRandom(16), matching record.cjs's own ULID_LENGTH. const RUN_FILE_EXTENSION = ".jsonl"; @@ -41,6 +54,21 @@ function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** Whether a journal says outright that it was written under some other schema. Absence is + * never that statement: every journal on disk before this reader looked at the field + * carries none, and refusing those would drop attribution this reader has always given — + * "an unknown is never a zero", applied to the reader rather than to a figure. A value that + * is not a finite number is read as absent for the same reason, since a torn or hand-edited + * field states nothing either. */ +function statesAnotherSchema(session: RunJournalSessionStart | undefined): boolean { + const stated = session?.schema_version; + return stated !== undefined && stated !== READABLE_JOURNAL_SCHEMA_VERSION; +} + interface RawJournalLine { readonly type?: unknown; readonly at?: unknown; @@ -55,6 +83,7 @@ interface RawJournalLine { readonly worktree_repo_id?: unknown; readonly path?: unknown; readonly plugin_version?: unknown; + readonly schema_version?: unknown; } function parseLine(line: string): RawJournalLine | null { @@ -110,15 +139,27 @@ function parseSessionStart(parsed: RawJournalLine): RunJournalSessionStart | nul if (at === undefined || runId === undefined || tool === undefined || vendorId === undefined) { return null; } - const projectId = asString(parsed.project_id); - const projectRemote = asString(parsed.project_remote); - const pluginVersion = asString(parsed.plugin_version); return { type: "session_start", at, run_id: runId, tool, vendor_id: vendorId, + ...headerExtras(parsed), + }; +} + +/** Every header field a journal may state and may omit — each absent rather than defaulted, + * the same rule `parseWorktree` above already follows: a field the writer left out is one + * this reader has nothing to say about, and a default would be an answer nobody wrote. Split + * out of `parseSessionStart` so that function stays under the line-count limit. */ +function headerExtras(parsed: RawJournalLine): Partial { + const projectId = asString(parsed.project_id); + const projectRemote = asString(parsed.project_remote); + const pluginVersion = asString(parsed.plugin_version); + const schemaVersion = asNumber(parsed.schema_version); + return { + ...(schemaVersion === undefined ? {} : { schema_version: schemaVersion }), ...(projectId === undefined ? {} : { project_id: projectId }), ...(projectRemote === undefined ? {} : { project_remote: projectRemote }), ...parseWorktree(parsed), @@ -222,6 +263,17 @@ export class RunJournalReaderAdapter implements RunJournalStore { return journals; } + async listForeignSchemas(): Promise { + const stated: number[] = []; + for (const fileName of await this.listRunFiles()) { + const collector = await this.collect(join(this.runsDir, fileName)); + const version = collector?.session?.schema_version; + if (version !== undefined && version !== READABLE_JOURNAL_SCHEMA_VERSION) + stated.push(version); + } + return stated; + } + async listRunFiles(): Promise { try { const entries = await readdir(this.runsDir); @@ -257,7 +309,7 @@ export class RunJournalReaderAdapter implements RunJournalStore { return match ? join(dir, match) : null; } - private async readJournal(filePath: string): Promise { + private async collect(filePath: string): Promise { let content: string; try { content = await readFile(filePath, "utf8"); @@ -269,6 +321,12 @@ export class RunJournalReaderAdapter implements RunJournalStore { const parsed = parseLine(line); if (parsed) classifyLine(collector, parsed); } + return collector; + } + + private async readJournal(filePath: string): Promise { + const collector = await this.collect(filePath); + if (!collector || statesAnotherSchema(collector.session)) return null; const { boundaries, filesWritten, taskDeclarations, session } = collector; return { boundaries, filesWritten, taskDeclarations, ...(session ? { session } : {}) }; } diff --git a/cli/tests/domain/models/telemetry-claim.unit.test.ts b/cli/tests/domain/models/telemetry-claim.unit.test.ts index 609459f0c..efbfba5e0 100644 --- a/cli/tests/domain/models/telemetry-claim.unit.test.ts +++ b/cli/tests/domain/models/telemetry-claim.unit.test.ts @@ -34,6 +34,9 @@ function evidence(overrides: Partial = {}): TelemetryEvidence // Readable by default — a clean machine where the declaration itself was never in // question. A test for the "could not be read" branch sets this to `false` explicitly. recorderDeclarationReadable: true, + // Empty by default: every journal on disk states the schema this build reads, or states + // none. A test for the disagreement sets it explicitly. + foreignSchemaVersions: [], ...overrides, }; } @@ -104,6 +107,38 @@ describe("diagnoseTelemetryClaims — hook fired", () => { expect(hookFired?.detail).not.toMatch(/declared nowhere/u); }); + // A journal the reader refused for stating a schema it does not read leaves `journals` + // empty, which every other branch here reads as "no run file". Two of them would then be + // outright false about a file that demonstrably exists and whose header parsed perfectly: + // "declared nowhere" claims no file, and "none carry a readable session_start" blames a + // torn write. The version disagreement is the fact that is actually known. + it("names a journal written under another schema, never a torn write or a missing file", () => { + const result = diagnoseTelemetryClaims( + evidence({ currentSessionId: "s-1", foreignSchemaVersions: [3] }) + ); + const hookFired = claim(result, "hook-fired"); + expect(hookFired?.verdict).toBe("fail"); + expect(hookFired?.reason).toBe("journal-in-another-schema"); + expect(hookFired?.detail).toContain("3"); + expect(hookFired?.detail).not.toMatch(/declared nowhere/u); + expect(hookFired?.detail).not.toMatch(/none carry a readable session_start/u); + }); + + // Ahead of the anchorless reading, deliberately: a build that cannot read a journal's + // schema cannot tell whether its session_start is missing or merely shaped differently, + // so blaming a torn write would be asserting what it just said it cannot see. + it("prefers the schema disagreement over an anchorless file when both are present", () => { + const result = diagnoseTelemetryClaims( + evidence({ + currentSessionId: "s-1", + journals: [journal({ vendorId: undefined })], + foreignSchemaVersions: [3], + }) + ); + + expect(claim(result, "hook-fired")?.reason).toBe("journal-in-another-schema"); + }); + it("names this session as having left no run file when an older one exists but not its own", () => { const result = diagnoseTelemetryClaims( evidence({ @@ -537,6 +572,11 @@ describe("the diagnostic skill's account of every no-run-file reason matches the phrase: "none carry a readable session_start", evidenceOverrides: { currentSessionId: "s-1", journals: [journal({ vendorId: undefined })] }, }, + "journal-in-another-schema": { + verdict: "fail", + phrase: "written under a schema this build does not read", + evidenceOverrides: { currentSessionId: "s-1", foreignSchemaVersions: [3] }, + }, }; // The bullet mentioning `phrase`, bounded by the nearest period on either side — one diff --git a/cli/tests/helpers/ports/in-memory-run-journal-reader.ts b/cli/tests/helpers/ports/in-memory-run-journal-reader.ts index 719a18483..e6ecda01f 100644 --- a/cli/tests/helpers/ports/in-memory-run-journal-reader.ts +++ b/cli/tests/helpers/ports/in-memory-run-journal-reader.ts @@ -11,6 +11,9 @@ import type { RunJournal, RunJournalStore } from "../../../src/domain/ports/run- export class InMemoryRunJournalReader implements RunJournalStore { readonly runsDir = "/fake/project/aidd_docs/runs"; runFileNames: string[] = []; + /** Settable directly, like `runFileNames` and for the same reason: a refused journal is + * one `list()` never returns, so it cannot be derived from what this double holds. */ + foreignSchemaVersions: number[] = []; readonly deletedFiles: string[] = []; readonly deletedFromDirs: string[] = []; readonly undeletable = new Set(); @@ -32,6 +35,10 @@ export class InMemoryRunJournalReader implements RunJournalStore { return this.runFileNames; } + async listForeignSchemas(): Promise { + return this.foreignSchemaVersions; + } + async deleteRunFile(dir: string, fileName: string): Promise { if (this.undeletable.has(fileName)) throw new Error(`cannot delete ${fileName}`); this.deletedFromDirs.push(dir); @@ -47,5 +54,6 @@ export const NULL_RUN_JOURNAL_READER: RunJournalStore = { read: async () => null, list: async () => [], listRunFiles: async () => [], + listForeignSchemas: async () => [], deleteRunFile: async () => {}, }; diff --git a/cli/tests/helpers/telemetry-journal-hook.ts b/cli/tests/helpers/telemetry-journal-hook.ts index d7066d1d3..680e61f08 100644 --- a/cli/tests/helpers/telemetry-journal-hook.ts +++ b/cli/tests/helpers/telemetry-journal-hook.ts @@ -29,6 +29,10 @@ export const journalRepo: JournalRepoModule = createRequire(import.meta.url)( interface JournalRecordModule { codexSessionIdFromTranscriptPath(transcriptPath: unknown): string | undefined; readSessionId(host: string, payload: Record): string | undefined; + /** The schema the hook stamps on every `session_start` it writes. Reached rather than + * copied so the reader's own notion of which schema it can read is pinned against the + * writer's, not against a second constant that can drift from it silently. */ + SCHEMA_VERSION: number; } export const journalRecord: JournalRecordModule = createRequire(import.meta.url)( diff --git a/cli/tests/infrastructure/adapters/run-journal-reader-adapter.integration.test.ts b/cli/tests/infrastructure/adapters/run-journal-reader-adapter.integration.test.ts index fd7252af1..1efa5be7b 100644 --- a/cli/tests/infrastructure/adapters/run-journal-reader-adapter.integration.test.ts +++ b/cli/tests/infrastructure/adapters/run-journal-reader-adapter.integration.test.ts @@ -3,10 +3,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + READABLE_JOURNAL_SCHEMA_VERSION, RunJournalReaderAdapter, sanitizePathSegment, } from "../../../src/infrastructure/adapters/run-journal-reader-adapter.js"; -import { journalRepo } from "../../helpers/telemetry-journal-hook.js"; +import { journalRecord, journalRepo } from "../../helpers/telemetry-journal-hook.js"; // A real-shaped ULID (26 Crockford-base32 characters), matching what // plugins/aidd-telemetry/hooks/lib/record.cjs's generateUlid mints — the adapter splits a @@ -219,6 +220,7 @@ describe("RunJournalReaderAdapter, beyond the boundaries", () => { expect((await adapter.read(SESSION_ID))?.session).toEqual({ type: "session_start", at: "2026-08-20T09:59:00Z", + schema_version: 2, run_id: RUN_ID, project_id: "acme-widgets", project_remote: "github.com/acme/widgets", @@ -411,3 +413,113 @@ describe("RunJournalReaderAdapter.deleteRunFile — confined to the directory it await rm(elsewhere, { recursive: true, force: true }); }); }); + +/** The hook stamps `schema_version` on every `session_start` it writes, and until now this + * reader dropped it — so a journal written under a schema whose line shapes had changed was + * read as if it were this one, which is a silent misreading rather than a refusal. */ +describe("RunJournalReaderAdapter — the schema a journal states it was written under", () => { + let projectRoot: string; + let runsDir: string; + + beforeEach(async () => { + projectRoot = await mkdtemp(join(tmpdir(), "aidd-run-journal-schema-")); + runsDir = join(projectRoot, "aidd_docs", "runs"); + await mkdir(runsDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); + }); + + function header(extra: Record): Record { + return { + type: "session_start", + at: "2026-08-20T09:59:00Z", + run_id: RUN_ID, + tool: "claude-code", + vendor_id: SESSION_ID, + ...extra, + }; + } + + async function writeJournal(...lines: readonly unknown[]): Promise { + await writeFile(join(runsDir, `${RUN_ID}__${SESSION_ID}.jsonl`), runFileLines(...lines)); + } + + // Reached rather than copied: a reader whose own constant is a second copy of the writer's + // goes on claiming it can read a schema the writer has already moved past. + it("reads exactly the schema the hook writes", () => { + expect(READABLE_JOURNAL_SCHEMA_VERSION).toBe(journalRecord.SCHEMA_VERSION); + }); + + it("carries the stated schema through onto the session it read", async () => { + await writeJournal(header({ schema_version: READABLE_JOURNAL_SCHEMA_VERSION })); + const adapter = new RunJournalReaderAdapter(projectRoot); + + const journal = await adapter.read(SESSION_ID); + + expect(journal?.session?.schema_version).toBe(READABLE_JOURNAL_SCHEMA_VERSION); + }); + + // The point of a version field: a key added under the schema this reader knows is a key it + // may ignore, never one that costs it the journal. + it("reads a journal carrying a key it has never heard of, under a schema it knows", async () => { + await writeJournal( + header({ schema_version: READABLE_JOURNAL_SCHEMA_VERSION, a_key_from_later: "ignored" }), + { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-dev:02-implement" } + ); + const adapter = new RunJournalReaderAdapter(projectRoot); + + const journal = await adapter.read(SESSION_ID); + + expect(journal?.session?.vendor_id).toBe(SESSION_ID); + expect(journal?.boundaries).toHaveLength(1); + }); + + it("refuses a journal written under a schema newer than the one it reads", async () => { + await writeJournal(header({ schema_version: READABLE_JOURNAL_SCHEMA_VERSION + 1 }), { + type: "step_start", + at: "2026-08-20T10:00:00Z", + skill: "aidd-dev:02-implement", + }); + const adapter = new RunJournalReaderAdapter(projectRoot); + + expect(await adapter.read(SESSION_ID)).toBeNull(); + expect(await adapter.list()).toEqual([]); + }); + + it("refuses one written under the schema this log replaced, whose lines are another shape", async () => { + await writeJournal(header({ schema_version: 1 })); + const adapter = new RunJournalReaderAdapter(projectRoot); + + expect(await adapter.read(SESSION_ID)).toBeNull(); + }); + + // Absence is not a stated disagreement. Every journal on disk before this reader looked at + // the field was read without it, and refusing them now would drop attribution this reader + // has always been able to give - the fault "an unknown is never a zero" names, applied to + // the reader rather than to a figure. + it("still reads a journal that states no schema at all", async () => { + await writeJournal(header({}), { + type: "step_start", + at: "2026-08-20T10:00:00Z", + skill: "aidd-dev:02-implement", + }); + const adapter = new RunJournalReaderAdapter(projectRoot); + + const journal = await adapter.read(SESSION_ID); + + expect(journal?.session?.schema_version).toBeUndefined(); + expect(journal?.boundaries).toHaveLength(1); + }); + + // What a refusal must not cost: the fact that a run file is there. Dropped silently, the + // diagnostic reads "none carry a readable session_start" about a file whose header it read + // perfectly well, and prints a torn write as the cause of a version disagreement. + it("still names the schema of every journal it refused", async () => { + await writeJournal(header({ schema_version: READABLE_JOURNAL_SCHEMA_VERSION + 1 })); + const adapter = new RunJournalReaderAdapter(projectRoot); + + expect(await adapter.listForeignSchemas()).toEqual([READABLE_JOURNAL_SCHEMA_VERSION + 1]); + }); +}); diff --git a/plugins/aidd-telemetry/skills/02-check/actions/02-diagnose.md b/plugins/aidd-telemetry/skills/02-check/actions/02-diagnose.md index ac20b3e3b..376e6c11a 100644 --- a/plugins/aidd-telemetry/skills/02-check/actions/02-diagnose.md +++ b/plugins/aidd-telemetry/skills/02-check/actions/02-diagnose.md @@ -63,6 +63,12 @@ judging anything. write, or a hooks block that registers another event without `SessionStart`): reads `FAIL` regardless of the recorder's own declaration, since a file this build can see is never "no run file" about. + - A run file was written under a schema this build does not read: reads `FAIL`, naming + every schema stated, since a journal from another version of the plugin is never a + hook that did not fire. This reading wins ahead of the anchorless one above: a build + that cannot read a journal's schema cannot tell a missing `session_start` from one + shaped differently, so blaming a torn write would assert what it just said it cannot + see. Every one of these is distinct from a hook that already ran and stopped: a run file that exists, carries its own session_start, but is not this session's own reads as this