From 44e41d1c6fcda1541277305a38cf62050b798ae2 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 08:32:14 +0200 Subject: [PATCH 01/19] fix(telemetry): one repository is one id, whatever its remote's spelling The host is case-folded on every form: WHATWG URL folds only special schemes, so ssh:// and git:// kept it. ssh.github.com is github.com (GitHub's SSH over port 443). Owner and repo are case-folded on github.com only, which documents them as not case sensitive; any other host keeps their case. Found by the R1 telemetry audit (M7). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../telemetry/domain/repository-identity.ts | 58 ++++++++++++++----- .../domain/repository-identity.unit.test.ts | 31 +++++++++- 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/cli/src/contexts/telemetry/domain/repository-identity.ts b/cli/src/contexts/telemetry/domain/repository-identity.ts index cdd90cb72..0ca97ba94 100644 --- a/cli/src/contexts/telemetry/domain/repository-identity.ts +++ b/cli/src/contexts/telemetry/domain/repository-identity.ts @@ -4,15 +4,16 @@ const SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i; /** `[user@]host:path`. A one-letter host is a Windows drive, not a host. */ const SCP_LIKE = /^(?:[^@/\s]+@)?([^:/\s]{2,}):(?!\/\/)(.+)/; -function ownerAndRepo(path: string): string | null { - const cleaned = path.replace(/^\/+/, "").replace(/(?:\.git)?\/*$/, ""); - return cleaned.includes("/") ? cleaned : null; +/** A remote's host and path as written, credentials and port dropped. `scp` says the host is + * the text before the colon, as typed; otherwise it is `URL`'s, which folds only the case of + * special schemes (`https`, not `ssh` or `git`). */ +export interface RemoteAddress { + readonly host: string; + readonly path: string; + readonly scp: boolean; } -/** `host/owner/repo` of a remote url, whatever its spelling: https, ssh, scp-like or git, with - * credentials, port, `.git` suffix and trailing slash dropped and the host case-folded. A local - * path, a `file://` url or anything with no owner and repo names no hosted repository. */ -export function normaliseRemote(url: string): string | null { +export function addressOf(url: string): RemoteAddress | null { const text = url.trim(); if (SCHEME.test(text)) { let parsed: URL; @@ -21,13 +22,43 @@ export function normaliseRemote(url: string): string | null { } catch { return null; } - const path = ownerAndRepo(parsed.pathname); - return parsed.hostname === "" || path === null ? null : `${parsed.hostname}/${path}`; + return parsed.hostname === "" + ? null + : { host: parsed.hostname, path: parsed.pathname, scp: false }; } const scp = SCP_LIKE.exec(text); - if (scp === null) return null; - const path = ownerAndRepo(scp[2] as string); - return path === null ? null : `${(scp[1] as string).toLowerCase()}/${path}`; + return scp === null ? null : { host: scp[1] as string, path: scp[2] as string, scp: true }; +} + +/** `owner/repo`, leading slashes, `.git` and trailing slashes dropped; `null` with no owner. */ +export function ownerAndRepo(path: string): string | null { + const cleaned = path.replace(/^\/+/, "").replace(/(?:\.git)?\/*$/, ""); + return cleaned.includes("/") ? cleaned : null; +} + +export function hashOf(address: string): string { + return createHash("sha256").update(address).digest("hex"); +} + +/** GitHub's SSH over the HTTPS port answers at `ssh.github.com` for the repositories of + * `github.com`. */ +const SAME_HOST: Readonly> = { "ssh.github.com": "github.com" }; +/** Hosts whose owner and repository names are not case sensitive, by their own documentation. + * Any other host keeps the case written: two spellings may give two ids, never one id two + * repositories. */ +const CASELESS_PATH = new Set(["github.com"]); + +/** `host/owner/repo` of a remote url, whatever its spelling: https, ssh, scp-like or git, with + * credentials, port, `.git` suffix and trailing slash dropped, the host case-folded, and the + * path too where the host ignores its case. A local path, a `file://` url or anything with no + * owner and repo names no hosted repository. */ +export function normaliseRemote(url: string): string | null { + const address = addressOf(url); + if (address === null) return null; + const folded = address.host.toLowerCase(); + const host = SAME_HOST[folded] ?? folded; + const path = ownerAndRepo(CASELESS_PATH.has(host) ? address.path.toLowerCase() : address.path); + return path === null ? null : `${host}/${path}`; } export interface RepositoryFacts { @@ -41,6 +72,5 @@ export interface RepositoryFacts { * its first commit. The url itself is never kept. */ export function repositoryIdOf(facts: RepositoryFacts): string | null { const hosted = facts.remote === null ? null : normaliseRemote(facts.remote); - if (hosted !== null) return createHash("sha256").update(hosted).digest("hex"); - return facts.rootCommit; + return hosted === null ? facts.rootCommit : hashOf(hosted); } diff --git a/cli/tests/contexts/telemetry/domain/repository-identity.unit.test.ts b/cli/tests/contexts/telemetry/domain/repository-identity.unit.test.ts index fba32f63d..2eaacb767 100644 --- a/cli/tests/contexts/telemetry/domain/repository-identity.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/repository-identity.unit.test.ts @@ -27,6 +27,17 @@ describe("a remote is reduced to host/owner/repo", () => { ["several slashes before the path", "https://github.com//acme/widgets"], ["several slashes after the path", "https://github.com/acme/widgets//"], ["several slashes after .git", "https://github.com/acme/widgets.git//"], + ["owner and repo in another case", "git@github.com:Acme/Widgets.git"], + ["https in another case", "https://github.com/ACME/widgets"], + ["upper-case .GIT on github.com", "https://github.com/acme/widgets.GIT"], + ["ssh:// upper-case host", "ssh://git@GitHub.COM/acme/widgets.git"], + ["ssh:// upper-case host with a port", "ssh://git@GitHub.COM:22/Acme/Widgets.git/"], + ["git:// upper-case host", "git://GITHUB.com/acme/widgets"], + ["git+ssh:// upper-case host", "git+ssh://git@GitHub.com/acme/widgets.git"], + ["ssh over the https port", "ssh://git@ssh.github.com:443/acme/widgets.git"], + ["ssh over the https port, upper case", "ssh://git@SSH.GitHub.com:443/Acme/Widgets"], + ["https with a port", "https://github.com:443/acme/widgets"], + ["credentials and a port", "https://u:p@GitHub.com:8443/Acme/widgets.git/"], ])("%s gives one spelling", (_name, url) => { expect(normaliseRemote(url)).toBe(CANONICAL); }); @@ -52,8 +63,22 @@ describe("a remote is reduced to host/owner/repo", () => { ); }); - it("keeps the case of the path: only the host is case-folded", () => { - expect(normaliseRemote("https://github.com/Acme/Widgets")).toBe("github.com/Acme/Widgets"); + it("keeps the case of the path on a host not known to ignore it, whatever the form", () => { + for (const url of [ + "https://git.example.org/Team/Repo.git", + "ssh://git@Git.Example.ORG:2222/Team/Repo.git/", + "git@GIT.example.org:Team/Repo", + "git://git.example.org/Team/Repo", + ]) { + expect(normaliseRemote(url)).toBe("git.example.org/Team/Repo"); + } + }); + + it("does not take a host that merely contains github.com for github.com", () => { + expect(normaliseRemote("https://github.com.example.org/Acme/X")).toBe( + "github.com.example.org/Acme/X" + ); + expect(normaliseRemote("git@ghe.acme.io:Acme/X.git")).toBe("ghe.acme.io/Acme/X"); }); it.each([ @@ -81,8 +106,10 @@ describe("a repository id", () => { it("is the sha256 of the normalised remote, whatever its spelling", () => { const a = repositoryIdOf({ remote: "git@github.com:acme/widgets.git", rootCommit: "c0ffee" }); const b = repositoryIdOf({ remote: "https://u:p@GitHub.com/acme/widgets", rootCommit: "beef" }); + const c = repositoryIdOf({ remote: "ssh://git@GitHub.com/Acme/Widgets.git", rootCommit: null }); expect(a).toBe(sha(CANONICAL)); expect(b).toBe(a); + expect(c).toBe(a); }); it("never contains the remote it came from", () => { From 50d51639704878d8d1f34e7f50ed24dab5d743f7 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 08:33:14 +0200 Subject: [PATCH 02/19] fix(telemetry): one call is one key, with or without a request id A call with no requestId was keyed message.id:sessionId:timestamp, so copies of it written at different times were never folded and the call was counted once per copy. The Messages API makes message.id a unique object identifier, so message.id:sessionId names one call. Found by the R1 telemetry audit (M10). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../domain/formats/claude-transcript-usage.ts | 2 +- .../claude-transcript-usage.unit.test.ts | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts b/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts index 0f7015ef4..312369cc3 100644 --- a/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts +++ b/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts @@ -96,7 +96,7 @@ export function readClaudeUsageLine(line: string): ClaudeUsageLine { const notes: string[] = []; const requestId = text(root.requestId); - const key = requestId === null ? `${id}:${sessionId}:${at}` : `${id}:${requestId}`; + const key = `${id}:${requestId ?? sessionId}`; const iterations = messageIterations(usage, notes); const writes = cacheWrites(usage, iterations, notes); const shared = { diff --git a/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts index dd88c81cc..98fa0ee83 100644 --- a/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts @@ -57,9 +57,20 @@ describe("one transcript line to usage records", () => { expect(only(line()).key).toBe("msg_A:req_A"); }); - it("keys a requestless call by message id, session id and timestamp", () => { - const requestless = line({ requestId: undefined }); - expect(only(requestless).key).toBe("msg_A:s-1:2026-10-07T10:00:01.000Z"); + it("keys a requestless call by message id and session id, so its copies are one key", () => { + const first = line({ requestId: undefined, timestamp: "2026-10-07T23:59:59.000Z" }); + const copy = line({ requestId: undefined, timestamp: "2026-10-08T00:00:02.000Z" }); + expect(only(first).key).toBe("msg_A:s-1"); + expect(only(copy).key).toBe("msg_A:s-1"); + }); + + it("keys a requestless call's advisor iterations under the same key", () => { + const usage = { + ...USAGE, + iterations: [{ type: "advisor_message", model: "m", input_tokens: 1, output_tokens: 2 }], + }; + const { records } = readClaudeUsageLine(line({ requestId: undefined }, { usage })); + expect(records.map((r) => r.key)).toEqual(["msg_A:s-1", "msg_A:s-1#advisor0"]); }); it("drops the synthetic model", () => { @@ -261,7 +272,7 @@ describe("one transcript line to usage records", () => { }); it("reads an empty request id as none", () => { - expect(only(line({ requestId: "" })).key).toBe("msg_A:s-1:2026-10-07T10:00:01.000Z"); + expect(only(line({ requestId: "" })).key).toBe("msg_A:s-1"); }); it("leaves a model that is not text unknown", () => { From 8ba96ac09f4b99f47ebb857589d2da97f062cb32 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 08:34:11 +0200 Subject: [PATCH 03/19] fix(telemetry): bring stored calls to the new key and repository id Data stored by released CLIs carries the earlier ids and keys. Every reader of the ledger, roots.json and branches.jsonl now re-keys a timestamped key and re-ids through ledger/repository-ids.json, a table from the id an earlier version gave a repository to its id now, made once from each root's origin. The first ingest or report writes the table, rewrites roots.json and every ledger partition, then marks it complete, under the ledger lock, by write-then-rename: a run stopped at any write is finished by the next. branches.jsonl stays append-only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- cli/aidd_docs/memory/codebase-map.md | 4 +- .../application/ingest-usage-use-case.ts | 5 +- .../legacy/stored-usage-migration.ts | 49 +++ .../legacy/legacy-repository-identity.ts | 11 + .../domain/legacy/legacy-usage-key.ts | 27 ++ .../domain/legacy/repository-aliases.ts | 30 ++ .../domain/ports/repository-alias-store.ts | 8 + .../binding-snapshot-store-adapter.ts | 27 +- .../legacy/repository-alias-store-adapter.ts | 30 ++ .../resolution-store-adapter.ts | 12 +- .../infrastructure/usage-ledger-adapter.ts | 13 +- cli/src/runtime/wiring/telemetry.ts | 8 +- .../orchestrator-deps.arch.test.ts | 7 +- ...age-migration.property.integration.test.ts | 365 ++++++++++++++++++ .../legacy-repository-identity.unit.test.ts | 28 ++ .../legacy/legacy-usage-key.unit.test.ts | 35 ++ .../legacy/repository-aliases.unit.test.ts | 32 ++ .../stored-ids-and-keys.integration.test.ts | 254 ++++++++++++ cli/tests/e2e/telemetry-ingest.e2e.test.ts | 1 + 19 files changed, 929 insertions(+), 17 deletions(-) create mode 100644 cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts create mode 100644 cli/src/contexts/telemetry/domain/legacy/legacy-repository-identity.ts create mode 100644 cli/src/contexts/telemetry/domain/legacy/legacy-usage-key.ts create mode 100644 cli/src/contexts/telemetry/domain/legacy/repository-aliases.ts create mode 100644 cli/src/contexts/telemetry/domain/ports/repository-alias-store.ts create mode 100644 cli/src/contexts/telemetry/infrastructure/legacy/repository-alias-store-adapter.ts create mode 100644 cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/legacy/legacy-repository-identity.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/domain/legacy/repository-aliases.unit.test.ts create mode 100644 cli/tests/contexts/telemetry/infrastructure/stored-ids-and-keys.integration.test.ts diff --git a/cli/aidd_docs/memory/codebase-map.md b/cli/aidd_docs/memory/codebase-map.md index adbda98f9..1078b4c55 100644 --- a/cli/aidd_docs/memory/codebase-map.md +++ b/cli/aidd_docs/memory/codebase-map.md @@ -54,6 +54,7 @@ src/ │ │ ├── application/ │ │ │ ├── forget/ # what measurement keeps on a machine, shown and erased │ │ │ ├── identity/ # who, if anyone, the measurement names +│ │ │ ├── legacy/ # bringing what an earlier version stored to today's key and id rules │ │ │ ├── report/ # what the stored calls consumed in a period, along one axis │ │ │ └── switch/ # opting a repository in or out, and leaving it clean of the previous version │ │ ├── domain/ @@ -61,7 +62,7 @@ src/ │ │ │ ├── formats/ │ │ │ ├── consent/ # a clone's identity, and when it consented │ │ │ ├── identity/ # the identifier a person chose to be named by -│ │ │ ├── legacy/ # what the previous version wrote, by name and location +│ │ │ ├── legacy/ # what the previous version wrote, by name and location, and its key and id rules │ │ │ ├── ports/ │ │ │ │ ├── bindings/ # the declarations kept beside the ledger, and the lock their writers share │ │ │ │ ├── forget/ @@ -74,6 +75,7 @@ src/ │ │ ├── declaration/ # where a declaration is kept: git config and the telemetry dir │ │ ├── forget/ # erasing the telemetry dir, the previous version's files, git config keys │ │ ├── identity/ # the identity file in the telemetry dir +│ │ ├── legacy/ # the table of ids an earlier version gave repositories, beside the ledger │ │ ├── left-out/ # what ingests measured and could not store, by hash, beside the ledger │ │ └── switch/ # the project config, what an earlier measurement left in a repository, and Claude settings; the clone's consent in git config is `git-consent-adapter.ts`, beside this folder │ └── translate/ # canonical source to target-native content diff --git a/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts b/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts index c0d6c870f..de9afb7c8 100644 --- a/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts +++ b/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts @@ -70,7 +70,8 @@ export class IngestUsageUseCase { private readonly ledger: UsageLedger, private readonly directories: DirectoryResolver, private readonly snapshots: SnapshotBindingsUseCase, - private readonly options: IngestOptions + private readonly options: IngestOptions, + private readonly migration?: { execute(): Promise } ) {} async execute(): Promise { @@ -92,6 +93,8 @@ export class IngestUsageUseCase { } private async ingest(): Promise { + // Stored data first brought to the rules of now, before anything is added to it. + await this.migration?.execute(); const before = await this.ledger.positions(); // The consent log first: a transcript last written before any interval opened is not read. const resolver = await this.directories.open(); diff --git a/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts b/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts new file mode 100644 index 000000000..8b9ad7c02 --- /dev/null +++ b/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts @@ -0,0 +1,49 @@ +import { legacyRepositoryIdOf } from "../../domain/legacy/legacy-repository-identity.js"; +import type { RepositoryAliasStore } from "../../domain/ports/repository-alias-store.js"; +import type { RepositoryLocator } from "../../domain/ports/repository-locator.js"; +import type { ResolutionStore } from "../../domain/ports/resolution-store.js"; +import type { UsageLedger } from "../../domain/ports/usage-ledger.js"; +import { repositoryIdOf } from "../../domain/repository-identity.js"; +import { settleLedger } from "../ledger-upkeep.js"; + +/** Brings what an earlier version stored to the rules of now: one key per call, one id per + * repository. A stored id is a hash, so only a live clone's origin can say which id an earlier + * version gave it: each root `roots.json` names is located once, and the earlier id of its + * origin mapped to the id of now. The table is written first; every reader of the ledger, + * `roots.json` and the branch snapshots applies it, and the key rule, from then on. Then + * `roots.json` and every ledger partition are rewritten under it, and the table is marked + * complete, last. A run stopped anywhere leaves a state the next one completes with the same + * table, asking git nothing. Runs under the ledger's lock. */ +export class StoredUsageMigration { + constructor( + private readonly locator: RepositoryLocator, + private readonly resolutions: ResolutionStore, + private readonly aliases: RepositoryAliasStore, + private readonly ledger: UsageLedger + ) {} + + async execute(): Promise { + const held = await this.aliases.load(); + if (held?.complete) return; + const ids = held?.ids ?? (await this.table()); + if (held === null) await this.aliases.save({ ids, complete: false }); + const roots = await this.resolutions.load(); + if (roots.size > 0) await this.resolutions.save(roots); + await settleLedger(this.ledger); + await this.aliases.save({ ids, complete: true }); + } + + private async table(): Promise> { + const roots = [...new Set([...(await this.resolutions.load()).values()].map((r) => r.root))]; + const ids = new Map(); + for (const root of roots) { + const located = await this.locator.locate(root); + if (located.status !== "repository") continue; + // Both rules fall back to the root commit alike, so one is null only when both are. + const earlier = legacyRepositoryIdOf(located); + const now = repositoryIdOf(located); + if (earlier !== now) ids.set(earlier as string, now as string); + } + return ids; + } +} diff --git a/cli/src/contexts/telemetry/domain/legacy/legacy-repository-identity.ts b/cli/src/contexts/telemetry/domain/legacy/legacy-repository-identity.ts new file mode 100644 index 000000000..7ac53de5a --- /dev/null +++ b/cli/src/contexts/telemetry/domain/legacy/legacy-repository-identity.ts @@ -0,0 +1,11 @@ +import { addressOf, hashOf, ownerAndRepo, type RepositoryFacts } from "../repository-identity.js"; + +/** The id cli-v5.5.0 stored for a repository: the host case-folded only when scp-like or of a + * special scheme, the path as written. Kept so the migration can tell which stored id a live + * clone's origin had (see `RepositoryIdMigration`); nothing new is ever named by it. */ +export function legacyRepositoryIdOf(facts: RepositoryFacts): string | null { + const address = facts.remote === null ? null : addressOf(facts.remote); + const path = address === null ? null : ownerAndRepo(address.path); + if (address === null || path === null) return facts.rootCommit; + return hashOf(`${address.scp ? address.host.toLowerCase() : address.host}/${path}`); +} diff --git a/cli/src/contexts/telemetry/domain/legacy/legacy-usage-key.ts b/cli/src/contexts/telemetry/domain/legacy/legacy-usage-key.ts new file mode 100644 index 000000000..4c1e182e5 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/legacy/legacy-usage-key.ts @@ -0,0 +1,27 @@ +import type { StoredUsage } from "../stored-usage.js"; +import type { UsageRecord } from "../usage-record.js"; + +const ADVISOR = /#advisor\d+$/; + +/** A call's key as it is keyed now. An earlier version keyed a call with no request id + * `message.id:sessionId:timestamp`, so each copy of it had its own key; the timestamp is the + * call's own `at`, after its own session, and is dropped. Any other key is kept: one keyed now + * ends in its session, never in `:at`, so re-keying twice is re-keying once. */ +export function currentKeyOf(call: Pick): string { + const suffix = ADVISOR.exec(call.key)?.[0] ?? ""; + const base = call.key.slice(0, call.key.length - suffix.length); + const earlier = `:${call.session_id}:${call.at}`; + return base.endsWith(earlier) && base.length > earlier.length + ? `${base.slice(0, -call.at.length - 1)}${suffix}` + : call.key; +} + +/** A stored call as it is keyed and named now: the same object when nothing changed, so a + * reader can tell a partition that needs rewriting. */ +export function storedNow(record: T, ids: ReadonlyMap): T { + const key = currentKeyOf(record); + const repositoryId = ids.get(record.repository_id) ?? record.repository_id; + return key === record.key && repositoryId === record.repository_id + ? record + : { ...record, key, repository_id: repositoryId }; +} diff --git a/cli/src/contexts/telemetry/domain/legacy/repository-aliases.ts b/cli/src/contexts/telemetry/domain/legacy/repository-aliases.ts new file mode 100644 index 000000000..14802f04d --- /dev/null +++ b/cli/src/contexts/telemetry/domain/legacy/repository-aliases.ts @@ -0,0 +1,30 @@ +import { tryParseJson } from "../../../../kernel/reading/json-file.js"; +import { asPlainObject } from "../../../../kernel/reading/plain-object.js"; +import { compareText } from "../text-order.js"; + +/** Which id an earlier version gave a repository that now has another, and whether every store + * has been rewritten to it. Both sides are hashes or root commits: no address is kept. */ +export interface RepositoryAliases { + readonly ids: ReadonlyMap; + readonly complete: boolean; +} + +const FORMAT = 1; + +/** `null` for a file missing, of another format, or damaged: the table is then made again. */ +export function parseRepositoryAliases(text: string | null): RepositoryAliases | null { + const parsed = tryParseJson(text ?? ""); + const object = parsed.ok ? asPlainObject(parsed.value) : null; + const aliases = asPlainObject(object?.aliases); + if (object?.version !== FORMAT || typeof object.complete !== "boolean" || aliases === null) { + return null; + } + const entries = Object.entries(aliases); + if (entries.some(([, id]) => typeof id !== "string" || id === "")) return null; + return { ids: new Map(entries as [string, string][]), complete: object.complete }; +} + +export function renderRepositoryAliases({ ids, complete }: RepositoryAliases): string { + const aliases = Object.fromEntries([...ids].sort(([a], [b]) => compareText(a, b))); + return `${JSON.stringify({ version: FORMAT, complete, aliases }, null, 2)}\n`; +} diff --git a/cli/src/contexts/telemetry/domain/ports/repository-alias-store.ts b/cli/src/contexts/telemetry/domain/ports/repository-alias-store.ts new file mode 100644 index 000000000..d138f76dc --- /dev/null +++ b/cli/src/contexts/telemetry/domain/ports/repository-alias-store.ts @@ -0,0 +1,8 @@ +import type { RepositoryAliases } from "../legacy/repository-aliases.js"; + +/** The table of ids an earlier version gave repositories, beside the ledger. */ +export interface RepositoryAliasStore { + /** `null` when there is none yet. */ + load(): Promise; + save(aliases: RepositoryAliases): Promise; +} diff --git a/cli/src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.ts b/cli/src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.ts index 40de19c00..fd9f32271 100644 --- a/cli/src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.ts @@ -3,6 +3,7 @@ import { readTextIfPresent } from "../../../kernel/reading/text-file.js"; import { type BranchSnapshot, parseBranchSnapshot, snapshotKey } from "../domain/branch-binding.js"; import type { BindingSnapshotStore } from "../domain/ports/bindings/binding-snapshot-store.js"; import type { PrivateStorage } from "../domain/ports/private-storage.js"; +import type { RepositoryAliasStore } from "../domain/ports/repository-alias-store.js"; /** `branches.jsonl`: append-only, one snapshot per line, the latest line of a branch being its * current one. It is appended to, never rewritten, because a snapshot is a fact about a moment @@ -12,16 +13,15 @@ export class BindingSnapshotStoreAdapter implements BindingSnapshotStore { constructor( private readonly dir: string, - private readonly storage: PrivateStorage + private readonly storage: PrivateStorage, + private readonly aliases?: RepositoryAliasStore ) { this.path = join(dir, "branches.jsonl"); } async history(): Promise> { const history = new Map(); - for (const line of ((await readTextIfPresent(this.path)) ?? "").split("\n")) { - const snapshot = parseBranchSnapshot(line); - if (snapshot === null) continue; + for (const snapshot of await this.read()) { const key = snapshotKey(snapshot.repository_id, snapshot.branch); history.set(key, [...(history.get(key) ?? []), snapshot]); } @@ -30,12 +30,25 @@ export class BindingSnapshotStoreAdapter implements BindingSnapshotStore { async latest(): Promise> { const latest = new Map(); + for (const snapshot of await this.read()) { + latest.set(snapshotKey(snapshot.repository_id, snapshot.branch), snapshot); + } + return latest; + } + + /** Every snapshot, in the order taken, each repository by its id of now: a snapshot taken + * under an id an earlier version gave joins the history of the id it has now, and the file + * is never rewritten. */ + private async read(): Promise { + const ids = (await this.aliases?.load())?.ids; + const snapshots: BranchSnapshot[] = []; for (const line of ((await readTextIfPresent(this.path)) ?? "").split("\n")) { const snapshot = parseBranchSnapshot(line); - if (snapshot !== null) - latest.set(snapshotKey(snapshot.repository_id, snapshot.branch), snapshot); + if (snapshot === null) continue; + const id = ids?.get(snapshot.repository_id); + snapshots.push(id === undefined ? snapshot : { ...snapshot, repository_id: id }); } - return latest; + return snapshots; } async append(snapshots: readonly BranchSnapshot[]): Promise { diff --git a/cli/src/contexts/telemetry/infrastructure/legacy/repository-alias-store-adapter.ts b/cli/src/contexts/telemetry/infrastructure/legacy/repository-alias-store-adapter.ts new file mode 100644 index 000000000..168948be4 --- /dev/null +++ b/cli/src/contexts/telemetry/infrastructure/legacy/repository-alias-store-adapter.ts @@ -0,0 +1,30 @@ +import { join } from "node:path"; +import { readTextIfPresent } from "../../../../kernel/reading/text-file.js"; +import { + parseRepositoryAliases, + type RepositoryAliases, + renderRepositoryAliases, +} from "../../domain/legacy/repository-aliases.js"; +import type { PrivateStorage } from "../../domain/ports/private-storage.js"; +import type { RepositoryAliasStore } from "../../domain/ports/repository-alias-store.js"; + +/** `repository-ids.json`, beside the ledger. */ +export class RepositoryAliasStoreAdapter implements RepositoryAliasStore { + private readonly path: string; + + constructor( + private readonly ledgerDir: string, + private readonly storage: PrivateStorage + ) { + this.path = join(ledgerDir, "repository-ids.json"); + } + + async load(): Promise { + return parseRepositoryAliases(await readTextIfPresent(this.path)); + } + + async save(aliases: RepositoryAliases): Promise { + await this.storage.ensureDirectory(this.ledgerDir); + await this.storage.replace(this.path, renderRepositoryAliases(aliases)); + } +} diff --git a/cli/src/contexts/telemetry/infrastructure/resolution-store-adapter.ts b/cli/src/contexts/telemetry/infrastructure/resolution-store-adapter.ts index 554041947..c089c6019 100644 --- a/cli/src/contexts/telemetry/infrastructure/resolution-store-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/resolution-store-adapter.ts @@ -7,20 +7,30 @@ import { type RepositoryResolution, renderResolutions, } from "../domain/repository-resolution.js"; +import { RepositoryAliasStoreAdapter } from "./legacy/repository-alias-store-adapter.js"; /** `roots.json`, beside the ledger. */ export class ResolutionStoreAdapter implements ResolutionStore { private readonly path: string; + private readonly aliases: RepositoryAliasStoreAdapter; constructor( private readonly ledgerDir: string, private readonly storage: PrivateStorage ) { this.path = join(ledgerDir, "roots.json"); + this.aliases = new RepositoryAliasStoreAdapter(ledgerDir, storage); } + /** Each repository by its id of now (see `RepositoryAliasStore`). */ async load(): Promise> { - return parseResolutions(await readTextIfPresent(this.path)); + const ids = (await this.aliases.load())?.ids; + const held = parseResolutions(await readTextIfPresent(this.path)); + for (const [key, resolution] of held) { + const id = ids?.get(resolution.repository_id); + if (id !== undefined) held.set(key, { ...resolution, repository_id: id }); + } + return held; } async save(resolutions: ReadonlyMap): Promise { diff --git a/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts b/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts index 47de1498e..9ac42c6bb 100644 --- a/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts @@ -2,6 +2,7 @@ import { readdir, rm } from "node:fs/promises"; import { join } from "node:path"; import { isErrnoException } from "../../../kernel/reading/json-file.js"; import { readTextIfPresent } from "../../../kernel/reading/text-file.js"; +import { storedNow } from "../domain/legacy/legacy-usage-key.js"; import type { PrivateStorage } from "../domain/ports/private-storage.js"; import type { LeftOutStore, LoadedLedger, UsageLedger } from "../domain/ports/usage-ledger.js"; import { @@ -19,6 +20,7 @@ import { import { foldUsage } from "../domain/usage-fold.js"; import { DirectoryLock, type LockOptions } from "./ledger-lock.js"; import { LeftOutAdapter } from "./left-out/left-out-adapter.js"; +import { RepositoryAliasStoreAdapter } from "./legacy/repository-alias-store-adapter.js"; const DAILY = /^\d{4}-\d{2}-\d{2}\.jsonl$/; const MONTHLY = /^\d{4}-\d{2}\.jsonl$/; @@ -51,6 +53,7 @@ function partitionOf(name: string): string { * and could not store (see `LeftOutAdapter`); `.lock` serialises the writers of all of them. */ export class UsageLedgerAdapter implements UsageLedger { readonly leftOut: LeftOutStore; + private readonly aliases: RepositoryAliasStoreAdapter; constructor( private readonly dir: string, @@ -58,6 +61,7 @@ export class UsageLedgerAdapter implements UsageLedger { private readonly lockOptions: Partial = {} ) { this.leftOut = new LeftOutAdapter(join(dir, "left-out.txt"), dir, storage); + this.aliases = new RepositoryAliasStoreAdapter(dir, storage); } exclusively(work: () => Promise): Promise { @@ -71,18 +75,23 @@ export class UsageLedgerAdapter implements UsageLedger { const damaged = new Set(); const unsettled = new Set(); const heldIn = new Map(); + // Every read applies the rules of now, so a call an earlier version keyed or named + // otherwise is still one call of one repository, and its partition is rewritten. + const ids = (await this.aliases.load())?.ids ?? new Map(); for (const name of holds === undefined ? names : inWindow(names, holds)) { const partition = partitionOf(name); if (MONTHLY.test(name)) unsettled.add(partition); const text = (await readTextIfPresent(join(this.dir, name))) ?? ""; for (const line of text.split("\n")) { if (line === "") continue; - const record = parseStoredUsage(line); - if (record === null) { + const read = parseStoredUsage(line); + if (read === null) { skippedLines += 1; damaged.add(partition); continue; } + const record = storedNow(read, ids); + if (record !== read) unsettled.add(partition); found.push(record); const id = `${record.tool}\u0000${record.key}`; const first = heldIn.get(id); diff --git a/cli/src/runtime/wiring/telemetry.ts b/cli/src/runtime/wiring/telemetry.ts index 8d29ff6d6..275701108 100644 --- a/cli/src/runtime/wiring/telemetry.ts +++ b/cli/src/runtime/wiring/telemetry.ts @@ -9,6 +9,7 @@ import { ForgetTelemetryUseCase } from "../../contexts/telemetry/application/for import { ManageIdentityUseCase } from "../../contexts/telemetry/application/identity/manage-identity-use-case.js"; import { ProposeIdentityUseCase } from "../../contexts/telemetry/application/identity/propose-identity-use-case.js"; import { IngestUsageUseCase } from "../../contexts/telemetry/application/ingest-usage-use-case.js"; +import { StoredUsageMigration } from "../../contexts/telemetry/application/legacy/stored-usage-migration.js"; import { ReadUsageUseCase } from "../../contexts/telemetry/application/read-usage-use-case.js"; import { DeclaredBindings } from "../../contexts/telemetry/application/report/declared-bindings.js"; import { ReportUsageUseCase } from "../../contexts/telemetry/application/report/report-usage-use-case.js"; @@ -36,6 +37,7 @@ import { GitConsentAdapter } from "../../contexts/telemetry/infrastructure/git-c import { GitRepositoryLocatorAdapter } from "../../contexts/telemetry/infrastructure/git-repository-locator-adapter.js"; import { GitUserSourceAdapter } from "../../contexts/telemetry/infrastructure/identity/git-user-source-adapter.js"; import { PersonIdentityAdapter } from "../../contexts/telemetry/infrastructure/identity/person-identity-adapter.js"; +import { RepositoryAliasStoreAdapter } from "../../contexts/telemetry/infrastructure/legacy/repository-alias-store-adapter.js"; import { ResolutionStoreAdapter } from "../../contexts/telemetry/infrastructure/resolution-store-adapter.js"; import { ClaudeSettingsAdapter } from "../../contexts/telemetry/infrastructure/switch/claude-settings-adapter.js"; import { LegacyHookAdapter } from "../../contexts/telemetry/infrastructure/switch/legacy-hook-adapter.js"; @@ -88,7 +90,8 @@ export function wireTelemetry(homedir: () => string): TelemetryDeps { const gitEnv = environmentWithoutGitVariables(); const bindingsDir = join(root, "bindings"); const branchSource = new GitBranchBindingSourceAdapter(gitEnv); - const snapshotStore = new BindingSnapshotStoreAdapter(bindingsDir, storage); + const aliases = new RepositoryAliasStoreAdapter(ledgerDir, storage); + const snapshotStore = new BindingSnapshotStoreAdapter(bindingsDir, storage, aliases); const bindingsLock = new BindingsLockAdapter(bindingsDir, storage); const snapshotBindingsUseCase = new SnapshotBindingsUseCase( branchSource, @@ -143,7 +146,8 @@ export function wireTelemetry(homedir: () => string): TelemetryDeps { ledger, new DirectoryResolver(locator, consents, resolutionStore, consentHistory, environment), snapshotBindingsUseCase, - { refusedByEnvironment } + { refusedByEnvironment }, + new StoredUsageMigration(locator, resolutionStore, aliases, ledger) ); const claudeDir = claudeConfigDir(process.env.CLAUDE_CONFIG_DIR, homedir()); return { diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts index e68ebec3c..783d3405e 100644 --- a/cli/tests/architecture/orchestrator-deps.arch.test.ts +++ b/cli/tests/architecture/orchestrator-deps.arch.test.ts @@ -96,9 +96,10 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] { path: "src/contexts/framework/application/plugin/plugin-remove-use-case.ts", injected: 9 }, // Ingest is four stages, each owned by a collaborator: read what the transcripts gained, // tie each call to a repository, store it, snapshot the branch declarations. The fifth is - // the environment's refusal. A stage folded into another would hide where the ledger is - // written. - { path: "src/contexts/telemetry/application/ingest-usage-use-case.ts", injected: 5 }, + // the environment's refusal. The sixth brings what an earlier version stored to today's key + // and id rules before anything is added to it, under the same lock. A stage folded into + // another would hide where the ledger is written. + { path: "src/contexts/telemetry/application/ingest-usage-use-case.ts", injected: 6 }, // Tying a call to a clone is four things it cannot fold into one: where the directory is, // what its clone says now, what was remembered of the directory, and when the clone // consented. The fifth is the machine's spelling of a directory and the clock. diff --git a/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts b/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts new file mode 100644 index 000000000..810916bda --- /dev/null +++ b/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts @@ -0,0 +1,365 @@ +import { appendFile, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as fc from "fast-check"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { DirectoryResolver } from "../../../../../src/contexts/telemetry/application/directory-resolver.js"; +import { IngestUsageUseCase } from "../../../../../src/contexts/telemetry/application/ingest-usage-use-case.js"; +import { StoredUsageMigration } from "../../../../../src/contexts/telemetry/application/legacy/stored-usage-migration.js"; +import { ReadUsageUseCase } from "../../../../../src/contexts/telemetry/application/read-usage-use-case.js"; +import { DeclaredBindings } from "../../../../../src/contexts/telemetry/application/report/declared-bindings.js"; +import { reportEnvelopeOf } from "../../../../../src/contexts/telemetry/application/report/report-envelope.js"; +import { ReportUsageUseCase } from "../../../../../src/contexts/telemetry/application/report/report-usage-use-case.js"; +import { SnapshotBindingsUseCase } from "../../../../../src/contexts/telemetry/application/snapshot-bindings-use-case.js"; +import type { CloneIdentity } from "../../../../../src/contexts/telemetry/domain/consent/clone-identity.js"; +import { readClaudeUsageLine } from "../../../../../src/contexts/telemetry/domain/formats/claude-transcript-usage.js"; +import { legacyRepositoryIdOf } from "../../../../../src/contexts/telemetry/domain/legacy/legacy-repository-identity.js"; +import type { PrivateStorage } from "../../../../../src/contexts/telemetry/domain/ports/private-storage.js"; +import type { ReportAxis } from "../../../../../src/contexts/telemetry/domain/report/usage-report.js"; +import { repositoryIdOf } from "../../../../../src/contexts/telemetry/domain/repository-identity.js"; +import { + renderResolutions, + resolutionKey, +} from "../../../../../src/contexts/telemetry/domain/repository-resolution.js"; +import { dayOf } from "../../../../../src/contexts/telemetry/domain/stored-usage.js"; +import { foldUsage } from "../../../../../src/contexts/telemetry/domain/usage-fold.js"; +import { BindingSnapshotStoreAdapter } from "../../../../../src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.js"; +import { RepositoryAliasStoreAdapter } from "../../../../../src/contexts/telemetry/infrastructure/legacy/repository-alias-store-adapter.js"; +import { ResolutionStoreAdapter } from "../../../../../src/contexts/telemetry/infrastructure/resolution-store-adapter.js"; +import { UsageLedgerAdapter } from "../../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; +import { PrivateStorageAdapter } from "../../../../../src/runtime/filesystem/private-storage-adapter.js"; +import { FailingStorage } from "../../../../helpers/ports/failing-private-storage.js"; +import { + cloneOf, + FakeBindings, + FakeConsents, + FakeLocator, + grantedBy, + InMemoryBindingsLock, + InMemoryConsentHistory, + InMemoryIdentity, + InMemorySessions, + InMemorySnapshots, + InMemoryTranscripts, +} from "../../../../helpers/ports/in-memory-telemetry.js"; + +let root: string; +let runs = 0; +function named(prefix: string): string { + runs += 1; + return `${prefix}-${runs}`; +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "aidd-migration-")); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +const NOW = new Date("2026-10-20T00:00:00.000Z"); + +/** Clones that consent, by their origin: three spellings of one GitHub repository, a host + * that keeps its path's case, and a repository with no origin. */ +const PLACES: Readonly> = { + "/work/a": "git@github.com:Acme/Tool.git", + "/work/b": "ssh://git@GitHub.com:22/acme/TOOL.git/", + "/work/c": "https://github.com/acme/tool", + "/work/d": "ssh://git@Git.Example.org/Team/Repo.git", + "/work/e": null, +}; +const DIRS = Object.keys(PLACES); +const facts = (dir: string) => ({ remote: PLACES[dir] ?? null, rootCommit: "c0ffee" }); +const cloneAt = (dir: string): CloneIdentity => cloneOf(`${dir}/.git`, { ino: dir }); + +/** The copies of one call carry these times: either side of a midnight, or two on one day. */ +const TIMES = [ + ["2026-10-07T23:59:59.000Z", "2026-10-08T00:00:01.000Z", "2026-10-08T00:00:03.000Z"], + ["2026-10-08T12:00:00.000Z", "2026-10-08T12:00:04.000Z"], + ["2026-10-31T23:59:58.000Z", "2026-11-01T00:00:02.000Z"], +]; + +interface Copy { + readonly call: number; + readonly time: number; + readonly output: number; + /** Read by an earlier version, before the migration, rather than after it. */ + readonly before: boolean; +} + +const copy = fc.record({ + call: fc.nat(11), + time: fc.nat(2), + output: fc.nat(3), + before: fc.boolean(), +}); + +/** A call is made in one place, by one session, with or without a request id, on or off the + * declared branch, and with or without an advisor: only its copies' times and counts differ. */ +function lineOf({ call, time, output }: Copy): string { + const dir = DIRS[call % DIRS.length] as string; + const times = TIMES[call % TIMES.length] as string[]; + const requestless = call % 2 === 0; + const advisor = call % 3 === 0; + return JSON.stringify({ + type: "assistant", + sessionId: `s-${call % 4}`, + ...(requestless ? {} : { requestId: `req_${call}` }), + timestamp: times[time % times.length], + cwd: dir, + gitBranch: call % 5 === 0 ? null : "feat/x", + message: { + id: `msg_${call}`, + model: "m", + usage: { + input_tokens: 1, + output_tokens: output, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + ...(advisor + ? { + iterations: [ + { type: "message", cache_creation_input_tokens: 0 }, + { type: "advisor_message", model: "a", input_tokens: 2, output_tokens: output }, + ], + } + : {}), + }, + }, + }); +} + +/** What cli-v5.5.0 keyed a call by: a requestless call by its own timestamp too. */ +function legacyKey(line: string, key: string): string { + const parsed = JSON.parse(line); + if (parsed.requestId !== undefined) return key; + const advisor = key.match(/#advisor\d+$/)?.[0] ?? ""; + return `${key.slice(0, key.length - advisor.length)}:${parsed.timestamp}${advisor}`; +} + +const scenario = fc.record({ + copies: fc.array(copy, { minLength: 1, maxLength: 24 }), + /** How the earlier version's reads were cut: each copy read before goes into one of them. */ + batches: fc.array(fc.nat(3), { minLength: 24, maxLength: 24 }), + /** Whether October is still in the earlier monthly layout. */ + monthly: fc.boolean(), +}); +type Scenario = typeof scenario extends fc.Arbitrary ? T : never; + +/** Writes, as cli-v5.5.0 left them, the ledger, `roots.json` and branch snapshots of the copies + * it read: each read's calls folded by its own keys and ids, appended to their days, so a call + * can sit in several days and a day hold a call twice. */ +async function writeEarlierStore(dir: string, bindings: string, s: Scenario): Promise { + await mkdir(dir, { recursive: true }); + await mkdir(bindings, { recursive: true }); + const before = s.copies.map((c, n) => ({ c, n })).filter(({ c }) => c.before); + for (let batch = 0; batch < 4; batch += 1) { + const read = before + .filter(({ n }) => s.batches[n] === batch) + .flatMap(({ c }) => { + const line = lineOf(c); + const dirOf = JSON.parse(line).cwd as string; + return readClaudeUsageLine(line).records.map((record) => ({ + ...record, + key: legacyKey(line, record.key), + repository_id: legacyRepositoryIdOf(facts(dirOf)) as string, + })); + }); + for (const stored of foldUsage(read)) { + const day = dayOf(stored.at) as string; + const name = s.monthly && day.startsWith("2026-10") ? day.slice(0, 7) : day; + await appendFile(join(dir, `${name}.jsonl`), `${JSON.stringify(stored)}\n`); + } + } + await writeRoots(dir, legacyRepositoryIdOf); + await writeSnapshots(bindings, legacyRepositoryIdOf); +} + +/** Every clone, as its `on` recorded it, under the id the rule gives. */ +async function writeRoots(dir: string, idOf: typeof repositoryIdOf): Promise { + await mkdir(dir, { recursive: true }); + const seen = DIRS.map((d) => ({ + dir: d, + repository_id: idOf(facts(d)) as string, + root: d, + clone: cloneAt(d), + seen_at: NOW.toISOString(), + })); + await writeFile( + join(dir, "roots.json"), + renderResolutions(new Map(seen.map((r) => [resolutionKey(r.dir, r.clone), r]))) + ); +} + +/** One declaration of `feat/x` per clone, the task named after the clone, under the id the + * rule gives: the clones of one repository share one history. */ +async function writeSnapshots(bindings: string, idOf: typeof repositoryIdOf): Promise { + await mkdir(bindings, { recursive: true }); + const lines = DIRS.map((d, n) => + JSON.stringify({ + branch: "feat/x", + task: `task-${d.slice(-1)}`, + ticket: null, + declared_at: `2026-10-0${n + 1}T00:00:00.000Z`, + none: false, + repository_id: idOf(facts(d)), + branch_created_at: "2026-09-01T00:00:00.000Z", + snapshot_at: `2026-10-0${n + 1}T00:00:00.000Z`, + }) + ); + await writeFile(join(bindings, "branches.jsonl"), `${lines.join("\n")}\n`); +} + +function machine(name: string, storage: PrivateStorage = new PrivateStorageAdapter()) { + const dir = join(root, name, "ledger"); + const bindings = join(root, name, "bindings"); + const ledger = new UsageLedgerAdapter(dir, storage); + const aliases = new RepositoryAliasStoreAdapter(dir, storage); + const resolutions = new ResolutionStoreAdapter(dir, storage); + const transcripts = new InMemoryTranscripts(); + const locator = new FakeLocator(); + const consents = new FakeConsents(); + const history = new InMemoryConsentHistory(); + for (const d of DIRS) { + const clone = cloneAt(d); + locator.directories.set(d, { + status: "repository", + root: d, + mainRoot: d, + clone, + ...facts(d), + }); + consents.cloneSays(clone, grantedBy(clone)); + history.consented(clone); + } + const snapshots = new BindingSnapshotStoreAdapter(bindings, storage, aliases); + const ingest = new IngestUsageUseCase( + [new ReadUsageUseCase(transcripts, readClaudeUsageLine)], + ledger, + new DirectoryResolver(locator, consents, resolutions, history, { + caseInsensitiveFileSystem: false, + now: () => NOW, + }), + new SnapshotBindingsUseCase( + new FakeBindings(), + new InMemorySnapshots(), + new InMemoryBindingsLock(), + () => NOW + ), + { refusedByEnvironment: false }, + new StoredUsageMigration(locator, resolutions, aliases, ledger) + ); + const report = new ReportUsageUseCase( + ingest, + ledger, + new DeclaredBindings(new InMemorySessions(), snapshots), + new InMemoryIdentity(), + locator, + { refusedByEnvironment: false } + ); + return { dir, bindings, transcripts, report }; +} + +const AXES: readonly ReportAxis[] = ["total", "repository", "task", "session", "agent", "day"]; + +/** Every axis, as the envelope prints it. */ +async function figures(m: ReturnType): Promise { + const out: string[] = []; + for (const axis of AXES) { + const result = await m.report.execute({ axis, period: { from: null, to: null } }); + if (result.status !== "reported") throw new Error(result.status); + out.push(JSON.stringify(reportEnvelopeOf(result))); + } + return out; +} + +/** Every file under the ledger, but the offsets, the lock and the migration's own table. */ +async function bytes(dir: string): Promise> { + const found: Record = {}; + for (const entry of await readdir(dir, { withFileTypes: true })) { + if (!entry.isFile() || ["offsets.json", ".lock", "repository-ids.json"].includes(entry.name)) + continue; + found[entry.name] = await readFile(join(dir, entry.name), "utf8"); + } + return Object.fromEntries(Object.entries(found).sort(([a], [b]) => (a < b ? -1 : 1))); +} + +/** The machine an earlier version left, now run by this one: the copies it read are stored as + * it stored them, the rest are still to be read. */ +async function upgraded(s: Scenario, storage?: PrivateStorage, name = named("up")) { + const m = machine(name, storage); + await writeEarlierStore(m.dir, m.bindings, s); + m.transcripts.files.set("/t/0.jsonl", s.copies.filter((c) => !c.before).map(lineOf)); + return { ...m, name }; +} + +/** One fresh ingest, under this version's rules, of every copy. */ +async function fresh(s: Scenario) { + const m = machine(named("fresh")); + await writeSnapshots(m.bindings, repositoryIdOf); + await writeRoots(m.dir, repositoryIdOf); + m.transcripts.files.set("/t/0.jsonl", s.copies.map(lineOf)); + return m; +} + +describe("stored data from an earlier version, once migrated", () => { + it("reports, and lies on disk, as one fresh ingest of the same copies under the rules of now", async () => { + await fc.assert( + fc.asyncProperty(scenario, async (s) => { + const up = await upgraded(s); + const one = await fresh(s); + await figures(up); + await figures(one); + expect(await figures(up)).toEqual(await figures(one)); + expect(await bytes(up.dir)).toEqual(await bytes(one.dir)); + }), + { numRuns: 60 } + ); + }, 240_000); + + it("migrated twice is migrated once", async () => { + await fc.assert( + fc.asyncProperty(scenario, async (s) => { + const up = await upgraded(s); + await figures(up); + const once = await bytes(up.dir); + const table = await readFile(join(up.dir, "repository-ids.json"), "utf8"); + const again = machine(up.name); + await figures(again); + expect(await bytes(up.dir)).toEqual(once); + expect(await readFile(join(up.dir, "repository-ids.json"), "utf8")).toBe(table); + }), + { numRuns: 30 } + ); + }, 240_000); + + it("stopped at any write and run again, ends as a run never stopped", async () => { + await fc.assert( + fc.asyncProperty(scenario, async (s) => { + const clean = await upgraded(s); + await figures(clean); + const expected = { bytes: await bytes(clean.dir), figures: await figures(clean) }; + for (let failAt = 1; ; failAt += 1) { + const name = named("crash"); + const crashed = await upgraded(s, new FailingStorage(failAt), name); + const stopped = await crashed.report + .execute({ axis: "total", period: { from: null, to: null } }) + .then( + () => false, + () => true + ); + const rerun = machine(name); + rerun.transcripts.files.set( + "/t/0.jsonl", + crashed.transcripts.files.get("/t/0.jsonl") ?? [] + ); + await figures(rerun); + expect(await figures(rerun)).toEqual(expected.figures); + expect(await bytes(rerun.dir)).toEqual(expected.bytes); + if (!stopped) break; + } + }), + { numRuns: 12 } + ); + }, 240_000); +}); diff --git a/cli/tests/contexts/telemetry/domain/legacy/legacy-repository-identity.unit.test.ts b/cli/tests/contexts/telemetry/domain/legacy/legacy-repository-identity.unit.test.ts new file mode 100644 index 000000000..3f6b04205 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/legacy/legacy-repository-identity.unit.test.ts @@ -0,0 +1,28 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { legacyRepositoryIdOf } from "../../../../../src/contexts/telemetry/domain/legacy/legacy-repository-identity.js"; + +const sha = (text: string): string => createHash("sha256").update(text).digest("hex"); + +/** What cli-v5.5.0 stored: the migration finds a stored id by computing it again. */ +describe("the repository id an earlier version stored", () => { + it.each([ + ["git@github.com:Acme/Widgets.git", "github.com/Acme/Widgets"], + ["git@GITHUB.com:acme/widgets", "github.com/acme/widgets"], + ["https://GitHub.COM:443/Acme/widgets.git/", "github.com/Acme/widgets"], + ["ssh://git@GitHub.COM:22/acme/widgets.git", "GitHub.COM/acme/widgets"], + ["git://GitHub.com/acme/widgets", "GitHub.com/acme/widgets"], + ["ssh://git@ssh.github.com:443/acme/widgets.git", "ssh.github.com/acme/widgets"], + ["https://u:p@git.example.org/Team/Repo.git", "git.example.org/Team/Repo"], + ])("of %s hashes %s", (remote, address) => { + expect(legacyRepositoryIdOf({ remote, rootCommit: "c0ffee" })).toBe(sha(address)); + }); + + it("is the root commit with no hosted origin, or none with neither", () => { + expect(legacyRepositoryIdOf({ remote: "/srv/x.git", rootCommit: "c0ffee" })).toBe("c0ffee"); + expect(legacyRepositoryIdOf({ remote: "https://github.com/x", rootCommit: "c0ffee" })).toBe( + "c0ffee" + ); + expect(legacyRepositoryIdOf({ remote: null, rootCommit: null })).toBeNull(); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts b/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts new file mode 100644 index 000000000..adbe68fc4 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { currentKeyOf } from "../../../../../src/contexts/telemetry/domain/legacy/legacy-usage-key.js"; + +const AT = "2026-10-07T10:00:01.000Z"; +const call = (key: string, at = AT, session_id = "s-1") => ({ key, at, session_id }); + +describe("a key an earlier version stored, as it is keyed now", () => { + it("drops the timestamp from a requestless key", () => { + expect(currentKeyOf(call(`msg_A:s-1:${AT}`))).toBe("msg_A:s-1"); + }); + + it("keeps an advisor iteration's suffix", () => { + expect(currentKeyOf(call(`msg_A:s-1:${AT}#advisor12`))).toBe("msg_A:s-1#advisor12"); + }); + + it("keeps a key with a request id, and one keyed now, as they are", () => { + expect(currentKeyOf(call("msg_A:req_A"))).toBe("msg_A:req_A"); + expect(currentKeyOf(call("msg_A:req_A#advisor0"))).toBe("msg_A:req_A#advisor0"); + expect(currentKeyOf(call("msg_A:s-1"))).toBe("msg_A:s-1"); + }); + + it("drops only the call's own time, after its own session", () => { + expect(currentKeyOf(call(`msg_A:s-2:${AT}`))).toBe(`msg_A:s-2:${AT}`); + expect(currentKeyOf(call(`msg_A:s-1:${AT}`, "2026-10-07T10:00:02.000Z"))).toBe( + `msg_A:s-1:${AT}` + ); + expect(currentKeyOf(call(`msg_A:xs-1:${AT}`))).toBe(`msg_A:xs-1:${AT}`); + expect(currentKeyOf(call(`s-1:${AT}`))).toBe(`s-1:${AT}`); + }); + + it("re-keys at most once", () => { + const once = currentKeyOf(call(`msg_A:s-1:${AT}#advisor0`)); + expect(currentKeyOf(call(once))).toBe(once); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/legacy/repository-aliases.unit.test.ts b/cli/tests/contexts/telemetry/domain/legacy/repository-aliases.unit.test.ts new file mode 100644 index 000000000..6454f968b --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/legacy/repository-aliases.unit.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + parseRepositoryAliases, + renderRepositoryAliases, +} from "../../../../../src/contexts/telemetry/domain/legacy/repository-aliases.js"; + +describe("the table of ids an earlier version gave repositories", () => { + it("reads back what it wrote, its ids in text order", () => { + const ids = new Map([ + ["b", "y"], + ["a", "x"], + ]); + const text = renderRepositoryAliases({ ids, complete: false }); + expect(text).toBe( + `${JSON.stringify({ version: 1, complete: false, aliases: { a: "x", b: "y" } }, null, 2)}\n` + ); + expect(parseRepositoryAliases(text)).toEqual({ ids, complete: false }); + }); + + it.each([ + ["missing", null], + ["not JSON", "{"], + ["another format", JSON.stringify({ version: 2, complete: true, aliases: {} })], + ["no completion", JSON.stringify({ version: 1, aliases: {} })], + ["a completion that is no boolean", JSON.stringify({ version: 1, complete: 1, aliases: {} })], + ["no table", JSON.stringify({ version: 1, complete: true })], + ["an id that is no text", JSON.stringify({ version: 1, complete: true, aliases: { a: 1 } })], + ["an empty id", JSON.stringify({ version: 1, complete: true, aliases: { a: "x", b: "" } })], + ])("is none when %s, so it is made again", (_name, text) => { + expect(parseRepositoryAliases(text)).toBeNull(); + }); +}); diff --git a/cli/tests/contexts/telemetry/infrastructure/stored-ids-and-keys.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/stored-ids-and-keys.integration.test.ts new file mode 100644 index 000000000..bba9a055f --- /dev/null +++ b/cli/tests/contexts/telemetry/infrastructure/stored-ids-and-keys.integration.test.ts @@ -0,0 +1,254 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { StoredUsageMigration } from "../../../../src/contexts/telemetry/application/legacy/stored-usage-migration.js"; +import { legacyRepositoryIdOf } from "../../../../src/contexts/telemetry/domain/legacy/legacy-repository-identity.js"; +import type { PrivateStorage } from "../../../../src/contexts/telemetry/domain/ports/private-storage.js"; +import { repositoryIdOf } from "../../../../src/contexts/telemetry/domain/repository-identity.js"; +import { renderResolutions } from "../../../../src/contexts/telemetry/domain/repository-resolution.js"; +import { BindingSnapshotStoreAdapter } from "../../../../src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.js"; +import { RepositoryAliasStoreAdapter } from "../../../../src/contexts/telemetry/infrastructure/legacy/repository-alias-store-adapter.js"; +import { ResolutionStoreAdapter } from "../../../../src/contexts/telemetry/infrastructure/resolution-store-adapter.js"; +import { UsageLedgerAdapter } from "../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; +import { PrivateStorageAdapter } from "../../../../src/runtime/filesystem/private-storage-adapter.js"; +import { FailingStorage } from "../../../helpers/ports/failing-private-storage.js"; +import { cloneOf, FakeLocator } from "../../../helpers/ports/in-memory-telemetry.js"; + +let dir: string; +let bindings: string; +const storage = new PrivateStorageAdapter(); + +beforeEach(async () => { + const root = await mkdtemp(join(tmpdir(), "aidd-ids-")); + dir = join(root, "ledger"); + bindings = join(root, "bindings"); + await mkdir(dir, { recursive: true }); + await mkdir(bindings, { recursive: true }); +}); +afterEach(async () => { + await rm(join(dir, ".."), { recursive: true, force: true }); +}); + +const MIXED = { remote: "git@github.com:Acme/Tool.git", rootCommit: "c0ffee" }; +const OLD = legacyRepositoryIdOf(MIXED) as string; +const NEW = repositoryIdOf(MIXED) as string; + +function call(overrides: Record = {}) { + return { + tool: "claude-code", + tool_version: "2", + key: "msg_A:s-1:2026-10-07T23:59:59.000Z", + session_id: "s-1", + agent: "main", + at: "2026-10-07T23:59:59.000Z", + model: "m", + input: 1, + output: 1, + cache_read: 0, + cache_write: 0, + cache_write_1h: null, + reasoning: null, + cwd: "/work/a", + git_branch: null, + repository_id: OLD, + ...overrides, + }; +} + +async function writeAliases(complete = true): Promise { + await writeFile( + join(dir, "repository-ids.json"), + `${JSON.stringify({ version: 1, complete, aliases: { [OLD]: NEW } }, null, 2)}\n` + ); +} + +describe("the ledger, read after the rules changed", () => { + it("re-keys a requestless call's copies stored apart, re-ids them, and counts the call once", async () => { + await writeAliases(); + await writeFile(join(dir, "2026-10-07.jsonl"), `${JSON.stringify(call())}\n`); + const later = call({ + key: "msg_A:s-1:2026-10-08T00:00:02.000Z", + at: "2026-10-08T00:00:02.000Z", + output: 9, + }); + await writeFile(join(dir, "2026-10-08.jsonl"), `${JSON.stringify(later)}\n`); + const ledger = new UsageLedgerAdapter(dir, storage); + const held = await ledger.load(); + expect(held.records).toEqual([{ ...later, key: "msg_A:s-1", repository_id: NEW }]); + expect([...held.unsettled].sort()).toEqual(["2026-10-07", "2026-10-08"]); + await ledger.save(held.records, held.unsettled); + expect(await readFile(join(dir, "2026-10-08.jsonl"), "utf8")).toBe( + `${JSON.stringify({ ...later, key: "msg_A:s-1", repository_id: NEW })}\n` + ); + await expect(readFile(join(dir, "2026-10-07.jsonl"), "utf8")).rejects.toThrow(); + }); + + it("folds a line an earlier version writes after the migration into the call it is a copy of", async () => { + await writeAliases(); + const now = call({ key: "msg_A:s-1", repository_id: NEW, output: 5 }); + await writeFile( + join(dir, "2026-10-07.jsonl"), + `${JSON.stringify(now)}\n${JSON.stringify(call({ output: 2 }))}\n` + ); + const held = await new UsageLedgerAdapter(dir, storage).load(); + expect(held.records).toEqual([now]); + }); + + it("leaves a call keyed and named by the rules of now as settled", async () => { + await writeAliases(); + const now = call({ key: "msg_A:req_A", repository_id: NEW }); + await writeFile(join(dir, "2026-10-07.jsonl"), `${JSON.stringify(now)}\n`); + const held = await new UsageLedgerAdapter(dir, storage).load(); + expect(held.records).toEqual([now]); + expect(held.unsettled.size).toBe(0); + }); +}); + +describe("roots.json and branch snapshots, read after the rules changed", () => { + const clone = cloneOf("/work/a/.git"); + const seen = { dir: "/work/a", root: "/work/a", clone, seen_at: "2026-10-01T00:00:00.000Z" }; + + it("name each repository by its id of now", async () => { + await writeAliases(); + await writeFile( + join(dir, "roots.json"), + renderResolutions(new Map([["k", { ...seen, repository_id: OLD }]])) + ); + const loaded = await new ResolutionStoreAdapter(dir, storage).load(); + expect([...loaded.values()].map((r) => r.repository_id)).toEqual([NEW]); + }); + + it("join an earlier id's snapshots to the history of its id of now, the file untouched", async () => { + await writeAliases(); + const snapshot = (id: string, task: string) => + JSON.stringify({ + branch: "feat/x", + task, + ticket: null, + declared_at: "2026-10-01T00:00:00.000Z", + none: false, + repository_id: id, + branch_created_at: null, + snapshot_at: "2026-10-01T00:00:00.000Z", + }); + const text = `${snapshot(OLD, "one")}\n${snapshot(NEW, "two")}\n`; + await writeFile(join(bindings, "branches.jsonl"), text); + const store = new BindingSnapshotStoreAdapter( + bindings, + storage, + new RepositoryAliasStoreAdapter(dir, storage) + ); + const history = await store.history(); + expect([...history.keys()]).toEqual([`${NEW}\u0000feat/x`]); + expect(history.get(`${NEW}\u0000feat/x`)?.map((s) => s.task)).toEqual(["one", "two"]); + expect((await store.latest()).get(`${NEW}\u0000feat/x`)?.task).toBe("two"); + expect(await readFile(join(bindings, "branches.jsonl"), "utf8")).toBe(text); + }); +}); + +describe("the migration", () => { + function migration(locator: FakeLocator, store: PrivateStorage = storage) { + const ledger = new UsageLedgerAdapter(dir, store); + return new StoredUsageMigration( + locator, + new ResolutionStoreAdapter(dir, store), + new RepositoryAliasStoreAdapter(dir, store), + ledger + ); + } + const live = (remote: string | null) => ({ + status: "repository" as const, + root: "/work/a", + mainRoot: "/work/a", + clone: cloneOf("/work/a/.git"), + remote, + rootCommit: "c0ffee", + }); + const rooted = async (...roots: string[]) => + writeFile( + join(dir, "roots.json"), + renderResolutions( + new Map( + roots.map((r) => [ + r, + { + dir: r, + root: r, + repository_id: OLD, + clone: cloneOf(`${r}/.git`), + seen_at: "2026-10-01T00:00:00.000Z", + }, + ]) + ) + ) + ); + const table = async () => JSON.parse(await readFile(join(dir, "repository-ids.json"), "utf8")); + + it("maps the id an earlier version gave each live root's origin to the id of now, and rewrites roots.json and the ledger", async () => { + await rooted("/work/a", "/work/gone"); + await writeFile(join(dir, "2026-10-07.jsonl"), `${JSON.stringify(call())}\n`); + const locator = new FakeLocator(); + locator.directories.set("/work/a", live(MIXED.remote)); + await migration(locator).execute(); + expect(await table()).toEqual({ version: 1, complete: true, aliases: { [OLD]: NEW } }); + expect(locator.asked).toEqual(["/work/a", "/work/gone"]); + expect(await readFile(join(dir, "roots.json"), "utf8")).not.toContain(OLD); + expect(await readFile(join(dir, "2026-10-07.jsonl"), "utf8")).toBe( + `${JSON.stringify(call({ key: "msg_A:s-1", repository_id: NEW }))}\n` + ); + }); + + it("maps nothing for an origin both rules name alike, nor for a root with no origin", async () => { + await rooted("/work/a", "/work/b", "/work/c"); + const locator = new FakeLocator(); + locator.directories.set("/work/a", live("https://github.com/acme/tool")); + locator.directories.set("/work/b", live(null)); + locator.directories.set("/work/c", { ...live(null), rootCommit: null }); + await migration(locator).execute(); + expect(await table()).toEqual({ version: 1, complete: true, aliases: {} }); + }); + + it("leaves no roots.json where there was none", async () => { + await migration(new FakeLocator()).execute(); + await expect(readFile(join(dir, "roots.json"), "utf8")).rejects.toThrow(); + expect(await table()).toEqual({ version: 1, complete: true, aliases: {} }); + }); + + it("asks git nothing and rewrites nothing once complete", async () => { + await rooted("/work/a"); + await writeAliases(true); + const earlier = `${JSON.stringify(call())}\n`; + await writeFile(join(dir, "2026-10-07.jsonl"), earlier); + const roots = await readFile(join(dir, "roots.json"), "utf8"); + const locator = new FakeLocator(); + await migration(locator).execute(); + expect(locator.asked).toEqual([]); + expect(await readFile(join(dir, "2026-10-07.jsonl"), "utf8")).toBe(earlier); + expect(await readFile(join(dir, "roots.json"), "utf8")).toBe(roots); + }); + + it("finishes an unfinished one with its own table, asking git nothing", async () => { + await rooted("/work/a"); + await writeAliases(false); + await writeFile(join(dir, "2026-10-07.jsonl"), `${JSON.stringify(call())}\n`); + const locator = new FakeLocator(); + await migration(locator).execute(); + expect(locator.asked).toEqual([]); + expect(await table()).toMatchObject({ complete: true, aliases: { [OLD]: NEW } }); + expect(await readFile(join(dir, "2026-10-07.jsonl"), "utf8")).toContain(NEW); + }); + + it("writes its table before anything else, and says it is complete last", async () => { + await rooted("/work/a"); + await writeFile(join(dir, "2026-10-07.jsonl"), `${JSON.stringify(call())}\n`); + const locator = new FakeLocator(); + locator.directories.set("/work/a", live(MIXED.remote)); + await expect(migration(locator, new FailingStorage(2)).execute()).rejects.toThrow(); + expect(await table()).toEqual({ version: 1, complete: false, aliases: { [OLD]: NEW } }); + // Rerun: roots.json, the day, then the table marked complete, which fails. + await expect(migration(new FakeLocator(), new FailingStorage(3)).execute()).rejects.toThrow(); + expect(await readFile(join(dir, "2026-10-07.jsonl"), "utf8")).toContain(NEW); + expect((await table()).complete).toBe(false); + }); +}); diff --git a/cli/tests/e2e/telemetry-ingest.e2e.test.ts b/cli/tests/e2e/telemetry-ingest.e2e.test.ts index c5e06c4ba..b91e7caff 100644 --- a/cli/tests/e2e/telemetry-ingest.e2e.test.ts +++ b/cli/tests/e2e/telemetry-ingest.e2e.test.ts @@ -134,6 +134,7 @@ describe("aidd telemetry ingest", () => { "2026-10-07.jsonl", "consents.jsonl", "offsets.json", + "repository-ids.json", "roots.json", ]); }); From 4a8c7d38b52ad1d8c13dc26536371ddeef5209f7 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 08:35:03 +0200 Subject: [PATCH 04/19] docs(telemetry): the key, id and migration rules, and their evidence The usage contract states the requestless key, the repository address rule and the new ledger/repository-ids.json store; the CLI memory points at the migration. The task folder holds the contract with the store inventory, the plan, the red runs and the hand mutations. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- aidd_docs/product/usage-contract.md | 7 +- .../contract.md | 98 ++++++++++++++ .../evidence.md | 120 ++++++++++++++++++ .../phase-1.md | 21 +++ .../phase-2.md | 16 +++ .../phase-3.md | 25 ++++ .../phase-4.md | 12 ++ .../plan.md | 33 +++++ .../review.md | 7 + cli/aidd_docs/memory/telemetry.md | 1 + 10 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-1.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-2.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-3.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-4.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/plan.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md index 92d4f8420..046d35e2e 100644 --- a/aidd_docs/product/usage-contract.md +++ b/aidd_docs/product/usage-contract.md @@ -56,7 +56,7 @@ The ledger stores `StoredUsage`: the record plus `repository_id`, resolved at in | Rule | Value | | --- | --- | -| Key | `message.id:requestId`; without `requestId`, `message.id:sessionId:timestamp`; each advisor iteration adds `#advisor` | +| Key | `message.id:requestId`; without `requestId`, `message.id:sessionId` (a message id is unique per call); each advisor iteration adds `#advisor` | | Fold | a call is written several times (streamed snapshots, resumed sessions, sub-agents); one record per `(tool, key)`: largest sum of the four counters (`null` as `0` for this comparison only) | | Tie | earliest `at`, then lowest `session_id`, then serialised record. No input order changes the winner | | Dropped | model `` | @@ -64,7 +64,7 @@ The ledger stores `StoredUsage`: the record plus `repository_id`, resolved at in | Advisor | each `advisor_message` iteration is a record, `agent: advisor`, own model | | Sub-agent | `isSidechain` or an `agentId` | -**Repository.** The call's working directory resolved at ingest: its git root, named by the hash of the `origin` address (`host/owner/repo`), else the root commit. The address is never kept. +**Repository.** The call's working directory resolved at ingest: its git root, named by the hash of the `origin` address (`host/owner/repo`), else the root commit. The address is never kept. Every form (https, `ssh://`, `git://`, scp-like) gives one address: scheme, credentials, port, `.git` and trailing `/` dropped, host case-folded, `ssh.github.com` read as `github.com`. Owner and repo are case-folded on `github.com` only, which documents them as not case sensitive; any other host keeps their case, so two spellings there may still give two ids. **Not stored.** Storing follows [Consent](#consent). A call read and left out counts in the run's `coverage.not_stored` under one reason, and nothing of it is kept, except an `undated` call of a clone that consents (see [Left out](#left-out)): @@ -162,13 +162,14 @@ Directory: `AIDD_TELEMETRY_DIR`, else `AIDD_USER_CONFIG_DIR/telemetry`, else `XD | --- | --- | --- | --- | | `ledger/.jsonl` | `ingest`, `report` | `report`, `forget` | `StoredUsage` by UTC day of `at`, in time then key order, one call per line. Ingest reads its calls' days and the day on each side, and rewrites only a changed day, whole, by rename; no new bytes, no write. `report` rewrites, folded, the partitions it read that share a call or hold a line that is not a record; a report with no period reads every partition, so after it each call is on disk once. A line that is not a record (a newer version's, a hand edit, a torn one) is never removed: a rewrite keeps it in its day, after the records, in text order and once, and counts it in `skipped_ledger_lines` on every read. A day holding only such lines stays. A monthly `.jsonl` from an earlier version is still read, split into its days by the first ingest or report whose window touches it, or by a report with no period, then removed; its lines that are not records go to the first day of its month | | `ledger/left-out.txt` | `ingest` | `report`, `forget` | what ingests measured and could not store, by hash, see [Left out](#left-out). cli-v5.5.0 never reads it | +| `ledger/repository-ids.json` | `ingest`, `report` | every reader of the ledger, `roots.json` and `branches.jsonl` | `{version: 1, complete, aliases}`: the id an earlier version gave a repository, to its id now, both hashes or root commits. Made once, from each root `roots.json` names, by its `origin` now. Every read re-keys a call stored as `message.id:sessionId:timestamp` and re-ids by this table; the first ingest or report also rewrites `roots.json` and every ledger partition so, then marks it `complete`. A run stopped part way is finished by the next with the same table. An earlier version reading migrated data reads it as written; what it stores afterwards is re-keyed, and re-ided when its repository is in the table, at every read. A repository none of whose clones could be read then keeps its earlier id, and an `undated` hash in `left-out.txt` keeps its earlier key | | `ledger/offsets.json` | `ingest`, `report` | same | per transcript: bytes consumed, size, file identity. Shrunk or changed identity: read whole | | `ledger/roots.json` | `ingest`, `on` | `ingest`, `forget` | `{version: 2, directories}`: per directory and per clone seen alive there, `repository_id`, `root`, `clone`, `seen_at`, for every directory a read call or line named, in any clone. Other format holds nothing: a live directory is remembered again, a gone one counts `never-seen-alive`. `on` records its own root for `forget` | | `ledger/consents.jsonl` | `on`, `off`, `ingest`, hooks (close only) | same, plus `forget` | consent intervals, see [Consent](#consent). Outlives the clone; removed with the ledger. Hooks parse it strictly, pinned by the shared fixture | | `ledger/.lock` | CLI | CLI | `{pid, created_at}`; dead process or older than ten minutes: cleared | | `bindings/.lock` | CLI | CLI | same format. Declarations and snapshots run under it; `task` never takes the ledger lock, which an ingest holds while reading transcripts | | `bindings/sessions.jsonl` | `task` | hooks, `report`, `task` | `{session_id, task, ticket, none, declared_at, by}`, `by` = `command` \| `hook-intercept` | -| `bindings/branches.jsonl` | `task`, `ingest`, `report` | `report`, `forget` | branch declaration at a moment, with when the branch took its name, from its reflog | +| `bindings/branches.jsonl` | `task`, `ingest`, `report` | `report`, `forget` | branch declaration at a moment, with when the branch took its name, from its reflog. Read with `repository-ids.json` applied, never rewritten | | `bindings/carries.jsonl` | `SessionStart` hook | hooks, `report` | `{session_id, from, at}` | | `bindings/processes.jsonl` | `SessionStart` hook | `SessionStart` hook | `{pid, session_id, source, at}` | | `identity.json` | `identity`, `on` | `report`, `status`, `on` | `{"person_id": ""}`, owner-only | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md new file mode 100644 index 000000000..10eebd8c6 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md @@ -0,0 +1,98 @@ +--- +status: done +--- + +# One call, one key; one repository, one id + +## Need + +Found by the R1 telemetry audit. Maintainer decision: migrate the stored data, the measurement is a beta. + +| # | Hole | Where | +| --- | --- | --- | +| M7 | One repository gets several ids. The owner and repository keep their case on every host, and an `ssh://` or `git://` host keeps its case too (WHATWG `URL` folds only special schemes): `git@github.com:Example/X.git`, `ssh://git@GitHub.com/example/x` and `https://github.com/example/x` give three ids. | `domain/repository-identity.ts` | +| M10 | A call with no `requestId` is keyed `message.id:sessionId:timestamp`. Its copies carry different timestamps, so they are never folded and the call is counted once per copy. | `domain/formats/claude-transcript-usage.ts` | + +## Rules, before and after + +| Rule | Before (cli-v5.5.0 to `616dbbea`) | After | +| --- | --- | --- | +| Scheme | dropped | dropped | +| Userinfo | dropped | dropped | +| Port | dropped (a scp-like address has none) | dropped | +| Host | case-folded for `http(s)`, `ws(s)`, `ftp` and scp-like; kept for `ssh://`, `git://`, `git+ssh://` | case-folded on every form ([RFC 3986 §3.2.2](https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2): the host is case-insensitive) | +| `ssh.github.com` | its own host | `github.com`: GitHub's SSH over port 443 answers there for the same repositories ([GitHub docs](https://docs.github.com/en/authentication/troubleshooting-ssh/using-ssh-over-the-https-port)) | +| Owner and repository | case kept | `github.com`: case-folded (GitHub REST: owner and repo, "The name is not case sensitive", [docs](https://docs.github.com/en/rest/repos/repos#get-a-repository)). Any other host, GitHub Enterprise included: case kept, since no primary source says otherwise there; two spellings may then still give two ids, never one id for two repositories | +| `.git` suffix, leading and trailing `/` | dropped | dropped, after case-folding where the host folds it | +| No origin | root commit | root commit | +| Key, with `requestId` | `message.id:requestId` | unchanged | +| Key, without `requestId` | `message.id:sessionId:timestamp` | `message.id:sessionId` | +| Advisor iteration | key + `#advisor` | unchanged | + +**Why `message.id:sessionId` is one key per call.** The Messages API: `id` is a "Unique object identifier" of the message ([reference](https://platform.claude.com/docs/en/api/messages)), so no two calls share a `message.id`, and none share it with a session added. The session stays in the key: dropping it, or `requestId`, would re-key every stored call and every `undated` hash, which this fix does not take on (see Limits). + +## Store inventory + +Everything under the telemetry directory, and what this fix does to it. + +| Store | Holds an id or a key | Migration | +| --- | --- | --- | +| `ledger/.jsonl` | `repository_id`, `key` | every read re-keys and re-ids; the first run rewrites them all, folded | +| `ledger/.jsonl` (earlier layout) | `repository_id`, `key` | same; split into days by that rewrite, as any full settle does | +| `ledger/left-out.txt` | `undated ` = hash of tool and key | none: the hash cannot be undone (see Limits). `unrecognised` hashes a line's text: no id, no key | +| `ledger/roots.json` | `repository_id` per directory and clone | every read re-ids; the first run rewrites it | +| `ledger/repository-ids.json` | earlier id to id, both hashes | new: the migration's journal and alias table | +| `ledger/offsets.json` | transcript path to position | none | +| `ledger/consents.jsonl` | token, clone, time | none | +| `ledger/.lock`, `bindings/.lock` | pid, time | none | +| `bindings/branches.jsonl` | `repository_id` per snapshot | every read re-ids; never rewritten, it stays append-only | +| `bindings/sessions.jsonl`, `carries.jsonl`, `processes.jsonl` | session ids | none | +| `identity.json` | person id | none | +| git config `aidd.telemetry`, `branch..aidd*` | token, task | none | +| plugin hooks (`plugins/aidd-telemetry/hooks/lib/`) | compute no repository id and no key | none | + +## Migration + +```mermaid +flowchart TD + S["ingest or report, under the ledger lock"] --> J{"repository-ids.json complete?"} + J -->|yes| I["ingest as usual"] + J -->|absent| M["locate each root roots.json names: earlier id to id, from its origin now"] + M --> W1["write repository-ids.json, not complete"] + J -->|not complete| R + W1 --> R["rewrite roots.json and every ledger partition: re-keyed, re-ided, folded"] + R --> W2["write repository-ids.json, complete"] + W2 --> I +``` + +| Requirement | How | +| --- | --- | +| Automatic | first step of every ingest, so of every report and of the plugin's hook | +| Deterministic | the alias table is a function of each root's `origin` now, written before anything else; a rerun reuses it | +| Idempotent | a key re-keys at most once (the new shape never ends in its own `:at`); an alias never names an id that is itself aliased: an earlier address differs from today's only by an upper-case letter or `ssh.github.com`, which no address of today holds | +| Crash-safe | under the ledger lock; every write is the existing write-then-rename; the table is written first and marked complete last, so a kill at any step leaves a state the next run completes | +| Read-time | every reader of the ledger, `roots.json` and `branches.jsonl` applies the key rule and the table, so a state the migration has not finished, or lines an older CLI writes afterwards, still count each call once under one id | +| No double count | copies of a call re-keyed alike fold, by the existing fold, to the copy one ingest would have kept | + +**An older CLI on migrated data** (cli-v5.5.0): the lines have the same shape, so it reads and reports them as they are, and ignores `repository-ids.json`. What it ingests afterwards it stores under its own rules; the newer CLI then re-keys it, and re-ids it when its repository is in the table, at every read. + +## Acceptance + +1. `normaliseRemote` folds every form in the table above to one address, and keeps owner and repo case for any host but `github.com`. +2. A requestless call's copies at different timestamps are one record. +3. For any pre-migration store (mixed-case ids, timestamped keys, copies across days, partitions and the earlier monthly layout), the migrated report and ledger bytes equal those of one fresh ingest under the new rules. +4. Migrating twice equals once; a failure at every write, then a rerun, equals a clean run. +5. The id and key in `roots.json` and the ledger are rewritten on disk by the first run; `branches.jsonl` is aliased at read. +6. Gates: `cd cli && pnpm test`, `node --test 'scripts/__tests__/**/*.test.js'`, `pnpm test:mutation:telemetry` at 96 or more, `pnpm smoke`, the tokens end-to-end checks, bundle within 709 KB. + +## Limits + +| Limit | Effect | +| --- | --- | +| A repository none of whose clones is readable at the migration | its earlier id stays; a later clone of it is stored under the new id: two ids | +| A root whose `origin` changed since its calls were stored | the table maps the address it has now, so those calls keep their id, as before | +| `undated` hashes in `left-out.txt` from before (unreleased: cli-v5.5.0 has no such file) | cannot be re-keyed; copies of one undated requestless call kept apart stay counted apart, and one read again from the start of its transcript adds a hash beside the old one | +| A requestless call copied into another session | two keys, as before; seen copies across sessions carry a `requestId`, keyed without the session | +| One call seen with and without a `requestId` | two keys, as before | +| A host alias from `~/.ssh/config` (`git@work-github:o/r`) | its own host: nothing on disk says what it stands for | +| Percent-encoding in a URL path | kept as written; a scp-like address is never encoded | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md new file mode 100644 index 000000000..9a19f8667 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md @@ -0,0 +1,120 @@ +# Evidence + +## Red runs, before any production change + +### Phases 1 and 2 (identity and key rules) + +`pnpm exec vitest run` on the identity, legacy and format tests: 15 failed, 92 passed. The two legacy test files failed to load (their modules did not exist yet); their behaviour went red under the hand mutations below. + +```txt +× owner and repo in another case gives one spelling + AssertionError: expected 'github.com/Acme/Widgets' to be 'github.com/acme/widgets' +× ssh:// upper-case host gives one spelling + AssertionError: expected 'GitHub.COM/acme/widgets' to be 'github.com/acme/widgets' +× ssh over the https port gives one spelling + AssertionError: expected 'ssh.github.com/acme/widgets' to be 'github.com/acme/widgets' +× https in another case, upper-case .GIT on github.com, ssh:// with a port, git://, git+ssh://, + ssh over the https port in upper case, credentials and a port: one spelling each +× keeps the case of the path on a host not known to ignore it, whatever the form +× is the sha256 of the normalised remote, whatever its spelling +× keys a requestless call by message id and session id, so its copies are one key +× keys a requestless call's advisor iterations under the same key +× reads an empty request id as none +``` + +### Phase 3 (stored data), against skeletons that compile and migrate nothing + +10 failed: every adapter and migration case, and two of the three properties. + +```txt +× re-keys a requestless call's copies stored apart, re-ids them, and counts the call once + AssertionError: expected [ Array(2) ] to deeply equal [ { tool: 'claude-code', …(15) } ] +× folds a line an earlier version writes after the migration into the call it is a copy of +× name each repository by its id of now +× join an earlier id's snapshots to the history of its id of now, the file untouched +× maps the id an earlier version gave each live root's origin to the id of now, and rewrites roots.json and the ledger +× maps nothing for an origin both rules name alike, nor for a root with no origin +× finishes an unfinished one with its own table, asking git nothing +× writes its table before anything else, and says it is complete last +× reports, and lies on disk, as one fresh ingest of the same copies under the rules of now + Caused by: AssertionError: expected [ …(6) ] to deeply equal [ …(6) ] +× migrated twice is migrated once +``` + +The third property (stop at any write, then rerun) passed against the skeleton, since a migration that does nothing also does nothing when stopped. It is proven by mutation G-h below. + +## Hand mutations + +Each mutation applied alone to the final code, the named tests run, the file restored: 23 of 23 went red. + +| # | Guard broken | Red tests (first ones) | +| --- | --- | --- | +| M7-a | host case-folded on every form | scp-like upper-case host, ssh:// upper-case host (8 tests) | +| M7-b | `ssh.github.com` is `github.com` | ssh over the https port (2) | +| M7-c | path case-folded on `github.com` only (folded everywhere) | keeps the case of the path on a host not known to ignore it (2) | +| M7-d | path case-folded on `github.com` at all | owner and repo in another case (7) | +| M7-e | the earlier rule folds a scp-like host only | of ssh://git@GitHub.COM:22/... hashes GitHub.COM/... (2) | +| M10-a | requestless key without the timestamp | keys a requestless call by message id and session id (3) | +| K-a | an earlier key is re-keyed | drops the timestamp from a requestless key; the fresh-ingest property | +| K-b | advisor suffix kept | keeps an advisor iteration's suffix | +| K-c | only the call's own session and time dropped | drops only the call's own time, after its own session | +| L-a | the ledger read applies the rules | re-keys a requestless call's copies stored apart (6) | +| L-b | a re-keyed partition is rewritten | maps the id ... and rewrites roots.json and the ledger (3) | +| R-a | `roots.json` read re-ids | name each repository by its id of now (2) | +| S-a | snapshots read re-id | join an earlier id's snapshots; the fresh-ingest property | +| G-a | a complete table asks nothing and rewrites nothing | asks git nothing and rewrites nothing once complete (survived first: the test only checked git; strengthened to check the files) | +| G-b | an unfinished table is reused | finishes an unfinished one with its own table | +| G-c | the table is written first | writes its table before anything else (2) | +| G-d | `roots.json` rewritten | the fresh-ingest property (3) | +| G-e | the ledger rewritten | finishes an unfinished one (3) | +| G-f | the table marked complete | maps nothing ... (4) | +| G-g | only a changed id is mapped (`if (true)`) | maps nothing for an origin both rules name alike, nor for a root with no origin | +| G-i | no `roots.json` made where there was none | leaves no roots.json where there was none | +| G-h | complete only after the rewrite (marked complete at once) | stopped at any write and run again, ends as a run never stopped | +| I-a | ingest migrates first | the fresh-ingest property; migrated twice is migrated once | + +## Gates + +| Gate | Result | +| --- | --- | +| `cd cli && pnpm test`, first full run | 3 failed: `folder-size` (three telemetry folders past ten files), `orchestrator-deps` (ingest's sixth collaborator), `telemetry-ingest.e2e` (the new `repository-ids.json`). Fixed by `legacy/` subfolders, the count with its reason, the expected listing | +| second full run | 3 failed: `codebase-map` (the two new `legacy/` folders), and the fresh-ingest property timing out at 60 s under the full suite's load (a cascade `ENOTEMPTY` in the next test). Fixed by the map, and the 240 s timeout the other telemetry properties use | +| third full run | 518 files passed, 1 skipped; 6,779 tests passed, 1 skipped | +| final full run, after the survivor tests | 519 files passed, 1 skipped; 6,789 tests passed, 1 skipped | +| `node --test 'scripts/__tests__/**/*.test.js'` | first run: 3 failed, `check-markdown-links` (this folder's `phase-4.md` linked `evidence.md` before it existed); after: 460 of 460 passed | +| `pnpm smoke` | PASS 132, FAIL 0, SKIP 1, coverage 100% | +| `node scripts/check-claude-tokens-end-to-end.cjs cli/dist/cli.js` | `2 calls, 393 tokens, all on task demo, ticket T-1, and on task demo in this repository` | +| same, `--attended` | same, `asked, answered by the hook, kept across /clear` | + +## Bundle + +| Step | `dist/cli.js` | +| --- | --- | +| `origin/next` `616dbbea` | 712,722 B (696.0 KB) | +| this change | 716,597 B (699.8 KB), budget 709 KB (726,016 B) not raised | + +## Mutation, `pnpm test:mutation:telemetry` + +| Run | Score (floor 96) | +| --- | --- | +| full, 4,555 mutants | 96.8 | +| incremental, after tests for the survivors on changed lines | 97.2 | + +Survivors of the first run on changed lines, and what became of them: + +| Where | Mutant | Now | +| --- | --- | --- | +| `stored-usage-migration.ts` | `roots.size > 0` to `true` | killed: no `roots.json` is made where there was none | +| `stored-usage-migration.ts` | the null checks around `earlier !== now` | removed: both rules fall back to the root commit alike, so one is null only when both are; `if (earlier !== now)` is killed by a root with neither origin nor commit | +| `stored-usage-migration.ts` | `roots.sort(...)` dropped | removed: the table is a map written in text order, the order roots are located in changes nothing | +| `repository-aliases.ts` | the parse guards | killed: a unit test per damaged shape | +| `legacy-repository-identity.ts` | `address === null \|\| path === null`, `&&` | killed by a remote with a host and one segment | + +Left, on changed lines, both equivalent: + +| Where | Mutant | Why it cannot be killed | +| --- | --- | --- | +| `legacy-repository-identity.ts:9` | `address === null` to `false` | `path` is `null` whenever `address` is, so `path === null` returns the same | +| `binding-snapshot-store-adapter.ts:45` | `?? ""` to `?? "Stryker was here!"` | an absent file then reads as one line that is no snapshot, skipped: no snapshot either way. The line moved from `history()` and `latest()` into `read()` unchanged | + +The other survivors in the touched files (`ingest-usage-use-case.ts` 88, 117, 121, 136; `usage-ledger-adapter.ts` 84, 121, 159; `claude-transcript-usage.ts` 42, 47) are on lines this change does not touch. diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-1.md new file mode 100644 index 000000000..5ae4226a8 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-1.md @@ -0,0 +1,21 @@ +--- +status: done +--- + +# Instruction: The identity rule (M7) + +```txt +cli/src/contexts/telemetry/domain/ +├── repository-identity.ts ✏️ host folded on every form, ssh.github.com, github.com path folded +└── legacy/legacy-repository-identity.ts 🆕 the earlier rule, frozen, for the migration only +cli/tests/contexts/telemetry/domain/ +├── repository-identity.unit.test.ts ✏️ every form of one repository, one address +└── legacy/legacy-repository-identity.unit.test.ts 🆕 the earlier rule pinned +``` + +| Case | Proof | +| --- | --- | +| https, ssh://, git://, git+ssh://, scp-like, userinfo, port, `.git`, trailing `/`, case: one address on `github.com` | unit table | +| another host keeps owner and repo case | unit | +| `ssh.github.com:443` is `github.com` | unit | +| the earlier rule gives what cli-v5.5.0 gave | unit, pinned hashes | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-2.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-2.md new file mode 100644 index 000000000..8f3988475 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-2.md @@ -0,0 +1,16 @@ +--- +status: done +--- + +# Instruction: The key rule (M10) + +```txt +cli/src/contexts/telemetry/domain/ +├── formats/claude-transcript-usage.ts ✏️ requestless key message.id:sessionId +└── legacy/legacy-usage-key.ts 🆕 an earlier requestless key, re-keyed +``` + +| Case | Proof | +| --- | --- | +| two copies of a requestless call at two timestamps: one key | format unit test | +| an earlier key re-keyed, advisor suffix kept, a `requestId` key untouched, re-keying twice = once | unit | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-3.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-3.md new file mode 100644 index 000000000..d8df26270 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-3.md @@ -0,0 +1,25 @@ +--- +status: done +--- + +# Instruction: Stored data migrated + +```txt +cli/src/contexts/telemetry/ +├── domain/legacy/repository-aliases.ts 🆕 the table's format +├── domain/ports/repository-alias-store.ts 🆕 the table's store +├── infrastructure/legacy/repository-alias-store-adapter.ts 🆕 ledger/repository-ids.json +├── infrastructure/usage-ledger-adapter.ts ✏️ load re-keys and re-ids, marks the partition unsettled +├── infrastructure/resolution-store-adapter.ts ✏️ load re-ids +├── infrastructure/binding-snapshot-store-adapter.ts ✏️ reads re-id +├── application/legacy/stored-usage-migration.ts 🆕 table, rewrite, complete +├── application/ingest-usage-use-case.ts ✏️ migration first, under the lock +└── ../../runtime/wiring/telemetry.ts ✏️ wired +``` + +| Case | Proof | +| --- | --- | +| migrated = fresh ingest, report and ledger bytes | fast-check property | +| twice = once; failure at every write then rerun = clean | same property | +| older CLI's lines after migration still fold | adapter test | +| `branches.jsonl` aliased at read, bytes untouched | adapter test | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-4.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-4.md new file mode 100644 index 000000000..b1065e0c2 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/phase-4.md @@ -0,0 +1,12 @@ +--- +status: done +--- + +# Instruction: Docs and gates + +| Edit | Where | +| --- | --- | +| key and repository rules, the new store | `aidd_docs/product/usage-contract.md` | +| where the migration lives | `cli/aidd_docs/memory/telemetry.md` | + +Gates as listed in the contract's acceptance, results in [`evidence.md`](./evidence.md). diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/plan.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/plan.md new file mode 100644 index 000000000..54f43b1ec --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/plan.md @@ -0,0 +1,33 @@ +--- +objective: "One call is one key and one repository is one id, in new data and in data already stored." +status: done +--- + +# Plan: One call, one key; one repository, one id + +## Overview + +| Field | Value | +| --- | --- | +| **Goal** | M7 and M10 of the R1 telemetry audit closed, stored data migrated, envelope untouched | +| **Source** | [`contract.md`](./contract.md) | + +## Phases + +| # | Phase | File | +| --- | --- | --- | +| 1 | The identity rule (M7) | [`phase-1.md`](./phase-1.md) | +| 2 | The key rule (M10) | [`phase-2.md`](./phase-2.md) | +| 3 | Stored data migrated | [`phase-3.md`](./phase-3.md) | +| 4 | Docs and gates | [`phase-4.md`](./phase-4.md) | + +## Decisions + +| Decision | Why | +| --- | --- | +| Case-fold owner and repo on `github.com` only | the only host with a primary source; elsewhere a split is safer than a merge | +| `message.id:sessionId`, `requestId` keys untouched | the asked fix; re-keying every call would orphan every `undated` hash | +| Read-time rules plus a one-off rewrite | figures are right in any half-migrated state and after an older CLI writes again; the rewrite makes the disk say it too | +| An alias table, not a re-hash | a stored id is a hash: only a live clone's `origin` can say what it was | +| `branches.jsonl` aliased at read, never rewritten | it stays append-only, and needs no second lock | +| Hooks unchanged | they compute no repository id and no key | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md new file mode 100644 index 000000000..d968d468b --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md @@ -0,0 +1,7 @@ +--- +status: pending +--- + +# Review: One call, one key; one repository, one id + +Pending independent review. diff --git a/cli/aidd_docs/memory/telemetry.md b/cli/aidd_docs/memory/telemetry.md index aad0899d0..8267bb12a 100644 --- a/cli/aidd_docs/memory/telemetry.md +++ b/cli/aidd_docs/memory/telemetry.md @@ -51,6 +51,7 @@ flowchart LR | What a Claude Code line becomes | `domain/formats/claude-transcript-usage.ts` | | Why a call is counted once | `domain/usage-fold.ts` | | Where a call's repository comes from | `application/directory-resolver.ts`, `domain/repository-identity.ts` | +| How data an earlier version stored is brought to today's key and id rules: read-time in every store reader, rewritten once by the first ingest | `application/legacy/stored-usage-migration.ts`, `domain/legacy/` (key, id and table rules), `infrastructure/legacy/repository-alias-store-adapter.ts`; rules in the [usage contract](../../../aidd_docs/product/usage-contract.md#stores) | | Which task a call belongs to | `domain/report/attribution.ts`, `domain/declaration/binding-resolution.ts` | | Whether a branch is declared: only by `task`, on that branch (`aiddDeclaredAt` set, `aiddDeclaredFor` absent or equal to the branch); the hook's twin is `plugins/aidd-telemetry/hooks/lib/git.cjs` | `domain/branch-binding.ts` `parseBranchConfig`; rule and limits in the [usage contract](../../../aidd_docs/product/usage-contract.md#branch-declaration) | | What counts as consent | `domain/telemetry-consent.ts`, `infrastructure/git-consent-adapter.ts` | From 247ced3a23ee358144a14063464a828a2c151e38 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 08:39:02 +0200 Subject: [PATCH 05/19] docs(telemetry): say what the migration properties exercise and what not Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../contract.md | 2 +- .../evidence.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md index 10eebd8c6..0a493e490 100644 --- a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md @@ -70,7 +70,7 @@ flowchart TD | Automatic | first step of every ingest, so of every report and of the plugin's hook | | Deterministic | the alias table is a function of each root's `origin` now, written before anything else; a rerun reuses it | | Idempotent | a key re-keys at most once (the new shape never ends in its own `:at`); an alias never names an id that is itself aliased: an earlier address differs from today's only by an upper-case letter or `ssh.github.com`, which no address of today holds | -| Crash-safe | under the ledger lock; every write is the existing write-then-rename; the table is written first and marked complete last, so a kill at any step leaves a state the next run completes | +| Crash-safe | under the ledger lock; every write is the existing write-then-rename; the table is written first and marked complete last, so a run stopped at any step leaves it incomplete and the next run redoes the whole rewrite. Exercised: a failure at every write (`replace`). Reasoned, not exercised: a stop during the removals that follow the writes, and a `kill -9` | | Read-time | every reader of the ledger, `roots.json` and `branches.jsonl` applies the key rule and the table, so a state the migration has not finished, or lines an older CLI writes afterwards, still count each call once under one id | | No double count | copies of a call re-keyed alike fold, by the existing fold, to the copy one ingest would have kept | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md index 9a19f8667..aae6a7754 100644 --- a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md @@ -41,6 +41,8 @@ × migrated twice is migrated once ``` +What the properties generate: requestless and requested calls, advisor iterations, copies across midnight and a month end, earlier reads in up to four batches, the monthly layout, three spellings of one GitHub repository, another host and a repository with no origin, every clone live and recorded in `roots.json`. Not generated: undated calls, a clone gone at the migration, an origin changed since its calls were stored (all stated limits). The stop property injects a failure at every `replace`; the removals that follow the writes and a `kill -9` are reasoned, not exercised. + The third property (stop at any write, then rerun) passed against the skeleton, since a migration that does nothing also does nothing when stopped. It is proven by mutation G-h below. ## Hand mutations From 571693308aaad6c79015a8bdfd72923a571e8d33 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 08:46:25 +0200 Subject: [PATCH 06/19] fix(telemetry): the migration puts every ledger day in canonical order It settled only the partitions a read finds unsettled, and a read never marks a day for its line order, so a day the new rules left alone kept the order it was found in. It now saves every partition from one full load; a day already canonical is not rewritten. Pins the counterexample the fresh-ingest property found. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../evidence.md | 24 +++++++++++++ .../legacy/stored-usage-migration.ts | 5 +-- ...age-migration.property.integration.test.ts | 36 +++++++++++++------ 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md index aae6a7754..91d803334 100644 --- a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md @@ -120,3 +120,27 @@ Left, on changed lines, both equivalent: | `binding-snapshot-store-adapter.ts:45` | `?? ""` to `?? "Stryker was here!"` | an absent file then reads as one line that is no snapshot, skipped: no snapshot either way. The line moved from `history()` and `latest()` into `read()` unchanged | The other survivors in the touched files (`ingest-usage-use-case.ts` 88, 117, 121, 136; `usage-ledger-adapter.ts` 84, 121, 159; `claude-transcript-usage.ts` 42, 47) are on lines this change does not touch. + +## After merging `next` (#1048): a migrated day kept the order it was found in + +The fresh-ingest property failed in the pre-push run on the merge commit `ffe60df1`, seed `-554519443`. It fails the same way on `247ced3a`, before the merge (temporary worktree, same test): the merge did not cause it. + +Counterexample, pinned as its own test ("puts a day it neither re-keys nor re-ids in the order one ingest writes"): calls 7 and 9 read before the migration in one batch, call 0 after. The earlier store wrote `2026-10-08.jsonl` in key order (`msg_7` at 12:00, then `msg_9` at 00:00:01). Neither call is re-keyed or re-ided, and no later read touches that day. + +```txt +× puts a day it neither re-keys nor re-ids in the order one ingest writes, whatever order it was found in + AssertionError: expected { …(3) } to deeply equal { …(3) } +- "2026-10-08.jsonl": "{…\"key\":\"msg_9:req_9\"…} ++ "2026-10-08.jsonl": "{…\"key\":\"msg_7:req_7\"…} +``` + +**Root cause:** `StoredUsageMigration.execute` called `settleLedger`, which rewrites only the partitions a read finds unsettled (`application/ledger-upkeep.ts` `settleLedger`). `UsageLedgerAdapter.load` marks a day unsettled when a record changes or a call is held twice, never for line order. So a day the rules left alone kept whatever order it had. The contract said the first run rewrites every partition. A released writer always wrote time-then-key order (cli-v5.5.0 `partitionByDay`), so this needs a store no released CLI wrote, such as a hand edit or the test's emulation. It is still a gap between the code and the contract. + +**Fix:** the migration saves every partition from one full load (`ledger.save(records)` with no partition list). `save` leaves a partition that already holds its canonical bytes untouched. + +| Proof | Result | +| --- | --- | +| Pinned test, before the fix | red, above | +| Pinned test and the three properties, after | 4 of 4, in 15 runs with random seeds | +| Mutation G-j: rewrite only the unsettled and damaged partitions again | killed by the pinned test | +| Mutation G-e: no ledger rewrite | killed (3 tests) | diff --git a/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts b/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts index 8b9ad7c02..5cd297272 100644 --- a/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts +++ b/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts @@ -4,7 +4,6 @@ import type { RepositoryLocator } from "../../domain/ports/repository-locator.js import type { ResolutionStore } from "../../domain/ports/resolution-store.js"; import type { UsageLedger } from "../../domain/ports/usage-ledger.js"; import { repositoryIdOf } from "../../domain/repository-identity.js"; -import { settleLedger } from "../ledger-upkeep.js"; /** Brings what an earlier version stored to the rules of now: one key per call, one id per * repository. A stored id is a hash, so only a live clone's origin can say which id an earlier @@ -29,7 +28,9 @@ export class StoredUsageMigration { if (held === null) await this.aliases.save({ ids, complete: false }); const roots = await this.resolutions.load(); if (roots.size > 0) await this.resolutions.save(roots); - await settleLedger(this.ledger); + // Every partition, not only the ones a read finds unsettled: a day is put in the order one + // ingest writes, whatever order it was found in. One already in it is not touched. + await this.ledger.save((await this.ledger.load()).records); await this.aliases.save({ ids, complete: true }); } diff --git a/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts b/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts index 810916bda..735d0566a 100644 --- a/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts @@ -303,20 +303,34 @@ async function fresh(s: Scenario) { } describe("stored data from an earlier version, once migrated", () => { + /** The migrated store reports, and lies on disk, as one fresh ingest does. */ + async function asFresh(s: Scenario): Promise { + const up = await upgraded(s); + const one = await fresh(s); + await figures(up); + await figures(one); + expect(await figures(up)).toEqual(await figures(one)); + expect(await bytes(up.dir)).toEqual(await bytes(one.dir)); + } + it("reports, and lies on disk, as one fresh ingest of the same copies under the rules of now", async () => { - await fc.assert( - fc.asyncProperty(scenario, async (s) => { - const up = await upgraded(s); - const one = await fresh(s); - await figures(up); - await figures(one); - expect(await figures(up)).toEqual(await figures(one)); - expect(await bytes(up.dir)).toEqual(await bytes(one.dir)); - }), - { numRuns: 60 } - ); + await fc.assert(fc.asyncProperty(scenario, asFresh), { numRuns: 60 }); }, 240_000); + it("puts a day it neither re-keys nor re-ids in the order one ingest writes, whatever order it was found in", async () => { + // Found by the property: two calls of one day, in key order rather than time order, that + // no rule of now changes, and no later read touches. + await asFresh({ + copies: [ + { call: 7, time: 0, output: 0, before: true }, + { call: 9, time: 1, output: 0, before: true }, + { call: 0, time: 0, output: 0, before: false }, + ], + batches: Array.from({ length: 24 }, () => 0), + monthly: false, + }); + }); + it("migrated twice is migrated once", async () => { await fc.assert( fc.asyncProperty(scenario, async (s) => { From 084d00e4dd7753d738cb2b416c28426bbd70f394 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 09:10:33 +0200 Subject: [PATCH 07/19] fix(telemetry): store a requestless call once, whatever its copies' spread Its key no longer carries the timestamp, and no real transcript bounds how far apart its copies are written (none of 512,820 usage lines had no requestId). An ingest storing one now reads every day, so the ledger holds it once and a bounded report agrees with the full one. Calls with a requestId keep the measured one-day window. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../telemetry/application/ledger-upkeep.ts | 10 ++- .../domain/formats/claude-transcript-usage.ts | 10 +++ .../telemetry/domain/report/period.ts | 9 ++- .../ledger-upkeep.integration.test.ts | 23 ++++++ ...age-migration.property.integration.test.ts | 6 +- ...report-window.property.integration.test.ts | 75 ++++++++++++++++++- .../claude-transcript-usage.unit.test.ts | 20 ++++- .../helpers/ports/in-memory-telemetry.ts | 10 +++ 8 files changed, 153 insertions(+), 10 deletions(-) diff --git a/cli/src/contexts/telemetry/application/ledger-upkeep.ts b/cli/src/contexts/telemetry/application/ledger-upkeep.ts index 58f6af264..1adbc29a4 100644 --- a/cli/src/contexts/telemetry/application/ledger-upkeep.ts +++ b/cli/src/contexts/telemetry/application/ledger-upkeep.ts @@ -1,3 +1,4 @@ +import { isKeyedBySession } from "../domain/formats/claude-transcript-usage.js"; import type { LoadedLedger, UsageLedger } from "../domain/ports/usage-ledger.js"; import { daysAround, type StoredUsage, upsertUsage } from "../domain/stored-usage.js"; @@ -11,13 +12,16 @@ export interface Stored { /** Folds incoming calls into the days around them, and rewrites only the days that changed, * plus any partition read that was damaged or not in daily form, so a month is split and a * line that is not a record put after the records, kept. Nothing outside the window is read, - * except a monthly file it splits and every day of that month. Runs under the caller's lock. */ + * except a monthly file it splits and every day of that month, and every day when a call has no + * request id. Runs under the caller's lock. */ export async function storeCalls( ledger: UsageLedger, incoming: readonly StoredUsage[] ): Promise { - const days = daysAround(incoming); - const held = await ledger.load((day) => days.has(day)); + // A requestless call's copies may lie any distance apart: storing one reads every day, so + // the ledger never holds such a call twice and no window has to reach its other copies. + const days = incoming.some(isKeyedBySession) ? null : daysAround(incoming); + const held = await ledger.load(days === null ? undefined : (day) => days.has(day)); const merged = upsertUsage(held.records, incoming); const partitions = new Set([...merged.days, ...held.damaged, ...held.unsettled]); if (partitions.size > 0) await ledger.save(merged.records, partitions); diff --git a/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts b/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts index 312369cc3..0bcebf153 100644 --- a/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts +++ b/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts @@ -71,6 +71,16 @@ function sum(values: readonly (number | null)[]): number { return values.reduce((all, value) => all + (value ?? 0), 0); } +/** Whether a call is keyed `message.id:sessionId`, having no request id. Its copies are bound + * in time by nothing measured: none was seen in real transcripts, whereas the copies of a call + * with a request id were measured under half an hour apart (see `windowOf`). */ +export function isKeyedBySession(call: Pick): boolean { + return ( + call.tool === "claude-code" && + call.key.replace(/#advisor\d+$/, "").endsWith(`:${call.session_id}`) + ); +} + export function readClaudeUsageLine(line: string): ClaudeUsageLine { let parsed: unknown; try { diff --git a/cli/src/contexts/telemetry/domain/report/period.ts b/cli/src/contexts/telemetry/domain/report/period.ts index 367a87952..ad8e3a867 100644 --- a/cli/src/contexts/telemetry/domain/report/period.ts +++ b/cli/src/contexts/telemetry/domain/report/period.ts @@ -32,11 +32,14 @@ function shift(day: string, days: number): string | null { * from the day before `from` to the day after `to`, an open side, or one past four-digit * years, staying open. `undefined` when the period has no bound: every partition. * - * One day on each side, because the copies of one call have been observed less than a day - * apart (of 262,631 real keys, the widest spread is 0.3 h). Under that bound they sit within a + * One day on each side, because the copies of one call with a request id have been observed + * less than a day apart (of 262,631 real keys, the widest spread is 0.3 h; measured again on + * 184,502 message ids written more than once, 0.27 h). Under that bound they sit within a * calendar day of each other: a call with a copy in the period has all of them in the window, * and folds to the same one as a full read would. Outside it, only an unbounded read folds - * every copy. */ + * every copy. A call with no request id is bound by nothing measured (none was seen in + * 512,820 real usage lines), so the window does not rely on it: storing one reads every day + * (see `storeCalls`), and the ledger holds it once. */ export function windowOf(period: Period): ((day: string) => boolean) | undefined { if (period.from === null && period.to === null) return undefined; const first = period.from === null ? null : shift(period.from, -1); diff --git a/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts b/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts index a83c965f5..89b0497ba 100644 --- a/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts @@ -216,3 +216,26 @@ describe("settling only a window", () => { expect((await bytes(dir))["2026-10-20.jsonl"]).toBe(torn); }); }); + +describe("storing a requestless call, whose copies nothing bounds in time", () => { + it("finds its copy days away, so the ledger holds the call once and a bounded read agrees", async () => { + // Found by the independent check: copies three days apart were stored in two days. + const ledger = new UsageLedgerAdapter(join(root, "ledger"), new PrivateStorageAdapter()); + await storeCalls(ledger, [stored("msg_1:s-1", "2026-10-01T10:00:00.000Z", 1)]); + await storeCalls(ledger, [stored("msg_1:s-1", "2026-10-04T10:00:00.000Z", 2)]); + expect((await ledger.load()).records).toEqual([ + stored("msg_1:s-1", "2026-10-04T10:00:00.000Z", 2), + ]); + expect(await readdir(join(root, "ledger"))).toEqual(["2026-10-04.jsonl"]); + }); + + it("still reads only the days around a call with a request id", async () => { + const ledger = new UsageLedgerAdapter(join(root, "ledger"), new PrivateStorageAdapter()); + await storeCalls(ledger, [stored("msg_1:req_1", "2026-10-01T10:00:00.000Z", 1)]); + await storeCalls(ledger, [stored("msg_1:req_1", "2026-10-04T10:00:00.000Z", 2)]); + expect((await readdir(join(root, "ledger"))).sort()).toEqual([ + "2026-10-01.jsonl", + "2026-10-04.jsonl", + ]); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts b/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts index 735d0566a..f588b1c64 100644 --- a/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts @@ -77,6 +77,8 @@ const TIMES = [ ["2026-10-07T23:59:59.000Z", "2026-10-08T00:00:01.000Z", "2026-10-08T00:00:03.000Z"], ["2026-10-08T12:00:00.000Z", "2026-10-08T12:00:04.000Z"], ["2026-10-31T23:59:58.000Z", "2026-11-01T00:00:02.000Z"], + // Days apart: a requestless call's copies are bound by nothing measured. + ["2026-10-03T10:00:00.000Z", "2026-10-06T09:00:00.000Z", "2026-10-12T11:00:00.000Z"], ]; interface Copy { @@ -88,7 +90,7 @@ interface Copy { } const copy = fc.record({ - call: fc.nat(11), + call: fc.nat(23), time: fc.nat(2), output: fc.nat(3), before: fc.boolean(), @@ -98,7 +100,7 @@ const copy = fc.record({ * declared branch, and with or without an advisor: only its copies' times and counts differ. */ function lineOf({ call, time, output }: Copy): string { const dir = DIRS[call % DIRS.length] as string; - const times = TIMES[call % TIMES.length] as string[]; + const times = TIMES[Math.floor(call / 2) % TIMES.length] as string[]; const requestless = call % 2 === 0; const advisor = call % 3 === 0; return JSON.stringify({ diff --git a/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts b/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts index 46013b1ee..6cbf927e5 100644 --- a/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts @@ -8,6 +8,7 @@ import { type IngestResult, IngestUsageUseCase, } from "../../../../../src/contexts/telemetry/application/ingest-usage-use-case.js"; +import { storeCalls } from "../../../../../src/contexts/telemetry/application/ledger-upkeep.js"; import { DeclaredBindings } from "../../../../../src/contexts/telemetry/application/report/declared-bindings.js"; import { reportEnvelopeOf } from "../../../../../src/contexts/telemetry/application/report/report-envelope.js"; import { ReportUsageUseCase } from "../../../../../src/contexts/telemetry/application/report/report-usage-use-case.js"; @@ -17,6 +18,7 @@ import { attribute } from "../../../../../src/contexts/telemetry/domain/report/a import { inPeriod, type Period, + windowOf, } from "../../../../../src/contexts/telemetry/domain/report/period.js"; import { conflictsOf, @@ -33,6 +35,7 @@ import { USAGE_AGENTS } from "../../../../../src/contexts/telemetry/domain/usage import { UsageLedgerAdapter } from "../../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; import { PrivateStorageAdapter } from "../../../../../src/runtime/filesystem/private-storage-adapter.js"; import { + ALREADY_MIGRATED, FakeBindings, FakeConsents, FakeLocator, @@ -219,7 +222,8 @@ function ingestOf(ledger: UsageLedger): IngestUsageUseCase { new InMemoryBindingsLock(), now ), - { refusedByEnvironment: false } + { refusedByEnvironment: false }, + ALREADY_MIGRATED ); } @@ -297,3 +301,72 @@ describe("a report on a bounded period", () => { ); }, 60_000); }); + +/** A call with no request id is keyed by its message and session, and nothing bounds how far + * apart its copies are written. A call with one keeps its copies under half a day apart, the + * measured bound. */ +const storedCall = fc.record({ + anchor: fc.nat(ANCHORS.length - 1), + requestless: fc.boolean(), + copies: fc.array( + fc.record({ + days: fc.integer({ min: -5, max: 5 }), + offset: fc.integer({ min: -HALF_DAY, max: HALF_DAY }), + output: fc.nat(5), + // Which ingest read this copy. + ingest: fc.nat(3), + }), + { minLength: 1, maxLength: 3 } + ), +}); + +describe("calls stored by ingests, a requestless call's copies days apart", () => { + it("leave every bounded read equal to the full ledger filtered to its period", async () => { + await fc.assert( + fc.asyncProperty( + fc.array(storedCall, { minLength: 1, maxLength: 8 }), + period, + async (calls, asked) => { + const ledger = await ledgerWith(new Map()); + for (let ingest = 0; ingest < 4; ingest += 1) { + const batch = calls.flatMap(({ anchor, requestless, copies }, index) => + copies + .filter((c) => c.ingest === ingest) + .map( + ({ days, offset, output }): StoredUsage => ({ + tool: "claude-code", + tool_version: "1.0.0", + key: requestless ? `msg_${index}:s-1` : `msg_${index}:req_${index}`, + session_id: "s-1", + agent: "main", + at: new Date( + (ANCHORS[anchor] as number) + (requestless ? days * DAY_MS : 0) + offset + ).toISOString(), + model: "m-1", + input: 1, + output, + cache_read: 0, + cache_write: 0, + cache_write_1h: null, + reasoning: null, + cwd: null, + git_branch: null, + repository_id: "repo-1", + }) + ) + ); + if (batch.length > 0) await storeCalls(ledger, batch); + } + const bounded = (await ledger.load(windowOf(asked))).records; + const full = (await ledger.load()).records; + const inside = (records: readonly StoredUsage[]) => + records.filter((record) => inPeriod(record.at, asked)); + expect(inside(bounded)).toEqual(inside(full)); + // One record per call, whatever its copies' spread. + expect(full.length).toBe(calls.filter((c) => c.copies.length > 0).length); + } + ), + { numRuns: 150 } + ); + }, 240_000); +}); diff --git a/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts index 98fa0ee83..a5d1d4bfd 100644 --- a/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { readClaudeUsageLine } from "../../../../../src/contexts/telemetry/domain/formats/claude-transcript-usage.js"; +import { + isKeyedBySession, + readClaudeUsageLine, +} from "../../../../../src/contexts/telemetry/domain/formats/claude-transcript-usage.js"; const USAGE = { input_tokens: 3, @@ -318,3 +321,18 @@ describe("one transcript line to usage records", () => { expect(outcome.unrecognised).toEqual(["usage.iterations holds a non-object"]); }); }); + +describe("a call keyed by its session, having no request id", () => { + it("is told by its key ending in its own session, advisor iteration or not", () => { + const call = (key: string, tool: "claude-code" | "codex" = "claude-code") => ({ + tool, + key, + session_id: "s-1", + }); + expect(isKeyedBySession(call("msg_A:s-1"))).toBe(true); + expect(isKeyedBySession(call("msg_A:s-1#advisor3"))).toBe(true); + expect(isKeyedBySession(call("msg_A:req_A"))).toBe(false); + expect(isKeyedBySession(call("msg_A:xs-1"))).toBe(false); + expect(isKeyedBySession(call("msg_A:s-1", "codex"))).toBe(false); + }); +}); diff --git a/cli/tests/helpers/ports/in-memory-telemetry.ts b/cli/tests/helpers/ports/in-memory-telemetry.ts index c807a6f26..7b9168df1 100644 --- a/cli/tests/helpers/ports/in-memory-telemetry.ts +++ b/cli/tests/helpers/ports/in-memory-telemetry.ts @@ -30,6 +30,7 @@ import type { import type { ConsentHistory } from "../../../src/contexts/telemetry/domain/ports/consent-history.js"; import type { ConsentSource } from "../../../src/contexts/telemetry/domain/ports/consent-source.js"; import type { PersonIdentityStore } from "../../../src/contexts/telemetry/domain/ports/identity/person-identity-store.js"; +import type { RepositoryAliasStore } from "../../../src/contexts/telemetry/domain/ports/repository-alias-store.js"; import type { LocatedDirectory, RepositoryLocator, @@ -411,3 +412,12 @@ export class InMemoryIdentity implements PersonIdentityStore { return held; } } + +/** A ledger already under today's key and id rules: the migration finds nothing to do. */ +export const ALREADY_MIGRATED = { execute: async (): Promise => {} }; + +/** No table of ids an earlier version gave repositories: every id is read as stored. */ +export const NO_ALIASES: RepositoryAliasStore = { + load: async () => null, + save: async () => {}, +}; From 84bf7fa322004327e43f9b845c1f5135308bc22d Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 09:11:32 +0200 Subject: [PATCH 08/19] fix(telemetry): map a root the migration could not read once it can A root unreadable or gone at the first run stayed unmapped while the table was marked complete, so its repository got a second id. Such roots are now kept pending and looked at again by every ingest; when one adds an id, the stores are rewritten under it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../legacy/stored-usage-migration.ts | 42 ++++++++--- .../domain/legacy/repository-aliases.ts | 13 +++- .../legacy/repository-aliases.unit.test.ts | 25 +++++-- .../stored-ids-and-keys.integration.test.ts | 71 +++++++++++++++++-- 4 files changed, 128 insertions(+), 23 deletions(-) diff --git a/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts b/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts index 5cd297272..a3045ecd4 100644 --- a/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts +++ b/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts @@ -7,12 +7,14 @@ import { repositoryIdOf } from "../../domain/repository-identity.js"; /** Brings what an earlier version stored to the rules of now: one key per call, one id per * repository. A stored id is a hash, so only a live clone's origin can say which id an earlier - * version gave it: each root `roots.json` names is located once, and the earlier id of its - * origin mapped to the id of now. The table is written first; every reader of the ledger, + * version gave it: each root `roots.json` names is located, and the earlier id of its origin + * mapped to the id of now. The table is written first; every reader of the ledger, * `roots.json` and the branch snapshots applies it, and the key rule, from then on. Then * `roots.json` and every ledger partition are rewritten under it, and the table is marked * complete, last. A run stopped anywhere leaves a state the next one completes with the same - * table, asking git nothing. Runs under the ledger's lock. */ + * table, asking git nothing. A root that could not be read, or was not there, stays pending: + * each later run looks at those roots alone, and rewrites the stores again only when one of + * them adds an id. Runs under the ledger's lock. */ export class StoredUsageMigration { constructor( private readonly locator: RepositoryLocator, @@ -23,28 +25,48 @@ export class StoredUsageMigration { async execute(): Promise { const held = await this.aliases.load(); - if (held?.complete) return; - const ids = held?.ids ?? (await this.table()); - if (held === null) await this.aliases.save({ ids, complete: false }); + if (held?.complete && held.pending.length === 0) return; + let table = held; + if (table === null || table.complete) { + const roots = table?.pending ?? (await this.roots()); + const found = await this.locate(roots); + const ids = new Map([...(table?.ids ?? []), ...found.ids]); + if (table !== null && ids.size === table.ids.size) { + // Nothing new: only the roots still pending may have changed. + if (found.pending.length < table.pending.length) { + await this.aliases.save({ ...table, pending: found.pending }); + } + return; + } + table = { ids, complete: false, pending: found.pending }; + await this.aliases.save(table); + } const roots = await this.resolutions.load(); if (roots.size > 0) await this.resolutions.save(roots); // Every partition, not only the ones a read finds unsettled: a day is put in the order one // ingest writes, whatever order it was found in. One already in it is not touched. await this.ledger.save((await this.ledger.load()).records); - await this.aliases.save({ ids, complete: true }); + await this.aliases.save({ ...table, complete: true }); + } + + private async roots(): Promise { + return [...new Set([...(await this.resolutions.load()).values()].map((r) => r.root))]; } - private async table(): Promise> { - const roots = [...new Set([...(await this.resolutions.load()).values()].map((r) => r.root))]; + private async locate( + roots: readonly string[] + ): Promise<{ ids: Map; pending: string[] }> { const ids = new Map(); + const pending: string[] = []; for (const root of roots) { const located = await this.locator.locate(root); + if (located.status === "unreadable" || located.status === "gone") pending.push(root); if (located.status !== "repository") continue; // Both rules fall back to the root commit alike, so one is null only when both are. const earlier = legacyRepositoryIdOf(located); const now = repositoryIdOf(located); if (earlier !== now) ids.set(earlier as string, now as string); } - return ids; + return { ids, pending }; } } diff --git a/cli/src/contexts/telemetry/domain/legacy/repository-aliases.ts b/cli/src/contexts/telemetry/domain/legacy/repository-aliases.ts index 14802f04d..43649d26f 100644 --- a/cli/src/contexts/telemetry/domain/legacy/repository-aliases.ts +++ b/cli/src/contexts/telemetry/domain/legacy/repository-aliases.ts @@ -6,7 +6,11 @@ import { compareText } from "../text-order.js"; * has been rewritten to it. Both sides are hashes or root commits: no address is kept. */ export interface RepositoryAliases { readonly ids: ReadonlyMap; + /** Every store has been rewritten under `ids`. */ readonly complete: boolean; + /** Roots that could not be read, or were not there, when last looked at: looked at again by + * the next run, since an earlier id can only be told from a clone's origin. */ + readonly pending: readonly string[]; } const FORMAT = 1; @@ -21,10 +25,13 @@ export function parseRepositoryAliases(text: string | null): RepositoryAliases | } const entries = Object.entries(aliases); if (entries.some(([, id]) => typeof id !== "string" || id === "")) return null; - return { ids: new Map(entries as [string, string][]), complete: object.complete }; + const { pending } = object; + if (!Array.isArray(pending) || pending.some((root) => typeof root !== "string")) return null; + return { ids: new Map(entries as [string, string][]), complete: object.complete, pending }; } -export function renderRepositoryAliases({ ids, complete }: RepositoryAliases): string { +export function renderRepositoryAliases({ ids, complete, pending }: RepositoryAliases): string { const aliases = Object.fromEntries([...ids].sort(([a], [b]) => compareText(a, b))); - return `${JSON.stringify({ version: FORMAT, complete, aliases }, null, 2)}\n`; + const roots = [...pending].sort(compareText); + return `${JSON.stringify({ version: FORMAT, complete, aliases, pending: roots }, null, 2)}\n`; } diff --git a/cli/tests/contexts/telemetry/domain/legacy/repository-aliases.unit.test.ts b/cli/tests/contexts/telemetry/domain/legacy/repository-aliases.unit.test.ts index 6454f968b..bdf78796e 100644 --- a/cli/tests/contexts/telemetry/domain/legacy/repository-aliases.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/legacy/repository-aliases.unit.test.ts @@ -10,11 +10,15 @@ describe("the table of ids an earlier version gave repositories", () => { ["b", "y"], ["a", "x"], ]); - const text = renderRepositoryAliases({ ids, complete: false }); + const text = renderRepositoryAliases({ ids, complete: false, pending: ["/w/b", "/w/a"] }); expect(text).toBe( - `${JSON.stringify({ version: 1, complete: false, aliases: { a: "x", b: "y" } }, null, 2)}\n` + `${JSON.stringify({ version: 1, complete: false, aliases: { a: "x", b: "y" }, pending: ["/w/a", "/w/b"] }, null, 2)}\n` ); - expect(parseRepositoryAliases(text)).toEqual({ ids, complete: false }); + expect(parseRepositoryAliases(text)).toEqual({ + ids, + complete: false, + pending: ["/w/a", "/w/b"], + }); }); it.each([ @@ -24,8 +28,19 @@ describe("the table of ids an earlier version gave repositories", () => { ["no completion", JSON.stringify({ version: 1, aliases: {} })], ["a completion that is no boolean", JSON.stringify({ version: 1, complete: 1, aliases: {} })], ["no table", JSON.stringify({ version: 1, complete: true })], - ["an id that is no text", JSON.stringify({ version: 1, complete: true, aliases: { a: 1 } })], - ["an empty id", JSON.stringify({ version: 1, complete: true, aliases: { a: "x", b: "" } })], + [ + "an id that is no text", + JSON.stringify({ version: 1, complete: true, aliases: { a: 1 }, pending: [] }), + ], + ["no pending roots", JSON.stringify({ version: 1, complete: true, aliases: {} })], + [ + "a pending root that is no text", + JSON.stringify({ version: 1, complete: true, aliases: {}, pending: [1] }), + ], + [ + "an empty id", + JSON.stringify({ version: 1, complete: true, aliases: { a: "x", b: "" }, pending: [] }), + ], ])("is none when %s, so it is made again", (_name, text) => { expect(parseRepositoryAliases(text)).toBeNull(); }); diff --git a/cli/tests/contexts/telemetry/infrastructure/stored-ids-and-keys.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/stored-ids-and-keys.integration.test.ts index bba9a055f..7005fa0cb 100644 --- a/cli/tests/contexts/telemetry/infrastructure/stored-ids-and-keys.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/stored-ids-and-keys.integration.test.ts @@ -59,7 +59,7 @@ function call(overrides: Record = {}) { async function writeAliases(complete = true): Promise { await writeFile( join(dir, "repository-ids.json"), - `${JSON.stringify({ version: 1, complete, aliases: { [OLD]: NEW } }, null, 2)}\n` + `${JSON.stringify({ version: 1, complete, aliases: { [OLD]: NEW }, pending: [] }, null, 2)}\n` ); } @@ -191,7 +191,12 @@ describe("the migration", () => { const locator = new FakeLocator(); locator.directories.set("/work/a", live(MIXED.remote)); await migration(locator).execute(); - expect(await table()).toEqual({ version: 1, complete: true, aliases: { [OLD]: NEW } }); + expect(await table()).toEqual({ + version: 1, + complete: true, + aliases: { [OLD]: NEW }, + pending: ["/work/gone"], + }); expect(locator.asked).toEqual(["/work/a", "/work/gone"]); expect(await readFile(join(dir, "roots.json"), "utf8")).not.toContain(OLD); expect(await readFile(join(dir, "2026-10-07.jsonl"), "utf8")).toBe( @@ -206,13 +211,64 @@ describe("the migration", () => { locator.directories.set("/work/b", live(null)); locator.directories.set("/work/c", { ...live(null), rootCommit: null }); await migration(locator).execute(); - expect(await table()).toEqual({ version: 1, complete: true, aliases: {} }); + expect(await table()).toEqual({ version: 1, complete: true, aliases: {}, pending: [] }); }); it("leaves no roots.json where there was none", async () => { await migration(new FakeLocator()).execute(); await expect(readFile(join(dir, "roots.json"), "utf8")).rejects.toThrow(); - expect(await table()).toEqual({ version: 1, complete: true, aliases: {} }); + expect(await table()).toEqual({ version: 1, complete: true, aliases: {}, pending: [] }); + }); + + it("maps a root it could not read at its first run once it can, so the repository keeps one id", async () => { + // Found by the independent check: the table was marked complete with the root unmapped. + await rooted("/work/a"); + await writeFile(join(dir, "2026-10-07.jsonl"), `${JSON.stringify(call())}\n`); + const locator = new FakeLocator(); + locator.directories.set("/work/a", { status: "unreadable" }); + await migration(locator).execute(); + expect(await table()).toEqual({ + version: 1, + complete: true, + aliases: {}, + pending: ["/work/a"], + }); + locator.directories.set("/work/a", live(MIXED.remote)); + await migration(locator).execute(); + expect(await table()).toEqual({ + version: 1, + complete: true, + aliases: { [OLD]: NEW }, + pending: [], + }); + expect(await readFile(join(dir, "2026-10-07.jsonl"), "utf8")).toBe( + `${JSON.stringify(call({ key: "msg_A:s-1", repository_id: NEW }))}\n` + ); + expect(await readFile(join(dir, "roots.json"), "utf8")).not.toContain(OLD); + }); + + it("keeps a root gone at its first run pending, and drops one that is no repository now", async () => { + await rooted("/work/a", "/work/b"); + const locator = new FakeLocator(); + locator.directories.set("/work/b", { status: "outside-repository" }); + await migration(locator).execute(); + expect((await table()).pending).toEqual(["/work/a"]); + locator.directories.set("/work/a", { status: "outside-repository" }); + await migration(locator).execute(); + expect(await table()).toEqual({ version: 1, complete: true, aliases: {}, pending: [] }); + }); + + it("asks only the pending roots again, and writes nothing while none can be read", async () => { + await rooted("/work/a", "/work/b"); + const locator = new FakeLocator(); + locator.directories.set("/work/a", { status: "unreadable" }); + locator.directories.set("/work/b", live("https://github.com/acme/tool")); + await migration(locator).execute(); + const before = await table(); + locator.asked.length = 0; + await migration(locator, new FailingStorage(1)).execute(); + expect(locator.asked).toEqual(["/work/a"]); + expect(await table()).toEqual(before); }); it("asks git nothing and rewrites nothing once complete", async () => { @@ -245,7 +301,12 @@ describe("the migration", () => { const locator = new FakeLocator(); locator.directories.set("/work/a", live(MIXED.remote)); await expect(migration(locator, new FailingStorage(2)).execute()).rejects.toThrow(); - expect(await table()).toEqual({ version: 1, complete: false, aliases: { [OLD]: NEW } }); + expect(await table()).toEqual({ + version: 1, + complete: false, + aliases: { [OLD]: NEW }, + pending: [], + }); // Rerun: roots.json, the day, then the table marked complete, which fails. await expect(migration(new FakeLocator(), new FailingStorage(3)).execute()).rejects.toThrow(); expect(await readFile(join(dir, "2026-10-07.jsonl"), "utf8")).toContain(NEW); From faa8bb02d0ab7b3f042780e1aec1bc492b19e532 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 09:12:29 +0200 Subject: [PATCH 09/19] fix(telemetry): require the migration wiring, re-key Claude Code only The migration and the snapshot store's alias table are required, so a wiring that leaves one out fails to compile. Re-keying an earlier key applies to claude-code records only. Fixes a stale class name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../telemetry/application/ingest-usage-use-case.ts | 5 +++-- .../domain/legacy/legacy-repository-identity.ts | 2 +- .../telemetry/domain/legacy/legacy-usage-key.ts | 8 ++++++-- .../binding-snapshot-store-adapter.ts | 4 ++-- .../application/ingest-usage-use-case.unit.test.ts | 7 +++++-- .../report/left-out.property.integration.test.ts | 4 +++- .../report-filters.property.integration.test.ts | 4 +++- .../report/report-usage-use-case.unit.test.ts | 4 +++- ...cript-stream-ingest.property.integration.test.ts | 4 +++- .../domain/legacy/legacy-usage-key.unit.test.ts | 13 ++++++++++++- ...nding-snapshot-store-adapter.integration.test.ts | 3 ++- .../bindings-lock-adapter.integration.test.ts | 4 ++-- 12 files changed, 45 insertions(+), 17 deletions(-) diff --git a/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts b/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts index de9afb7c8..c4c40cd99 100644 --- a/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts +++ b/cli/src/contexts/telemetry/application/ingest-usage-use-case.ts @@ -71,7 +71,8 @@ export class IngestUsageUseCase { private readonly directories: DirectoryResolver, private readonly snapshots: SnapshotBindingsUseCase, private readonly options: IngestOptions, - private readonly migration?: { execute(): Promise } + /** Brings stored data to today's key and id rules first (`StoredUsageMigration`). */ + private readonly migration: { execute(): Promise } ) {} async execute(): Promise { @@ -94,7 +95,7 @@ export class IngestUsageUseCase { private async ingest(): Promise { // Stored data first brought to the rules of now, before anything is added to it. - await this.migration?.execute(); + await this.migration.execute(); const before = await this.ledger.positions(); // The consent log first: a transcript last written before any interval opened is not read. const resolver = await this.directories.open(); diff --git a/cli/src/contexts/telemetry/domain/legacy/legacy-repository-identity.ts b/cli/src/contexts/telemetry/domain/legacy/legacy-repository-identity.ts index 7ac53de5a..7960418d9 100644 --- a/cli/src/contexts/telemetry/domain/legacy/legacy-repository-identity.ts +++ b/cli/src/contexts/telemetry/domain/legacy/legacy-repository-identity.ts @@ -2,7 +2,7 @@ import { addressOf, hashOf, ownerAndRepo, type RepositoryFacts } from "../reposi /** The id cli-v5.5.0 stored for a repository: the host case-folded only when scp-like or of a * special scheme, the path as written. Kept so the migration can tell which stored id a live - * clone's origin had (see `RepositoryIdMigration`); nothing new is ever named by it. */ + * clone's origin had (see `StoredUsageMigration`); nothing new is ever named by it. */ export function legacyRepositoryIdOf(facts: RepositoryFacts): string | null { const address = facts.remote === null ? null : addressOf(facts.remote); const path = address === null ? null : ownerAndRepo(address.path); diff --git a/cli/src/contexts/telemetry/domain/legacy/legacy-usage-key.ts b/cli/src/contexts/telemetry/domain/legacy/legacy-usage-key.ts index 4c1e182e5..029f466ae 100644 --- a/cli/src/contexts/telemetry/domain/legacy/legacy-usage-key.ts +++ b/cli/src/contexts/telemetry/domain/legacy/legacy-usage-key.ts @@ -5,9 +5,13 @@ const ADVISOR = /#advisor\d+$/; /** A call's key as it is keyed now. An earlier version keyed a call with no request id * `message.id:sessionId:timestamp`, so each copy of it had its own key; the timestamp is the - * call's own `at`, after its own session, and is dropped. Any other key is kept: one keyed now + * call's own `at`, after its own session, and is dropped; only Claude Code was keyed so. Any + * other key is kept: one keyed now * ends in its session, never in `:at`, so re-keying twice is re-keying once. */ -export function currentKeyOf(call: Pick): string { +export function currentKeyOf( + call: Pick +): string { + if (call.tool !== "claude-code") return call.key; const suffix = ADVISOR.exec(call.key)?.[0] ?? ""; const base = call.key.slice(0, call.key.length - suffix.length); const earlier = `:${call.session_id}:${call.at}`; diff --git a/cli/src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.ts b/cli/src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.ts index fd9f32271..287f2cd71 100644 --- a/cli/src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.ts @@ -14,7 +14,7 @@ export class BindingSnapshotStoreAdapter implements BindingSnapshotStore { constructor( private readonly dir: string, private readonly storage: PrivateStorage, - private readonly aliases?: RepositoryAliasStore + private readonly aliases: RepositoryAliasStore ) { this.path = join(dir, "branches.jsonl"); } @@ -40,7 +40,7 @@ export class BindingSnapshotStoreAdapter implements BindingSnapshotStore { * under an id an earlier version gave joins the history of the id it has now, and the file * is never rewritten. */ private async read(): Promise { - const ids = (await this.aliases?.load())?.ids; + const ids = (await this.aliases.load())?.ids; const snapshots: BranchSnapshot[] = []; for (const line of ((await readTextIfPresent(this.path)) ?? "").split("\n")) { const snapshot = parseBranchSnapshot(line); diff --git a/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts index 22322e569..9c7a78dfb 100644 --- a/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/ingest-usage-use-case.unit.test.ts @@ -9,6 +9,7 @@ import { readClaudeUsageLine } from "../../../../src/contexts/telemetry/domain/f import type { LocatedDirectory } from "../../../../src/contexts/telemetry/domain/ports/repository-locator.js"; import { resolutionKey } from "../../../../src/contexts/telemetry/domain/repository-resolution.js"; import { + ALREADY_MIGRATED, AUTUMN, cloneOf, FakeBindings, @@ -113,7 +114,8 @@ function setup(options: { refused?: boolean; caseInsensitive?: boolean } = {}) { now: () => NOW, }), snapshots, - { refusedByEnvironment: options.refused ?? false } + { refusedByEnvironment: options.refused ?? false }, + ALREADY_MIGRATED ); locator.directories.set("/work/a", repository("/work/a")); /** The clone at `path` consents: it says so now, and it opted in once. */ @@ -135,7 +137,8 @@ function setup(options: { refused?: boolean; caseInsensitive?: boolean } = {}) { now: () => NOW, }), snapshots, - { refusedByEnvironment: false } + { refusedByEnvironment: false }, + ALREADY_MIGRATED ); return { ingestReadingAll, diff --git a/cli/tests/contexts/telemetry/application/report/left-out.property.integration.test.ts b/cli/tests/contexts/telemetry/application/report/left-out.property.integration.test.ts index 3bf98d444..bf91be72b 100644 --- a/cli/tests/contexts/telemetry/application/report/left-out.property.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/report/left-out.property.integration.test.ts @@ -16,6 +16,7 @@ import type { Period } from "../../../../../src/contexts/telemetry/domain/report import { UsageLedgerAdapter } from "../../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; import { PrivateStorageAdapter } from "../../../../../src/runtime/filesystem/private-storage-adapter.js"; import { + ALREADY_MIGRATED, cloneOf, FakeBindings, FakeConsents, @@ -194,7 +195,8 @@ function machine() { now: () => NOW, }), snapshots, - { refusedByEnvironment: false } + { refusedByEnvironment: false }, + ALREADY_MIGRATED ); const report = new ReportUsageUseCase( ingest, diff --git a/cli/tests/contexts/telemetry/application/report/report-filters.property.integration.test.ts b/cli/tests/contexts/telemetry/application/report/report-filters.property.integration.test.ts index bf766ab9b..83f7a9204 100644 --- a/cli/tests/contexts/telemetry/application/report/report-filters.property.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/report/report-filters.property.integration.test.ts @@ -28,6 +28,7 @@ import { import type { StoredUsage } from "../../../../../src/contexts/telemetry/domain/stored-usage.js"; import { USAGE_AGENTS } from "../../../../../src/contexts/telemetry/domain/usage-record.js"; import { + ALREADY_MIGRATED, FakeBindings, FakeConsents, FakeLocator, @@ -132,7 +133,8 @@ async function reporterOf({ calls, declarations, person }: Ledger) { { caseInsensitiveFileSystem: false, now } ), new SnapshotBindingsUseCase(new FakeBindings(), snapshots, new InMemoryBindingsLock(), now), - { refusedByEnvironment: false } + { refusedByEnvironment: false }, + ALREADY_MIGRATED ), ledger, new DeclaredBindings(sessions, snapshots), diff --git a/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts index 1084fd49d..4d2734e93 100644 --- a/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/report/report-usage-use-case.unit.test.ts @@ -22,6 +22,7 @@ import { import type { ReportAxis } from "../../../../../src/contexts/telemetry/domain/report/usage-report.js"; import { repositoryIdOf } from "../../../../../src/contexts/telemetry/domain/repository-identity.js"; import { + ALREADY_MIGRATED, AUTUMN, cloneOf, FakeBindings, @@ -86,7 +87,8 @@ function setup(options: { refused?: boolean } = {}) { new InMemoryBindingsLock(), () => new Date("2026-10-09T00:00:00.000Z") ), - { refusedByEnvironment: options.refused ?? false } + { refusedByEnvironment: options.refused ?? false }, + ALREADY_MIGRATED ); locator.directories.set("/work/a", { status: "repository", diff --git a/cli/tests/contexts/telemetry/application/transcript-stream-ingest.property.integration.test.ts b/cli/tests/contexts/telemetry/application/transcript-stream-ingest.property.integration.test.ts index 9e92fa184..4275ffdb8 100644 --- a/cli/tests/contexts/telemetry/application/transcript-stream-ingest.property.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/transcript-stream-ingest.property.integration.test.ts @@ -14,6 +14,7 @@ import { ClaudeTranscriptSourceAdapter } from "../../../../src/contexts/telemetr import { UsageLedgerAdapter } from "../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; import { PrivateStorageAdapter } from "../../../../src/runtime/filesystem/private-storage-adapter.js"; import { + ALREADY_MIGRATED, cloneOf, FakeBindings, FakeConsents, @@ -144,7 +145,8 @@ function machine(source: TranscriptSource) { new InMemoryBindingsLock(), () => NOW ), - { refusedByEnvironment: false } + { refusedByEnvironment: false }, + ALREADY_MIGRATED ); const locked = cloneKey(cloneOf("/work/locked/.git", { ino: "/work/locked" })); const lock = (refused: boolean) => diff --git a/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts b/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts index adbe68fc4..7a9ae14bd 100644 --- a/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest"; import { currentKeyOf } from "../../../../../src/contexts/telemetry/domain/legacy/legacy-usage-key.js"; const AT = "2026-10-07T10:00:01.000Z"; -const call = (key: string, at = AT, session_id = "s-1") => ({ key, at, session_id }); +const call = (key: string, at = AT, session_id = "s-1", tool = "claude-code" as const) => ({ + tool, + key, + at, + session_id, +}); describe("a key an earlier version stored, as it is keyed now", () => { it("drops the timestamp from a requestless key", () => { @@ -28,6 +33,12 @@ describe("a key an earlier version stored, as it is keyed now", () => { expect(currentKeyOf(call(`s-1:${AT}`))).toBe(`s-1:${AT}`); }); + it("re-keys only what Claude Code keyed so, never another tool's key", () => { + expect(currentKeyOf({ tool: "codex", key: `msg_A:s-1:${AT}`, at: AT, session_id: "s-1" })).toBe( + `msg_A:s-1:${AT}` + ); + }); + it("re-keys at most once", () => { const once = currentKeyOf(call(`msg_A:s-1:${AT}#advisor0`)); expect(currentKeyOf(call(once))).toBe(once); diff --git a/cli/tests/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.integration.test.ts index 5c7a46731..c406cfbf8 100644 --- a/cli/tests/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.integration.test.ts @@ -8,6 +8,7 @@ import { } from "../../../../src/contexts/telemetry/domain/branch-binding.js"; import { BindingSnapshotStoreAdapter } from "../../../../src/contexts/telemetry/infrastructure/binding-snapshot-store-adapter.js"; import { PrivateStorageAdapter } from "../../../../src/runtime/filesystem/private-storage-adapter.js"; +import { NO_ALIASES } from "../../../helpers/ports/in-memory-telemetry.js"; let root: string; let dir: string; @@ -16,7 +17,7 @@ let store: BindingSnapshotStoreAdapter; beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "aidd-snapshots-")); dir = join(root, "bindings"); - store = new BindingSnapshotStoreAdapter(dir, new PrivateStorageAdapter()); + store = new BindingSnapshotStoreAdapter(dir, new PrivateStorageAdapter(), NO_ALIASES); }); afterEach(async () => { await rm(root, { recursive: true, force: true }); diff --git a/cli/tests/contexts/telemetry/infrastructure/bindings-lock-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/bindings-lock-adapter.integration.test.ts index b1438ac77..9e30be44f 100644 --- a/cli/tests/contexts/telemetry/infrastructure/bindings-lock-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/bindings-lock-adapter.integration.test.ts @@ -7,7 +7,7 @@ import { BindingSnapshotStoreAdapter } from "../../../../src/contexts/telemetry/ import { BindingsLockAdapter } from "../../../../src/contexts/telemetry/infrastructure/bindings-lock-adapter.js"; import { UsageLedgerAdapter } from "../../../../src/contexts/telemetry/infrastructure/usage-ledger-adapter.js"; import { PrivateStorageAdapter } from "../../../../src/runtime/filesystem/private-storage-adapter.js"; -import { FakeBindings } from "../../../helpers/ports/in-memory-telemetry.js"; +import { FakeBindings, NO_ALIASES } from "../../../helpers/ports/in-memory-telemetry.js"; let root: string; const storage = new PrivateStorageAdapter(); @@ -98,7 +98,7 @@ describe("the bindings lock", () => { const snapshotter = () => new SnapshotBindingsUseCase( source, - new BindingSnapshotStoreAdapter(dir, storage), + new BindingSnapshotStoreAdapter(dir, storage, NO_ALIASES), new BindingsLockAdapter(dir, storage), () => new Date("2026-10-09T09:00:00.000Z") ); From a06f98571289dd015c697e0fca39e5f4c712b4f5 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 09:13:28 +0200 Subject: [PATCH 10/19] fix(telemetry): state the measured spread, pending roots and table loss The usage contract and the task contract state that a requestless call is stored once, that a root unread at migration stays pending until it can be read, and what a damaged table costs branches.jsonl. Evidence holds the measurement, the red runs, the first-run cost and the round one review. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- aidd_docs/product/usage-contract.md | 4 +- .../contract.md | 4 +- .../evidence.md | 70 ++++++++++++++ .../review.md | 91 ++++++++++++++++++- 4 files changed, 164 insertions(+), 5 deletions(-) diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md index 08bb444a0..ddddd32d6 100644 --- a/aidd_docs/product/usage-contract.md +++ b/aidd_docs/product/usage-contract.md @@ -56,7 +56,7 @@ The ledger stores `StoredUsage`: the record plus `repository_id`, resolved at in | Rule | Value | | --- | --- | -| Key | `message.id:requestId`; without `requestId`, `message.id:sessionId` (a message id is unique per call); each advisor iteration adds `#advisor` | +| Key | `message.id:requestId`; without `requestId`, `message.id:sessionId` (a message id is unique per call; nothing bounds how far apart its copies are written, so an ingest storing such a call reads every day, and the ledger holds it once); each advisor iteration adds `#advisor` | | Fold | a call is written several times (streamed snapshots, resumed sessions, sub-agents); one record per `(tool, key)`: largest sum of the four counters (`null` as `0` for this comparison only) | | Tie | earliest `at`, then lowest `session_id`, then serialised record. No input order changes the winner | | Dropped | model `` | @@ -163,7 +163,7 @@ Directory: `AIDD_TELEMETRY_DIR`, else `AIDD_USER_CONFIG_DIR/telemetry`, else `XD | --- | --- | --- | --- | | `ledger/.jsonl` | `ingest`, `report` | `report`, `forget` | `StoredUsage` by UTC day of `at`, in time then key order, one call per line. Ingest reads its calls' days and the day on each side, and rewrites only a changed day, whole, by rename; no new bytes, no write. `report` rewrites, folded, the partitions it read that share a call or hold a line that is not a record; a report with no period reads every partition, so after it each call is on disk once. A line that is not a record (a newer version's, a hand edit, a torn one) is never removed: a rewrite keeps it in its day, after the records, in text order and once, and counts it in `skipped_ledger_lines` on every read. A day holding only such lines stays. A monthly `.jsonl` from an earlier version is still read, split into its days by the first ingest or report whose window touches it, or by a report with no period, then removed; its lines that are not records go to the first day of its month | | `ledger/left-out.txt` | `ingest` | `report`, `forget` | what ingests measured and could not store, by hash, see [Left out](#left-out). cli-v5.5.0 never reads it | -| `ledger/repository-ids.json` | `ingest`, `report` | every reader of the ledger, `roots.json` and `branches.jsonl` | `{version: 1, complete, aliases}`: the id an earlier version gave a repository, to its id now, both hashes or root commits. Made once, from each root `roots.json` names, by its `origin` now. Every read re-keys a call stored as `message.id:sessionId:timestamp` and re-ids by this table; the first ingest or report also rewrites `roots.json` and every ledger partition so, then marks it `complete`. A run stopped part way is finished by the next with the same table. An earlier version reading migrated data reads it as written; what it stores afterwards is re-keyed, and re-ided when its repository is in the table, at every read. A repository none of whose clones could be read then keeps its earlier id, and an `undated` hash in `left-out.txt` keeps its earlier key | +| `ledger/repository-ids.json` | `ingest`, `report` | every reader of the ledger, `roots.json` and `branches.jsonl` | `{version: 1, complete, aliases, pending}`: the id an earlier version gave a repository, to its id now, both hashes or root commits. Made from each root `roots.json` names, by its `origin` now. Every read re-keys a Claude Code call stored as `message.id:sessionId:timestamp` and re-ids by this table; the first ingest or report also rewrites `roots.json` and every ledger partition so, then marks it `complete`. A run stopped part way is finished by the next with the same table. A root that could not be read, or was not there, stays in `pending`: every later ingest looks at those roots alone and, when one adds an id, rewrites the stores again. Until then its stored calls keep the earlier id while its new calls get the id of now: two ids for one repository. An earlier version reading migrated data reads it as written; what it stores afterwards is re-keyed, and re-ided when its repository is in the table, at every read. `branches.jsonl` is never rewritten, so it relies on this table for good: a damaged table is made again from the clones still there, and a snapshot of a repository none of whose clones remains keeps its earlier id, so the calls of now no longer find its declaration. An `undated` hash in `left-out.txt` keeps its earlier key | | `ledger/offsets.json` | `ingest`, `report` | same | per transcript: bytes consumed, size, file identity. Shrunk or changed identity: read whole | | `ledger/roots.json` | `ingest`, `on` | `ingest`, `forget` | `{version: 2, directories}`: per directory and per clone seen alive there, `repository_id`, `root`, `clone`, `seen_at`, for every directory a read call or line named, in any clone. Other format holds nothing: a live directory is remembered again, a gone one counts `never-seen-alive`. `on` records its own root for `forget` | | `ledger/consents.jsonl` | `on`, `off`, `ingest`, hooks (close only) | same, plus `forget` | consent intervals, see [Consent](#consent). Outlives the clone; removed with the ledger. Hooks parse it strictly, pinned by the shared fixture | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md index 0a493e490..98ab339d2 100644 --- a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md @@ -89,7 +89,9 @@ flowchart TD | Limit | Effect | | --- | --- | -| A repository none of whose clones is readable at the migration | its earlier id stays; a later clone of it is stored under the new id: two ids | +| A root unreadable or absent at the migration | kept pending and looked at again by every ingest; until it can be read, its stored calls keep the earlier id and its new calls get the new one: two ids. A clone deleted for good stays pending, at the cost of one look per ingest | +| `repository-ids.json` damaged | made again from the clones still there; `branches.jsonl`, never rewritten, then loses the alias of a repository with no clone left, and its declarations stop matching the calls of now | +| Requestless copies more than a day apart | measured: no requestless line in 512,820 real usage lines (3,837 transcripts); copies of a message with a request id, 184,502 of them, at most 0.27 h apart. Nothing bounds the requestless ones, so storing one reads the whole ledger (4.5 s at 250,000 records) and the ledger holds it once | | A root whose `origin` changed since its calls were stored | the table maps the address it has now, so those calls keep their id, as before | | `undated` hashes in `left-out.txt` from before (unreleased: cli-v5.5.0 has no such file) | cannot be re-keyed; copies of one undated requestless call kept apart stay counted apart, and one read again from the start of its transcript adds a hash beside the old one | | A requestless call copied into another session | two keys, as before; seen copies across sessions carry a `requestId`, keyed without the session | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md index 91d803334..90a19d10e 100644 --- a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md @@ -144,3 +144,73 @@ Counterexample, pinned as its own test ("puts a day it neither re-keys nor re-id | Pinned test and the three properties, after | 4 of 4, in 15 runs with random seeds | | Mutation G-j: rewrite only the unsettled and damaged partitions again | killed by the pinned test | | Mutation G-e: no ledger rewrite | killed (3 tests) | + +## Review round 1 (62/100): D1, M1, minors + +### D1: how far apart are a requestless call's copies? Measured first + +Read-only scan of the maintainer's own Claude Code transcripts (script outside the repository; only these aggregates are kept): + +| Measure | Value | +| --- | --- | +| Transcript files, lines | 3,837; 2,147,449 | +| Usage lines (assistant, `message.usage`, not ``) | 512,820 | +| Of which with no `requestId` | 0 | +| Message ids with a request id, written more than once | 184,502 | +| Their widest spread between copies (any session) | 0.2687 h | + +No requestless copy exists to measure, so no spread can be cited for them and the window cannot rely on one. **Decision:** keep M10 (`message.id:sessionId`) and fix it structurally. An ingest that stores a requestless call reads every day, not the days around it (`storeCalls`), so the ledger holds such a call once, whatever its spread, and a bounded read cannot see a copy the full read folds away. The window stays ±1 day for calls with a request id, the measured case. Cost: one full ledger load per ingest that meets a requestless line, which these transcripts never did. + +Red run, before the fix (the probe's case pinned, both generators extended): + +```txt +× finds its copy days away, so the ledger holds the call once and a bounded read agrees + AssertionError: expected [ '2026-10-01.jsonl', …(1) ] to deeply equal [ '2026-10-04.jsonl' ] +× leave every bounded read equal to the full ledger filtered to its period + Counterexample: [[{"anchor":5,"requestless":true,"copies":[{"days":0,…},{"days":3,…,"ingest":1}]}],{"from":null,"to":"2026-11-01"}] + Caused by: AssertionError: expected [ { tool: 'claude-code', …(15) } ] to deeply equal [] +``` + +The new window property generates requestless copies up to five days apart, stored by up to four ingests (requested copies stay within the measured half day). The migration property's times gain copies 3 and 9 days apart, for requestless and requested calls alike. Its run against the unfixed code passed: it compares full reports only, which fold every copy. + +### M1: a root unreadable at the first run + +The table now keeps `pending`: the roots that were `unreadable` or `gone`. A complete table with pending roots makes each later run locate those roots alone. When one adds an id, it rewrites `roots.json` and the ledger again. When none does, it writes nothing, or only the shorter `pending` once a root is no repository any more. + +Red run, before the fix: 10 failed, 15 passed. + +```txt +× maps a root it could not read at its first run once it can, so the repository keeps one id + AssertionError: expected [] to deeply equal [ '/work/a' ] +× keeps a root gone at its first run pending, and drops one that is no repository now +× asks only the pending roots again, and writes nothing while none can be read +× reads back what it wrote, its ids in text order (and the table's other shapes, with `pending`) +``` + +The "drops one that is no repository now" second half was added after the fix. It is proven by mutation M1-g. + +### Minors + +| # | Change | Proof | +| --- | --- | --- | +| m1 | re-keying applies to `claude-code` records only | red before: `× re-keys only what Claude Code keyed so, never another tool's key`; mutation m1 killed | +| m2 | the contract says what a damaged table costs `branches.jsonl` | doc | +| m3 | first-run cost measured, below | no change needed | +| m4 | the migration (`IngestUsageUseCase`) and the alias store (`BindingSnapshotStoreAdapter`) are required. The ledger and `roots.json` adapters build theirs from their own directory, so no wiring can leave them out | `tsc` | +| m5 | `RepositoryIdMigration` reference fixed | - | +| m6 | `period.ts` states both bounds | - | + +### m3: first-run cost against the hook's 110 s + +Probe outside the repository, sandboxed `HOME`, real git: every root is a `git clone --shared` of this repository (1,077 commits), origin `git@github.com:Acme/Tool.git`, so each needs an alias. The ledger has 365 days of records, one in ten with an earlier requestless key. + +| Roots | Records | Ledger | `locate` per root | First run | Second run | Heap | +| --- | --- | --- | --- | --- | --- | --- | +| 50 | 250,000 | 112 MB | 29 ms | 4.5 s | 0 ms | 313 MB | +| 200 | 1,000,000 | 449 MB | 38 ms | 20.8 s | 1 ms | 1.4 GB | + +Both stay well inside 110 s. The 250,000-record case is about half the usage lines found on the measuring machine, before folding. Progress is not split further. + +### Hand mutations, round 2 + +Each applied alone, the named tests run, the file restored: 12 of 12 went red. D1-a to D1-d: the full read, the session test, the advisor suffix, the tool. m1. M1-a to M1-g: unreadable roots pending, gone ones too, a complete table with pending roots looks again, only the pending roots are asked, nothing new writes nothing, a dropped root is saved, `pending` kept in the file. diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md index d968d468b..5d09e6ac8 100644 --- a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md @@ -1,7 +1,94 @@ --- -status: pending +status: changes-requested --- # Review: One call, one key; one repository, one id -Pending independent review. +Head `57169330`, base `origin/next`. Independent check, read-only on the code. + +**Verdict: changes requested. Score 62/100.** + +## Deal breakers + +### D1. The new requestless key breaks the #1032 window property, under a bound nobody measured (High) + +- **Where:** `domain/formats/claude-transcript-usage.ts:99` (the key), `domain/report/period.ts:34-39` (`windowOf`), `domain/stored-usage.ts` `daysAround`. +- **Why the bound does not cover it:** `windowOf` and `daysAround` read one day on each side. `period.ts` justifies that with "of 262,631 real keys, the widest spread is 0.3 h". That data could never contain a requestless call's copies: the earlier key ended in the timestamp, so each copy had its own key, with a spread of 0. M10 now folds copies whose spread nobody has measured. The contract says those copies "carry different timestamps", which is why they need folding at all. +- **Failure, reproduced:** one requestless call, `msg_1` in session `s`, copies at `2026-10-01T10:00Z` and `2026-10-04T10:00Z`. + - Each `storeCalls` reads only the days around its own copy, so both copies are stored, in two days. + - `settleLedger(windowOf({from: 2026-10-04, to: 2026-10-04}))`, filtered to the period, holds 1 record. The unbounded settle, filtered the same way, holds 0. + - Probe output: `PROBE window bounded= 1 full= 0`. + - Effect: the reports for 10-01 and for 10-04 sum to 2 calls. The full report says 1. The period axis is not deterministic. +- **Why the tests miss it:** + - `report-window.property` keys every call `k${index}` and keeps its copies within `HALF_DAY`. + - The migration property's `TIMES` keep copies seconds apart. +- **Fix:** + - Measure the spread of `message.id:sessionId` among requestless lines in real transcripts. Then either cite the measurement in `period.ts` and in the contract's Limits, or widen the window to match it. + - Add requestless copies more than a day apart to the window property's generator, and to the migration property's `TIMES`. + +## Major + +### M1. A root unreadable at the first run is never mapped, so the PR creates the split it set out to remove + +- **Where:** `application/legacy/stored-usage-migration.ts:39-47`. `table()` skips any root whose status is not `repository`, and `execute` marks the table `complete` regardless. +- **Failure, reproduced:** + - Setup: root `/w/a` has origin `git@github.com:Acme/Tool.git`, where the legacy id differs from the new one. It reads `unreadable` on the first run and `repository` on the second. + - Probe output after both runs: `{"complete":true,"size":0}`. + - The resolver then names that clone's new calls by the new id (`directory-resolver.ts:183`), while its stored calls keep the legacy id. Before this PR the repository had one id; after it, two. + - The first run after an upgrade is likely to be the async `catch-up.cjs` hook, with a 110 s timeout and the hook's environment. That makes a transient `unreadable` or `gone` (an unmounted volume) plausible. +- **Contradiction:** `DirectoryResolver` treats `unreadable` as something that may become readable later (`mayConsent`). The migration treats it as final. +- **Fix:** do not mark the table `complete` while any root is `unreadable`, or keep a list of pending roots and re-locate them on later runs. Adding an alias later is safe: every reader applies the table at read time, and aliases cannot chain (see Verified). +- **Docs:** the `repository-ids.json` row in `usage-contract.md` says such a repository "keeps its earlier id". It does not say that its new calls get the new id. State the split. + +## Minor + +| # | Where | Finding | Fix | +| --- | --- | --- | --- | +| m1 | `domain/legacy/legacy-usage-key.ts:10-17` | Re-keying ignores `tool`. No released writer stores `codex` records: `cli-v5.5.0` and HEAD have only the Claude format, so nothing is affected today. A future codex key ending in `:session:at` would be collapsed silently. | Re-key only `claude-code` records. | +| m2 | `infrastructure/binding-snapshot-store-adapter.ts` `read()` | `branches.jsonl` is never rewritten, so it depends on `repository-ids.json` for good. If the table is damaged, `parseRepositoryAliases` returns `null` and the table is rebuilt from the clones that still exist. Snapshots of a repository whose clones are gone by then lose their alias for good, and so does their task attribution. | Document it, or rewrite `branches.jsonl` once under the bindings lock. | +| m3 | `stored-usage-migration.ts:37-48` | First-run cost is not measured. `locate` costs about four git spawns per root, including `rev-list --max-parents=0 HEAD`, which walks the whole history, though only `origin` is needed when it exists. The table is saved only after every root is located. If the `catch-up` hook's 110 s timeout is exceeded, the work is lost and redone at every SessionStart, and the hook's ingest never runs. | Measure on a large `roots.json`, or locate the remote only. | +| m4 | `usage-ledger-adapter.ts:64`, `resolution-store-adapter.ts:23`, `binding-snapshot-store-adapter.ts:17`, `ingest-usage-use-case.ts:74` | The alias store is wired three ways: built inside two adapters, and an optional parameter in the third. The `migration?` collaborator is also optional. A wiring that omits either one silently skips re-iding or migrating. | Inject both and make them required. | +| m5 | `domain/legacy/legacy-repository-identity.ts:5` | Cites `RepositoryIdMigration`, which does not exist. The class is `StoredUsageMigration`. | Rename the reference. | +| m6 | `domain/report/period.ts:34-38` | The doc's empirical bound no longer covers every key (see D1). | Update it together with D1. | +| m7 | evidence.md | Tests are committed with their code. The red runs are recorded only as text. The legacy tests went red because their modules were missing, not on behaviour. The stop property passed against the skeleton and is proven only by mutation G-h. evidence.md admits all of this, and the hand mutations compensate. | None required. | + +## Verified, holds + +- **Identity: no two repositories get one id.** + - Host folding follows RFC 3986. + - Path folding applies to `github.com` only, which GitHub documents as case-insensitive. + - `ssh.github.com` maps to `github.com`. + - A root-commit fallback is 40 hex characters; an address hash is 64. They cannot collide. +- **Aliases cannot chain.** A legacy id differs from its new id only when the address held an upper-case host, an upper-case path on `github.com`, or `ssh.github.com`. No new address holds any of these, so no alias target is itself aliased. Idempotence holds. +- **Legacy rule is faithful.** `legacyRepositoryIdOf` reproduces `cli-v5.5.0` exactly: it uses URL `hostname`, folds the host for scp-like addresses only, and applies the same `ownerAndRepo`. +- **Key: no merge across forms, no double count.** + - `currentKeyOf` strips only the record's own `:session_id:at`. + - A stored `at` is the raw `timestamp` text that the earlier key embedded, so the two always agree. + - Keys with a `requestId` are untouched, and advisor suffixes are kept. + - Covered: an old CLI writing after the migration, and a half-migrated store. Every reader re-keys and re-ids at read time, whether or not the table is `complete`. +- **Crash order and lock.** + - Order: table (incomplete), then `roots.json`, then every partition, then `complete`. `save` writes before it removes, by write-then-rename. + - The migration runs inside `ledger.exclusively`. + - `task` takes only the bindings lock, so `prompt-gate` is not blocked. +- **git missing:** `runGit` throws inside `table()` before any write. The ingest fails, nothing persists, and the next run retries. Ingest already needs git. +- **Plugin hooks** compute no id and no key. Grep of `hooks/` for `repository_id|branches.jsonl|roots.json`: no hit. +- **Equivalent mutants confirmed.** + - `legacy-repository-identity.ts:9`: `path` is `null` whenever `address` is. + - `binding-snapshot-store-adapter.ts:45`: `parseBranchSnapshot` returns `null` on non-JSON and does not throw. + - Other survivors are on untouched lines. `git diff -U0` hunks: ingest 73-74 and 96-97; ledger 5, 23, 56, 64, 78-80, 87-88, 93-94; transcript 99. +- **Docs:** `usage-contract.md` and `telemetry.md` are consistent with the code and with envelope v2. The 01-usage skill states no key or id rule, so it needs no change. +- **Bundle:** `node scripts/check-bundle-size.mjs` prints `Bundle size: 699.8 KB / budget: 709 KB`, `OK: within budget`. There is no raise, so the policy's ~2 % headroom rule does not apply. Headroom is now 1.3 %. + +## Commands + +| Command | Decisive output | +| --- | --- | +| `vitest run` on the identity, legacy, format and `stored-ids-and-keys` tests | `Test Files 9 passed`, `Tests 140 passed` | +| `vitest run` on `stored-usage-migration.property` and `report-window.property` | `Tests 5 passed` | +| `vitest run` on `telemetry-ingest.e2e` and `ledger-upkeep` | `Tests 27 passed` | +| scratch probe (outside the repo): requestless copies 3 days apart | `PROBE window bounded= 1 full= 0` | +| scratch probe: root `unreadable`, then readable | `{"complete":true,"size":0}` | +| `git grep "key =" cli-v5.5.0 -- formats/` | Claude format only, no codex writer | +| `node scripts/check-bundle-size.mjs` | `699.8 KB / budget: 709 KB` | + +Not run: the full suite and Stryker, per the constraints. The mutation claims were verified by reading the code and the diff hunks. From f5ab70aea92414fcc8687719a616845ccc692745 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 09:32:11 +0200 Subject: [PATCH 11/19] fix(telemetry): one advisor key pattern, and no guard nothing fails for The re-keying and the requestless test shared a copy of the advisor suffix pattern; they now use the format's own, pinned with a two-digit iteration. The migration's early return for a complete table with nothing pending changed nothing: that run locates nothing and writes nothing, so the guard is gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../telemetry/application/legacy/stored-usage-migration.ts | 1 - .../telemetry/domain/formats/claude-transcript-usage.ts | 5 +++-- cli/src/contexts/telemetry/domain/legacy/legacy-usage-key.ts | 3 +-- .../domain/formats/claude-transcript-usage.unit.test.ts | 1 + .../telemetry/domain/legacy/legacy-usage-key.unit.test.ts | 1 + 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts b/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts index a3045ecd4..aa4869bc3 100644 --- a/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts +++ b/cli/src/contexts/telemetry/application/legacy/stored-usage-migration.ts @@ -25,7 +25,6 @@ export class StoredUsageMigration { async execute(): Promise { const held = await this.aliases.load(); - if (held?.complete && held.pending.length === 0) return; let table = held; if (table === null || table.complete) { const roots = table?.pending ?? (await this.roots()); diff --git a/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts b/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts index 0bcebf153..8e71c5a95 100644 --- a/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts +++ b/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts @@ -12,6 +12,8 @@ export interface ClaudeUsageLine { type Json = Record; const SYNTHETIC_MODEL = ""; +/** What an advisor iteration adds to its message's key. */ +export const ADVISOR = /#advisor\d+$/; const NONE: ClaudeUsageLine = { records: [], unrecognised: [] }; function counter(value: unknown): number | null { @@ -76,8 +78,7 @@ function sum(values: readonly (number | null)[]): number { * with a request id were measured under half an hour apart (see `windowOf`). */ export function isKeyedBySession(call: Pick): boolean { return ( - call.tool === "claude-code" && - call.key.replace(/#advisor\d+$/, "").endsWith(`:${call.session_id}`) + call.tool === "claude-code" && call.key.replace(ADVISOR, "").endsWith(`:${call.session_id}`) ); } diff --git a/cli/src/contexts/telemetry/domain/legacy/legacy-usage-key.ts b/cli/src/contexts/telemetry/domain/legacy/legacy-usage-key.ts index 029f466ae..b68858b9d 100644 --- a/cli/src/contexts/telemetry/domain/legacy/legacy-usage-key.ts +++ b/cli/src/contexts/telemetry/domain/legacy/legacy-usage-key.ts @@ -1,8 +1,7 @@ +import { ADVISOR } from "../formats/claude-transcript-usage.js"; import type { StoredUsage } from "../stored-usage.js"; import type { UsageRecord } from "../usage-record.js"; -const ADVISOR = /#advisor\d+$/; - /** A call's key as it is keyed now. An earlier version keyed a call with no request id * `message.id:sessionId:timestamp`, so each copy of it had its own key; the timestamp is the * call's own `at`, after its own session, and is dropped; only Claude Code was keyed so. Any diff --git a/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts index a5d1d4bfd..6f85f4b7f 100644 --- a/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/formats/claude-transcript-usage.unit.test.ts @@ -331,6 +331,7 @@ describe("a call keyed by its session, having no request id", () => { }); expect(isKeyedBySession(call("msg_A:s-1"))).toBe(true); expect(isKeyedBySession(call("msg_A:s-1#advisor3"))).toBe(true); + expect(isKeyedBySession(call("msg_A:s-1#advisor10"))).toBe(true); expect(isKeyedBySession(call("msg_A:req_A"))).toBe(false); expect(isKeyedBySession(call("msg_A:xs-1"))).toBe(false); expect(isKeyedBySession(call("msg_A:s-1", "codex"))).toBe(false); diff --git a/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts b/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts index 7a9ae14bd..9543e1e65 100644 --- a/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts @@ -16,6 +16,7 @@ describe("a key an earlier version stored, as it is keyed now", () => { it("keeps an advisor iteration's suffix", () => { expect(currentKeyOf(call(`msg_A:s-1:${AT}#advisor12`))).toBe("msg_A:s-1#advisor12"); + expect(currentKeyOf(call(`msg_A:s-1:${AT}#advisor3`))).toBe("msg_A:s-1#advisor3"); }); it("keeps a key with a request id, and one keyed now, as they are", () => { From 0bcf3cdea15352291908d080ba881d5e27fbddfb Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 09:33:09 +0200 Subject: [PATCH 12/19] fix(telemetry): exercise unread roots and older-CLI lines in the properties The migration property's first run now has clones unreadable or gone, back by the next. A failure at every write of a pending root's retry ends as a clean retry. The window property generates requestless lines an older CLI writes after the migration and exempts the bounded read for those calls alone, the stated limit. The ledger and roots adapters say why they build their alias table themselves. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../resolution-store-adapter.ts | 1 + .../infrastructure/usage-ledger-adapter.ts | 4 +- ...age-migration.property.integration.test.ts | 17 ++++++- ...report-window.property.integration.test.ts | 47 ++++++++++++++----- .../stored-ids-and-keys.integration.test.ts | 33 +++++++++++++ 5 files changed, 88 insertions(+), 14 deletions(-) diff --git a/cli/src/contexts/telemetry/infrastructure/resolution-store-adapter.ts b/cli/src/contexts/telemetry/infrastructure/resolution-store-adapter.ts index c089c6019..b91f81941 100644 --- a/cli/src/contexts/telemetry/infrastructure/resolution-store-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/resolution-store-adapter.ts @@ -19,6 +19,7 @@ export class ResolutionStoreAdapter implements ResolutionStore { private readonly storage: PrivateStorage ) { this.path = join(ledgerDir, "roots.json"); + // Its own directory's table, read at every load: no wiring can leave it out. this.aliases = new RepositoryAliasStoreAdapter(ledgerDir, storage); } diff --git a/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts b/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts index 9ac42c6bb..9830de2b9 100644 --- a/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/usage-ledger-adapter.ts @@ -39,7 +39,8 @@ function partitionOf(name: string): string { * ledger is never appended to: a changed day is rewritten whole, by temporary file and * rename, and an unchanged one is not touched, so no reader ever meets a torn line. An ingest * reads the days around its calls, plus every day of a month it splits, and writes only what - * changed, so its cost does not grow with the ledger. Between reports, a call whose snapshots + * changed, so its cost does not grow with the ledger; one storing a call with no request id + * reads every day (see `storeCalls`). Between reports, a call whose snapshots * fell outside that window may sit in two days, and a crashed split leaves it in a month and a * day: every reader folds at read, so it is still counted once. Once a report with no bound * has settled the ledger, each call is on disk once and the bytes depend on what the ledger @@ -61,6 +62,7 @@ export class UsageLedgerAdapter implements UsageLedger { private readonly lockOptions: Partial = {} ) { this.leftOut = new LeftOutAdapter(join(dir, "left-out.txt"), dir, storage); + // Its own directory's table, read at every load: no wiring can leave it out. this.aliases = new RepositoryAliasStoreAdapter(dir, storage); } diff --git a/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts b/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts index f588b1c64..942ef5585 100644 --- a/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/legacy/stored-usage-migration.property.integration.test.ts @@ -145,6 +145,15 @@ const scenario = fc.record({ batches: fc.array(fc.nat(3), { minLength: 24, maxLength: 24 }), /** Whether October is still in the earlier monthly layout. */ monthly: fc.boolean(), + /** Clones git cannot read, or not there (an unmounted volume), at the first run after the + * upgrade, and back by the next. */ + away: fc.array( + fc.record({ + dir: fc.constantFrom(...DIRS), + as: fc.constantFrom("unreadable" as const, "gone" as const), + }), + { maxLength: 2 } + ), }); type Scenario = typeof scenario extends fc.Arbitrary ? T : never; @@ -259,7 +268,7 @@ function machine(name: string, storage: PrivateStorage = new PrivateStorageAdapt locator, { refusedByEnvironment: false } ); - return { dir, bindings, transcripts, report }; + return { dir, bindings, transcripts, report, locator }; } const AXES: readonly ReportAxis[] = ["total", "repository", "task", "session", "agent", "day"]; @@ -309,6 +318,11 @@ describe("stored data from an earlier version, once migrated", () => { async function asFresh(s: Scenario): Promise { const up = await upgraded(s); const one = await fresh(s); + // The first run after the upgrade, with some clones out of reach; then they are back. + const back = new Map(s.away.map(({ dir }) => [dir, up.locator.directories.get(dir)])); + for (const { dir, as } of s.away) up.locator.directories.set(dir, { status: as }); + await up.report.execute({ axis: "total", period: { from: null, to: null } }); + for (const [dir, located] of back) if (located) up.locator.directories.set(dir, located); await figures(up); await figures(one); expect(await figures(up)).toEqual(await figures(one)); @@ -330,6 +344,7 @@ describe("stored data from an earlier version, once migrated", () => { ], batches: Array.from({ length: 24 }, () => 0), monthly: false, + away: [], }); }); diff --git a/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts b/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts index 6cbf927e5..327a934da 100644 --- a/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { appendFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import * as fc from "fast-check"; @@ -315,12 +315,15 @@ const storedCall = fc.record({ output: fc.nat(5), // Which ingest read this copy. ingest: fc.nat(3), + // Read by an older CLI after the migration: a requestless copy then gets its own + // timestamped key, appended to its own day. + older: fc.boolean(), }), { minLength: 1, maxLength: 3 } ), }); -describe("calls stored by ingests, a requestless call's copies days apart", () => { +describe("calls stored by ingests, a requestless call's copies days apart, some by an older CLI", () => { it("leave every bounded read equal to the full ledger filtered to its period", async () => { await fc.assert( fc.asyncProperty( @@ -328,20 +331,24 @@ describe("calls stored by ingests, a requestless call's copies days apart", () = period, async (calls, asked) => { const ledger = await ledgerWith(new Map()); + const dir = join(root, `ledger-${runs}`); + const olderOf = (requestless: boolean, older: boolean) => requestless && older; for (let ingest = 0; ingest < 4; ingest += 1) { const batch = calls.flatMap(({ anchor, requestless, copies }, index) => copies .filter((c) => c.ingest === ingest) - .map( - ({ days, offset, output }): StoredUsage => ({ + .map(({ days, offset, output, older }): StoredUsage => { + const at = new Date( + (ANCHORS[anchor] as number) + (requestless ? days * DAY_MS : 0) + offset + ).toISOString(); + const key = requestless ? `msg_${index}:s-1` : `msg_${index}:req_${index}`; + return { tool: "claude-code", tool_version: "1.0.0", - key: requestless ? `msg_${index}:s-1` : `msg_${index}:req_${index}`, + key: olderOf(requestless, older) ? `${key}:${at}` : key, session_id: "s-1", agent: "main", - at: new Date( - (ANCHORS[anchor] as number) + (requestless ? days * DAY_MS : 0) + offset - ).toISOString(), + at, model: "m-1", input: 1, output, @@ -352,15 +359,31 @@ describe("calls stored by ingests, a requestless call's copies days apart", () = cwd: null, git_branch: null, repository_id: "repo-1", - }) - ) + }; + }) ); - if (batch.length > 0) await storeCalls(ledger, batch); + const older = batch.filter((record) => record.key.split(":").length > 2); + const now = batch.filter((record) => !older.includes(record)); + if (now.length > 0) await storeCalls(ledger, now); + for (const record of older) { + await appendFile( + join(dir, `${record.at.slice(0, 10)}.jsonl`), + `${JSON.stringify(record)}\n` + ); + } } const bounded = (await ledger.load(windowOf(asked))).records; const full = (await ledger.load()).records; + // A stated limit, and the only one: a requestless call an older CLI also stored after + // the migration may sit in two days until a report with no period settles it, so a + // bounded read is exempt for that call alone. The full read is never exempt. + const exempt = new Set( + calls.flatMap(({ requestless, copies }, index) => + copies.some((c) => olderOf(requestless, c.older)) ? [`msg_${index}:s-1`] : [] + ) + ); const inside = (records: readonly StoredUsage[]) => - records.filter((record) => inPeriod(record.at, asked)); + records.filter((record) => inPeriod(record.at, asked) && !exempt.has(record.key)); expect(inside(bounded)).toEqual(inside(full)); // One record per call, whatever its copies' spread. expect(full.length).toBe(calls.filter((c) => c.copies.length > 0).length); diff --git a/cli/tests/contexts/telemetry/infrastructure/stored-ids-and-keys.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/stored-ids-and-keys.integration.test.ts index 7005fa0cb..3d245be94 100644 --- a/cli/tests/contexts/telemetry/infrastructure/stored-ids-and-keys.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/stored-ids-and-keys.integration.test.ts @@ -247,6 +247,39 @@ describe("the migration", () => { expect(await readFile(join(dir, "roots.json"), "utf8")).not.toContain(OLD); }); + it("stopped at any write of a pending root's retry and run again, ends as a retry never stopped", async () => { + const files = async () => + Promise.all( + ["2026-10-07.jsonl", "roots.json", "repository-ids.json"].map((name) => + readFile(join(dir, name), "utf8") + ) + ); + const firstRun = async () => { + await rooted("/work/a"); + await writeFile(join(dir, "2026-10-07.jsonl"), `${JSON.stringify(call())}\n`); + await rm(join(dir, "repository-ids.json"), { force: true }); + const locator = new FakeLocator(); + locator.directories.set("/work/a", { status: "unreadable" }); + await migration(locator).execute(); + locator.directories.set("/work/a", live(MIXED.remote)); + return locator; + }; + await migration(await firstRun()).execute(); + const clean = await files(); + for (let failAt = 1; ; failAt += 1) { + const locator = await firstRun(); + const stopped = await migration(locator, new FailingStorage(failAt)) + .execute() + .then( + () => false, + () => true + ); + await migration(locator).execute(); + expect(await files()).toEqual(clean); + if (!stopped) break; + } + }); + it("keeps a root gone at its first run pending, and drops one that is no repository now", async () => { await rooted("/work/a", "/work/b"); const locator = new FakeLocator(); From 429948a308bd2e3c866b9d370cec9e580dfc1e44 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 09:33:57 +0200 Subject: [PATCH 13/19] docs(telemetry): the older-CLI limit, pending roots, measured store cost Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- aidd_docs/product/usage-contract.md | 8 +- .../contract.md | 12 +- .../evidence.md | 30 +++ .../review.md | 173 ++++++++++-------- cli/aidd_docs/memory/telemetry.md | 2 +- 5 files changed, 143 insertions(+), 82 deletions(-) diff --git a/aidd_docs/product/usage-contract.md b/aidd_docs/product/usage-contract.md index ddddd32d6..48e432e47 100644 --- a/aidd_docs/product/usage-contract.md +++ b/aidd_docs/product/usage-contract.md @@ -56,7 +56,7 @@ The ledger stores `StoredUsage`: the record plus `repository_id`, resolved at in | Rule | Value | | --- | --- | -| Key | `message.id:requestId`; without `requestId`, `message.id:sessionId` (a message id is unique per call; nothing bounds how far apart its copies are written, so an ingest storing such a call reads every day, and the ledger holds it once); each advisor iteration adds `#advisor` | +| Key | `message.id:requestId`; without `requestId`, `message.id:sessionId` (a message id is unique per call; nothing bounds how far apart its copies are written, so an ingest storing such a call reads every day, and the ledger holds it once, except copies an older CLI stores after the migration, which only a report with no period folds); each advisor iteration adds `#advisor` | | Fold | a call is written several times (streamed snapshots, resumed sessions, sub-agents); one record per `(tool, key)`: largest sum of the four counters (`null` as `0` for this comparison only) | | Tie | earliest `at`, then lowest `session_id`, then serialised record. No input order changes the winner | | Dropped | model `` | @@ -124,7 +124,7 @@ Row and `totals`: `records`, `input`, `output`, `cache_read`, `cache_write` (eac - `AIDD_TELEMETRY=0`: nothing read, object is `{"version": 2, "refused": "AIDD_TELEMETRY=0"}`. - The text report labels an `absent` person `anonymous`; the envelope keeps `kind: absent`, `key: null`. - A report ingests first; what that read found is its `run`, see [the envelope reference](../../plugins/aidd-telemetry/skills/01-usage/references/envelope.md#run). `coverage.records` describes the period; `coverage.left_out` every ingest so far. -- A report with `--days`, `--from` or `--to` reads only the days of its period and the day on each side, plus any monthly file one of them falls in, with every day of its month; with none it reads the whole ledger. The copies of one call have been observed less than a day apart, and under that bound both print the same figures. Outside it, only a report with no period folds every copy of a call. `run.skipped_ledger_lines` counts the lines of the partitions read. +- A report with `--days`, `--from` or `--to` reads only the days of its period and the day on each side, plus any monthly file one of them falls in, with every day of its month; with none it reads the whole ledger. The copies of a call with a `requestId` have been observed less than a day apart, and under that bound both print the same figures; a call with none is held once by the ingest that stores it, so no bound is needed. Outside these, only a report with no period folds every copy of a call: copies an older CLI stores after the migration are one such case. `run.skipped_ledger_lines` counts the lines of the partitions read. - No path, working directory or branch. `session` keys are full session ids, `repository` keys are hashes. ### Filters @@ -161,9 +161,9 @@ Directory: `AIDD_TELEMETRY_DIR`, else `AIDD_USER_CONFIG_DIR/telemetry`, else `XD | Store | Writer | Readers | Holds | | --- | --- | --- | --- | -| `ledger/.jsonl` | `ingest`, `report` | `report`, `forget` | `StoredUsage` by UTC day of `at`, in time then key order, one call per line. Ingest reads its calls' days and the day on each side, and rewrites only a changed day, whole, by rename; no new bytes, no write. `report` rewrites, folded, the partitions it read that share a call or hold a line that is not a record; a report with no period reads every partition, so after it each call is on disk once. A line that is not a record (a newer version's, a hand edit, a torn one) is never removed: a rewrite keeps it in its day, after the records, in text order and once, and counts it in `skipped_ledger_lines` on every read. A day holding only such lines stays. A monthly `.jsonl` from an earlier version is still read, split into its days by the first ingest or report whose window touches it, or by a report with no period, then removed; its lines that are not records go to the first day of its month | +| `ledger/.jsonl` | `ingest`, `report` | `report`, `forget` | `StoredUsage` by UTC day of `at`, in time then key order, one call per line. Ingest reads its calls' days and the day on each side (every day, and so every monthly file, when one has no `requestId`), and rewrites only a changed day, whole, by rename; no new bytes, no write. `report` rewrites, folded, the partitions it read that share a call or hold a line that is not a record; a report with no period reads every partition, so after it each call is on disk once. A line that is not a record (a newer version's, a hand edit, a torn one) is never removed: a rewrite keeps it in its day, after the records, in text order and once, and counts it in `skipped_ledger_lines` on every read. A day holding only such lines stays. A monthly `.jsonl` from an earlier version is still read, split into its days by the first ingest or report whose window touches it, or by a report with no period, then removed; its lines that are not records go to the first day of its month | | `ledger/left-out.txt` | `ingest` | `report`, `forget` | what ingests measured and could not store, by hash, see [Left out](#left-out). cli-v5.5.0 never reads it | -| `ledger/repository-ids.json` | `ingest`, `report` | every reader of the ledger, `roots.json` and `branches.jsonl` | `{version: 1, complete, aliases, pending}`: the id an earlier version gave a repository, to its id now, both hashes or root commits. Made from each root `roots.json` names, by its `origin` now. Every read re-keys a Claude Code call stored as `message.id:sessionId:timestamp` and re-ids by this table; the first ingest or report also rewrites `roots.json` and every ledger partition so, then marks it `complete`. A run stopped part way is finished by the next with the same table. A root that could not be read, or was not there, stays in `pending`: every later ingest looks at those roots alone and, when one adds an id, rewrites the stores again. Until then its stored calls keep the earlier id while its new calls get the id of now: two ids for one repository. An earlier version reading migrated data reads it as written; what it stores afterwards is re-keyed, and re-ided when its repository is in the table, at every read. `branches.jsonl` is never rewritten, so it relies on this table for good: a damaged table is made again from the clones still there, and a snapshot of a repository none of whose clones remains keeps its earlier id, so the calls of now no longer find its declaration. An `undated` hash in `left-out.txt` keeps its earlier key | +| `ledger/repository-ids.json` | `ingest`, `report` | every reader of the ledger, `roots.json` and `branches.jsonl` | `{version: 1, complete, aliases, pending}`: the id an earlier version gave a repository, to its id now, both hashes or root commits. Made from each root `roots.json` names, by its `origin` now. Every read re-keys a Claude Code call stored as `message.id:sessionId:timestamp` and re-ids by this table; the first ingest or report also rewrites `roots.json` and every ledger partition so, then marks it `complete`. A run stopped part way is finished by the next with the same table. A root that could not be read, or was not there, stays in `pending`: every later ingest looks at those roots alone and, when one adds an id, rewrites the stores again. Until then its stored calls keep the earlier id while its new calls get the id of now: two ids for one repository. An earlier version reading migrated data reads it as written; what it stores afterwards is re-keyed, and re-ided when its repository is in the table, at every read. A requestless call it stores then may sit in two days: a report with a period may count that copy, until a report with no period settles the ledger. `branches.jsonl` is never rewritten, so it relies on this table for good: a damaged table is made again from the clones still there, and a snapshot of a repository none of whose clones remains keeps its earlier id, so the calls of now no longer find its declaration. An `undated` hash in `left-out.txt` keeps its earlier key | | `ledger/offsets.json` | `ingest`, `report` | same | per transcript: bytes consumed, size, file identity. Shrunk or changed identity: read whole | | `ledger/roots.json` | `ingest`, `on` | `ingest`, `forget` | `{version: 2, directories}`: per directory and per clone seen alive there, `repository_id`, `root`, `clone`, `seen_at`, for every directory a read call or line named, in any clone. Other format holds nothing: a live directory is remembered again, a gone one counts `never-seen-alive`. `on` records its own root for `forget` | | `ledger/consents.jsonl` | `on`, `off`, `ingest`, hooks (close only) | same, plus `forget` | consent intervals, see [Consent](#consent). Outlives the clone; removed with the ledger. Hooks parse it strictly, pinned by the shared fixture | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md index 98ab339d2..8b4c8bc6f 100644 --- a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/contract.md @@ -56,7 +56,10 @@ Everything under the telemetry directory, and what this fix does to it. ```mermaid flowchart TD S["ingest or report, under the ledger lock"] --> J{"repository-ids.json complete?"} - J -->|yes| I["ingest as usual"] + J -->|"yes, none pending"| I["ingest as usual"] + J -->|"yes, roots pending"| P["locate the pending roots alone"] + P -->|"no new id"| I + P -->|"a new id"| W1 J -->|absent| M["locate each root roots.json names: earlier id to id, from its origin now"] M --> W1["write repository-ids.json, not complete"] J -->|not complete| R @@ -71,10 +74,10 @@ flowchart TD | Deterministic | the alias table is a function of each root's `origin` now, written before anything else; a rerun reuses it | | Idempotent | a key re-keys at most once (the new shape never ends in its own `:at`); an alias never names an id that is itself aliased: an earlier address differs from today's only by an upper-case letter or `ssh.github.com`, which no address of today holds | | Crash-safe | under the ledger lock; every write is the existing write-then-rename; the table is written first and marked complete last, so a run stopped at any step leaves it incomplete and the next run redoes the whole rewrite. Exercised: a failure at every write (`replace`). Reasoned, not exercised: a stop during the removals that follow the writes, and a `kill -9` | -| Read-time | every reader of the ledger, `roots.json` and `branches.jsonl` applies the key rule and the table, so a state the migration has not finished, or lines an older CLI writes afterwards, still count each call once under one id | +| Read-time | every reader of the ledger, `roots.json` and `branches.jsonl` applies the key rule and the table, so a state the migration has not finished, or lines an older CLI writes afterwards, still count each call once under one id in a report with no period; a bounded report may count a requestless copy an older CLI stored after the migration (see Limits) | | No double count | copies of a call re-keyed alike fold, by the existing fold, to the copy one ingest would have kept | -**An older CLI on migrated data** (cli-v5.5.0): the lines have the same shape, so it reads and reports them as they are, and ignores `repository-ids.json`. What it ingests afterwards it stores under its own rules; the newer CLI then re-keys it, and re-ids it when its repository is in the table, at every read. +**An older CLI on migrated data** (cli-v5.5.0): the lines have the same shape, so it reads and reports them as they are, and ignores `repository-ids.json`. What it ingests afterwards it stores under its own rules; the newer CLI then re-keys it, and re-ids it when its repository is in the table, at every read. A requestless call it stores is not held once: a report with no period settles it. ## Acceptance @@ -91,7 +94,8 @@ flowchart TD | --- | --- | | A root unreadable or absent at the migration | kept pending and looked at again by every ingest; until it can be read, its stored calls keep the earlier id and its new calls get the new one: two ids. A clone deleted for good stays pending, at the cost of one look per ingest | | `repository-ids.json` damaged | made again from the clones still there; `branches.jsonl`, never rewritten, then loses the alias of a repository with no clone left, and its declarations stop matching the calls of now | -| Requestless copies more than a day apart | measured: no requestless line in 512,820 real usage lines (3,837 transcripts); copies of a message with a request id, 184,502 of them, at most 0.27 h apart. Nothing bounds the requestless ones, so storing one reads the whole ledger (4.5 s at 250,000 records) and the ledger holds it once | +| Requestless copies more than a day apart | measured: no requestless line in 512,820 real usage lines (3,837 transcripts); copies of a message with a request id, 184,502 of them, at most 0.27 h apart. Nothing bounds the requestless ones, so storing one reads the whole ledger (measured by the checker at 250,000 records: 1,672 ms, against 13 ms with a request id) and the ledger holds it once | +| Requestless lines an older CLI writes after the migration | keyed by their timestamp, stored in their own day: a report with a period may count such a copy as well as the call's other copy, until a report with no period settles the ledger. Needs a downgrade or a pinned older binary, and no requestless line was measured | | A root whose `origin` changed since its calls were stored | the table maps the address it has now, so those calls keep their id, as before | | `undated` hashes in `left-out.txt` from before (unreleased: cli-v5.5.0 has no such file) | cannot be re-keyed; copies of one undated requestless call kept apart stay counted apart, and one read again from the start of its transcript adds a hash beside the old one | | A requestless call copied into another session | two keys, as before; seen copies across sessions carry a `requestId`, keyed without the session | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md index 90a19d10e..16c374e5c 100644 --- a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md @@ -214,3 +214,33 @@ Both stay well inside 110 s. The 250,000-record case is about half the usage lin ### Hand mutations, round 2 Each applied alone, the named tests run, the file restored: 12 of 12 went red. D1-a to D1-d: the full read, the session test, the advisor suffix, the tool. m1. M1-a to M1-g: unreadable roots pending, gone ones too, a complete table with pending roots looks again, only the pending roots are asked, nothing new writes nothing, a dropped root is saved, `pending` kept in the file. + +## Review round 2 (86/100): n1 to n5 + +| # | Change | Proof | +| --- | --- | --- | +| n1 | Requestless lines an older CLI writes after the migration are a stated limit. A bounded report may count such a copy until a report with no period settles the ledger. Stated in both contracts, with the Key row, the Read-time row and the older-CLI paragraph qualified | the window property now generates them (below) | +| n2 | six stale statements fixed: usage contract (Key row, day-file row, window paragraph), `usage-ledger-adapter.ts` header, CLI memory row, task contract (flowchart with pending roots, the measured 1,672 ms against 13 ms) | doc | +| n3 | one advisor pattern, `ADVISOR` in the Claude format, used by the re-keying too; the redundant guard in `StoredUsageMigration.execute` deleted (a complete table with nothing pending locates nothing and writes nothing) | `#advisor10` and `#advisor3` cases; mutation `\d+` to `\d` killed | +| n4 | the migration property's first run after the upgrade has up to two clones `unreadable` or `gone`, back by the next run; a failure at every write of a pending root's retry, then a rerun, equals a clean retry | mutations P-a and M1-h below | +| n5 | the ledger and `roots.json` adapters keep building their table from their own directory, with one line saying so: no wiring can leave it out | doc | + +Red run, n1: the window property with an older CLI's requestless lines, before the exemption was named: + +```txt +× leave every bounded read equal to the full ledger filtered to its period + Counterexample: [[{"anchor":1,"requestless":true,"copies":[{"days":0,…,"older":false},{"days":2,…,"older":true}]},…],{"from":"2025-12-29","to":"2026-02-28"}] + Caused by: AssertionError: expected [ Array(2) ] to deeply equal [ { tool: 'claude-code', …(15) } ] +``` + +After it, the property exempts the bounded read for the calls an older CLI stored a copy of, and only those. It still asserts one record per call in the full read for every call. + +The n3 two-digit case, the n4 generator and the retry test passed against the code as it stood: the behaviour was right and unpinned. Each is proven by a mutation, all applied alone and killed: + +| # | Mutation | Killed by | +| --- | --- | --- | +| n3 | `ADVISOR` `\d+` to `\d` | keeps an advisor iteration's suffix; is told by its key ending in its own session | +| P-a | an unreadable or gone root not kept pending | the fresh-ingest migration property | +| M1-h | the retry writes its table complete before the rewrite | stopped at any write of a pending root's retry; writes its table before anything else | + +Re-run with them: D1-a to D1-d, m1, M1-a, M1-b, M1-d to M1-g, all killed again (M1-c gone with its guard). diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md index 5d09e6ac8..b08553c8c 100644 --- a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md @@ -2,93 +2,120 @@ status: changes-requested --- -# Review: One call, one key; one repository, one id +# Review: One call, one key; one repository, one id (round 2) -Head `57169330`, base `origin/next`. Independent check, read-only on the code. +Head `a06f9857`, base `origin/next`. Independent check, read-only on the code. Round 1 (62/100, head `57169330`) is superseded by this report. -**Verdict: changes requested. Score 62/100.** +**Verdict: changes requested, minors only. Score 86/100.** + +No deal breaker. D1 and M1 hold under every interleaving the new code owns. One interleaving it does not own, lines an older CLI writes after the migration, still breaks the window property, and the contract says it does not. The project's rule forbids that ("Never state ... anything not just observed"). Every other finding is cheap. ## Deal breakers -### D1. The new requestless key breaks the #1032 window property, under a bound nobody measured (High) +None. + +## Round 1 findings, re-checked -- **Where:** `domain/formats/claude-transcript-usage.ts:99` (the key), `domain/report/period.ts:34-39` (`windowOf`), `domain/stored-usage.ts` `daysAround`. -- **Why the bound does not cover it:** `windowOf` and `daysAround` read one day on each side. `period.ts` justifies that with "of 262,631 real keys, the widest spread is 0.3 h". That data could never contain a requestless call's copies: the earlier key ended in the timestamp, so each copy had its own key, with a spread of 0. M10 now folds copies whose spread nobody has measured. The contract says those copies "carry different timestamps", which is why they need folding at all. -- **Failure, reproduced:** one requestless call, `msg_1` in session `s`, copies at `2026-10-01T10:00Z` and `2026-10-04T10:00Z`. - - Each `storeCalls` reads only the days around its own copy, so both copies are stored, in two days. - - `settleLedger(windowOf({from: 2026-10-04, to: 2026-10-04}))`, filtered to the period, holds 1 record. The unbounded settle, filtered the same way, holds 0. - - Probe output: `PROBE window bounded= 1 full= 0`. - - Effect: the reports for 10-01 and for 10-04 sum to 2 calls. The full report says 1. The period axis is not deterministic. -- **Why the tests miss it:** - - `report-window.property` keys every call `k${index}` and keeps its copies within `HALF_DAY`. - - The migration property's `TIMES` keep copies seconds apart. -- **Fix:** - - Measure the spread of `message.id:sessionId` among requestless lines in real transcripts. Then either cite the measurement in `period.ts` and in the contract's Limits, or widen the window to match it. - - Add requestless copies more than a day apart to the window property's generator, and to the migration property's `TIMES`. +| # | Claim | Status | Evidence | +| --- | --- | --- | --- | +| D1 | a requestless store reads the whole ledger | fulfilled for the new CLI | `ledger-upkeep.ts:22-23`. Probe of 300 random runs (requestless copies up to ±5 days apart, requestful ±12 h, 4 ingests, then a full settle, compared with one ingest of all copies): `PROBE byte-identity mismatches 0 of 300`. The round 1 repro is now a test (`ledger-upkeep.integration.test.ts:220`) | +| D1 | settle path | holds | `settleLedger` reads the window and folds. A call the new `storeCalls` stores is on disk once, so no window can miss a copy | +| D1 | legacy monthly file | moot | `cli-v5.5.0` writes daily files only (`git show cli-v5.5.0:…/usage-ledger-adapter.ts`: monthly is "as an earlier version wrote it"). The migration's unpartitioned `save()` splits and removes any monthly file. A requestless store's full read marks every monthly file `unsettled`, and that store splits it | +| D1 | deadlock, hook cost | holds | `ledger.load` takes no lock: nothing nests under `exclusively`. Measured on 250,000 records: `PROBE cost ms withRequest= 13 requestless= 1672 heapMB= 307`. The hook allows 110 s and the lock goes stale after 10 min | +| M1 | a pending root is retried | fulfilled | `stored-usage-migration.ts:26-50`. Probe: retry twice is idempotent. A failure at each of writes 1 to 6, then a rerun, ends byte-identical to a clean retry: `crash mismatches= 0 of 6` | +| M1 | a clone replaced at the same path | no misattribution | the alias is keyed by the origin's legacy id, never by the root. Probe: the table holds only `legacyRepositoryIdOf(B) → repositoryIdOf(B)` (`09ae01a7… → aecf4b10…`, confirmed). `OLD_A` stays unmapped and its calls keep `OLD_A`. That is the documented split, not a wrong id | +| M1 | a root pending for good | cheap | `gone` costs one `realpath`, with no git spawn (`git-repository-locator-adapter.ts:33`). `unreadable` costs the git spawns per ingest. Nothing is written while the list is unchanged | +| m1 | re-keying limited to `claude-code` | fulfilled | `legacy-usage-key.ts:14`; mutant killed | +| m2 | damaged-table cost documented | fulfilled | `usage-contract.md` `repository-ids.json` row | +| m3 | first-run cost measured | fulfilled | evidence.md: 4.5 s and 20.8 s | +| m4 | wiring required | partial | see n5 | +| m5 | stale reference | fulfilled | `legacy-repository-identity.ts:5` | +| m6 | `period.ts` states both bounds | fulfilled | `period.ts:35-42` | + +Round 1 "Verified" properties re-checked, no regression: no two repositories share an id; no alias chains (a pending root adds only a legacy-to-new pair, and new ids carry no upper case and no `ssh.github.com`); no merge across key forms; write-then-rename crash order; the migration runs inside `ledger.exclusively`; hooks compute no id and no key. -## Major +## Minor -### M1. A root unreadable at the first run is never mapped, so the PR creates the split it set out to remove +### n1. An older CLI's requestless lines, written after the migration, still break the window property (Medium) -- **Where:** `application/legacy/stored-usage-migration.ts:39-47`. `table()` skips any root whose status is not `repository`, and `execute` marks the table `complete` regardless. +- **Where:** + - the claims: `contract.md:74` (Read-time row), `contract.md:77`, `usage-contract.md:59` (Key row: "the ledger holds it once"); + - the cause: `ledger-upkeep.ts:22`, which takes the full read only when an incoming record is requestless. - **Failure, reproduced:** - - Setup: root `/w/a` has origin `git@github.com:Acme/Tool.git`, where the legacy id differs from the new one. It reads `unreadable` on the first run and `repository` on the second. - - Probe output after both runs: `{"complete":true,"size":0}`. - - The resolver then names that clone's new calls by the new id (`directory-resolver.ts:183`), while its stored calls keep the legacy id. Before this PR the repository had one id; after it, two. - - The first run after an upgrade is likely to be the async `catch-up.cjs` hook, with a 110 s timeout and the hook's environment. That makes a transient `unreadable` or `gone` (an unmounted volume) plausible. -- **Contradiction:** `DirectoryResolver` treats `unreadable` as something that may become readable later (`mayConsent`). The migration treats it as final. -- **Fix:** do not mark the table `complete` while any root is `unreadable`, or keep a list of pending roots and re-locate them on later runs. Adding an alias later is safe: every reader applies the table at read time, and aliases cannot chain (see Verified). -- **Docs:** the `repository-ids.json` row in `usage-contract.md` says such a repository "keeps its earlier id". It does not say that its new calls get the new id. State the split. + - Setup: the table is complete. cli-v5.5.0 then stores `msg_1:s-1:` on 10-01 (output 9) and on 10-04 (output 1). + - A report bounded to 10-04 re-keys and counts the 10-04 copy. The full read folds both copies to the 10-01 one. + - Probe: `PROBE old-cli bounded= 1 full= 0`. + - Only a report with no period heals it, or a later requestless ingest by the new CLI. +- **Likelihood:** low. 0 requestless lines were measured, and it needs a downgrade or a pinned older binary. +- **Fix:** add a Limits row ("requestless lines an older CLI writes after the migration: a bounded report may count a copy until a report with no period settles the ledger"), and qualify `contract.md:74` and `usage-contract.md:59` to match. -## Minor +### n2. Stale docs: the window and the cost claims no longer hold for every ingest -| # | Where | Finding | Fix | -| --- | --- | --- | --- | -| m1 | `domain/legacy/legacy-usage-key.ts:10-17` | Re-keying ignores `tool`. No released writer stores `codex` records: `cli-v5.5.0` and HEAD have only the Claude format, so nothing is affected today. A future codex key ending in `:session:at` would be collapsed silently. | Re-key only `claude-code` records. | -| m2 | `infrastructure/binding-snapshot-store-adapter.ts` `read()` | `branches.jsonl` is never rewritten, so it depends on `repository-ids.json` for good. If the table is damaged, `parseRepositoryAliases` returns `null` and the table is rebuilt from the clones that still exist. Snapshots of a repository whose clones are gone by then lose their alias for good, and so does their task attribution. | Document it, or rewrite `branches.jsonl` once under the bindings lock. | -| m3 | `stored-usage-migration.ts:37-48` | First-run cost is not measured. `locate` costs about four git spawns per root, including `rev-list --max-parents=0 HEAD`, which walks the whole history, though only `origin` is needed when it exists. The table is saved only after every root is located. If the `catch-up` hook's 110 s timeout is exceeded, the work is lost and redone at every SessionStart, and the hook's ingest never runs. | Measure on a large `roots.json`, or locate the remote only. | -| m4 | `usage-ledger-adapter.ts:64`, `resolution-store-adapter.ts:23`, `binding-snapshot-store-adapter.ts:17`, `ingest-usage-use-case.ts:74` | The alias store is wired three ways: built inside two adapters, and an optional parameter in the third. The `migration?` collaborator is also optional. A wiring that omits either one silently skips re-iding or migrating. | Inject both and make them required. | -| m5 | `domain/legacy/legacy-repository-identity.ts:5` | Cites `RepositoryIdMigration`, which does not exist. The class is `StoredUsageMigration`. | Rename the reference. | -| m6 | `domain/report/period.ts:34-38` | The doc's empirical bound no longer covers every key (see D1). | Update it together with D1. | -| m7 | evidence.md | Tests are committed with their code. The red runs are recorded only as text. The legacy tests went red because their modules were missing, not on behaviour. The stop property passed against the skeleton and is proven only by mutation G-h. evidence.md admits all of this, and the hand mutations compensate. | None required. | - -## Verified, holds - -- **Identity: no two repositories get one id.** - - Host folding follows RFC 3986. - - Path folding applies to `github.com` only, which GitHub documents as case-insensitive. - - `ssh.github.com` maps to `github.com`. - - A root-commit fallback is 40 hex characters; an address hash is 64. They cannot collide. -- **Aliases cannot chain.** A legacy id differs from its new id only when the address held an upper-case host, an upper-case path on `github.com`, or `ssh.github.com`. No new address holds any of these, so no alias target is itself aliased. Idempotence holds. -- **Legacy rule is faithful.** `legacyRepositoryIdOf` reproduces `cli-v5.5.0` exactly: it uses URL `hostname`, folds the host for scp-like addresses only, and applies the same `ownerAndRepo`. -- **Key: no merge across forms, no double count.** - - `currentKeyOf` strips only the record's own `:session_id:at`. - - A stored `at` is the raw `timestamp` text that the earlier key embedded, so the two always agree. - - Keys with a `requestId` are untouched, and advisor suffixes are kept. - - Covered: an old CLI writing after the migration, and a half-migrated store. Every reader re-keys and re-ids at read time, whether or not the table is `complete`. -- **Crash order and lock.** - - Order: table (incomplete), then `roots.json`, then every partition, then `complete`. `save` writes before it removes, by write-then-rename. - - The migration runs inside `ledger.exclusively`. - - `task` takes only the bindings lock, so `prompt-gate` is not blocked. -- **git missing:** `runGit` throws inside `table()` before any write. The ingest fails, nothing persists, and the next run retries. Ingest already needs git. -- **Plugin hooks** compute no id and no key. Grep of `hooks/` for `repository_id|branches.jsonl|roots.json`: no hit. -- **Equivalent mutants confirmed.** - - `legacy-repository-identity.ts:9`: `path` is `null` whenever `address` is. - - `binding-snapshot-store-adapter.ts:45`: `parseBranchSnapshot` returns `null` on non-JSON and does not throw. - - Other survivors are on untouched lines. `git diff -U0` hunks: ingest 73-74 and 96-97; ledger 5, 23, 56, 64, 78-80, 87-88, 93-94; transcript 99. -- **Docs:** `usage-contract.md` and `telemetry.md` are consistent with the code and with envelope v2. The 01-usage skill states no key or id rule, so it needs no change. -- **Bundle:** `node scripts/check-bundle-size.mjs` prints `Bundle size: 699.8 KB / budget: 709 KB`, `OK: within budget`. There is no raise, so the policy's ~2 % headroom rule does not apply. Headroom is now 1.3 %. +| Where | Says | Now | +| --- | --- | --- | +| `usage-contract.md:164` | "Ingest reads its calls' days and the day on each side" | a requestless store reads every day and splits every monthly file. This contradicts the Key row at `:59` | +| `usage-contract.md:127` | "The copies of one call have been observed less than a day apart" | true only of calls with a request id. Qualify it as `period.ts` does | +| `usage-ledger-adapter.ts:41-42` | "An ingest reads the days around its calls … so its cost does not grow with the ledger" | false for a requestless store | +| `cli/aidd_docs/memory/telemetry.md:55` | "rewritten once by the first ingest" | rewritten again when a pending root adds an id | +| `contract.md:59` (flowchart) | `complete? yes → ingest as usual` | complete with roots pending → locate the pending roots, and maybe rewrite | +| `contract.md:94` | "storing one reads the whole ledger (4.5 s at 250,000 records)" | 4.5 s is the migration's first run, git included. The requestless store measured 1,672 ms at 250,000 records. Cite the figure that was observed | + +### n3. Survivors in the changed lines (Stryker, narrow scope: 97.12 %, 101 killed, 3 survived) + +- **`claude-transcript-usage.ts:80`, `\d+$` mutated to `\d$`: survives.** A key ending `#advisor10` is not recognised. This is harmless in practice: the main record of the same message is in the same batch and triggers the full read. It is still a guard the tests do not pin. + - Fix: add a two-digit case to `claude-transcript-usage.unit.test.ts:333`. + - Better: reuse `ADVISOR` from `legacy-usage-key.ts:4`. The regex is duplicated (DRY). +- **`claude-transcript-usage.ts:80`, with the `$` anchor removed: equivalent.** `#advisor` only ever appears as the suffix the format appends (`:139`). Message and session ids carry no `#advisor`. +- **`stored-usage-migration.ts:28` mutated to `if (false) return`: equivalent.** A complete table with nothing pending reaches `locate([])`. The sizes are then equal and nothing is pending, so the run returns without writing. The guard is redundant ("a guard nothing fails for is not a guard", `coding-assertions.md`). + - Fix: delete it, or keep it and label it as a fast path. + +The "12 of 12 hand mutations" claim stands. They were not exhaustive. + +### n4. Generator gaps + +- The migration property (`stored-usage-migration.property.integration.test.ts`) generates no `unreadable` or `gone` roots. The retry path is covered only by example tests. +- No test fails a write during a retry rewrite. My probe covered that case (above). +- The window property drives `storeCalls` directly and never emits legacy-keyed lines written after the migration. That gap is why n1 went unseen. +- Fix: add a pending root that turns readable to the migration generator, and an older CLI's timestamped requestless lines to the window generator. The second one should go red until n1 is either fixed or excluded by name. + +### n5. m4 is half done; the placement is arguable + +- **m4:** `UsageLedgerAdapter` (`usage-ledger-adapter.ts:58`) and `ResolutionStoreAdapter` (`resolution-store-adapter.ts:22`) still build their own `RepositoryAliasStoreAdapter`. `BindingSnapshotStoreAdapter` has its store injected. All three read the same file, so nothing is incorrect, but the wiring is inconsistent. Fix: inject the store everywhere, or say in one line why the two adapters build their own. +- **Placement:** `ledger-upkeep.ts:1` makes the tool-agnostic upkeep import a Claude-format predicate. A per-format "copies unbounded in time" predicate would keep the format knowledge in its own module. No action is needed while there is only one format. + +## Tests-first + +- evidence.md records red runs for D1 (the pinned case and the window property's counterexample), M1 (10 failed, 15 passed) and m1. +- Tests ship in the same commits as their code, as in round 1 (m7, accepted). +- The migration property was extended, but it passed on the unfixed code. evidence.md says so. + +## Checklist + +| Item | Result | +| --- | --- | +| No duplication | partial: the advisor regex is duplicated (n3) | +| No incoherence | partial: n1 and n2 | +| No over-engineering | partial: redundant guard at `stored-usage-migration.ts:28` (n3) | +| No dead code or debug leftovers | holds: `git diff 57169330..a06f9857 -- cli/src` has no logs and no TODOs | + +## Bundle + +`node scripts/check-bundle-size.mjs` prints `Bundle size: 700.7 KB / budget: 709 KB`, `OK: within budget`. `dist/cli.js` was built at the head commit's time. The budget is unchanged, so the headroom rule in `check-bundle-size.mjs` is not triggered. Headroom is now 1.2 %. ## Commands | Command | Decisive output | | --- | --- | -| `vitest run` on the identity, legacy, format and `stored-ids-and-keys` tests | `Test Files 9 passed`, `Tests 140 passed` | -| `vitest run` on `stored-usage-migration.property` and `report-window.property` | `Tests 5 passed` | -| `vitest run` on `telemetry-ingest.e2e` and `ledger-upkeep` | `Tests 27 passed` | -| scratch probe (outside the repo): requestless copies 3 days apart | `PROBE window bounded= 1 full= 0` | -| scratch probe: root `unreadable`, then readable | `{"complete":true,"size":0}` | -| `git grep "key =" cli-v5.5.0 -- formats/` | Claude format only, no codex writer | -| `node scripts/check-bundle-size.mjs` | `699.8 KB / budget: 709 KB` | - -Not run: the full suite and Stryker, per the constraints. The mutation claims were verified by reading the code and the diff hunks. +| `vitest run` on the 14 test files changed since `57169330` | `Test Files 14 passed`, `Tests 242 passed` | +| scratch probe: N ingests plus a full settle vs one ingest, 300 runs | `PROBE byte-identity mismatches 0 of 300` | +| scratch probe: requestless lines from an older CLI, after the migration | `PROBE old-cli bounded= 1 full= 0` | +| scratch probe: 250,000-record ledger, one store | `withRequest= 13 requestless= 1672 heapMB= 307` | +| scratch probe: M1 retry, failure at each write, replaced clone | `twice-idempotent= true crash mismatches= 0 of 6`; `OLD_A mapped? false` | +| `stryker run -m `, under `mutation.lock` | `97.12`, 101 killed, 3 survived | +| `node scripts/check-bundle-size.mjs` | `700.7 KB / budget: 709 KB` | + +Not run: the full suite (pre-push passed it at `a06f9857`: 6,808 tests). + +## Score + +86 = 100, minus 6 (n1: a contract claim falsified by a probe), minus 4 (n2: six stale statements), minus 2 (n3), minus 1 (n4), minus 1 (n5). diff --git a/cli/aidd_docs/memory/telemetry.md b/cli/aidd_docs/memory/telemetry.md index 4eafcd0f7..616f9382c 100644 --- a/cli/aidd_docs/memory/telemetry.md +++ b/cli/aidd_docs/memory/telemetry.md @@ -52,7 +52,7 @@ flowchart LR | What a Claude Code line becomes | `domain/formats/claude-transcript-usage.ts` | | Why a call is counted once | `domain/usage-fold.ts` | | Where a call's repository comes from | `application/directory-resolver.ts`, `domain/repository-identity.ts` | -| How data an earlier version stored is brought to today's key and id rules: read-time in every store reader, rewritten once by the first ingest | `application/legacy/stored-usage-migration.ts`, `domain/legacy/` (key, id and table rules), `infrastructure/legacy/repository-alias-store-adapter.ts`; rules in the [usage contract](../../../aidd_docs/product/usage-contract.md#stores) | +| How data an earlier version stored is brought to today's key and id rules: read-time in every store reader, rewritten by the first ingest and again when a root left pending adds an id | `application/legacy/stored-usage-migration.ts`, `domain/legacy/` (key, id and table rules), `infrastructure/legacy/repository-alias-store-adapter.ts`; rules in the [usage contract](../../../aidd_docs/product/usage-contract.md#stores) | | Which task a call belongs to | `domain/report/attribution.ts`, `domain/declaration/binding-resolution.ts` | | Whether a branch is declared: only by `task`, on that branch (`aiddDeclaredAt` set, `aiddDeclaredFor` absent or equal to the branch); the hook's twin is `plugins/aidd-telemetry/hooks/lib/git.cjs` | `domain/branch-binding.ts` `parseBranchConfig`; rule and limits in the [usage contract](../../../aidd_docs/product/usage-contract.md#branch-declaration) | | What counts as consent | `domain/telemetry-consent.ts`, `infrastructure/git-consent-adapter.ts` | From 5061bd1cf787f74a6b1bff27710136ee73572e45 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 12:27:18 +0200 Subject: [PATCH 14/19] fix(telemetry): pin the mixed-batch trigger, the table checks, the stated limit A batch mixing a requestless call with one that has a request id must read every day: a fixed example now fails when that trigger is narrowed to batches of requestless calls only. A table of another version, or with a completion that is no boolean, is rejected even when all else is valid. The window property no longer skips a call an older CLI stored a copy of: it asserts the stated limit, so a read that drops that call fails. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../ledger-upkeep.integration.test.ts | 14 +++++++++++++ ...report-window.property.integration.test.ts | 20 ++++++++++++++++++- .../legacy/repository-aliases.unit.test.ts | 13 +++++++++++- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts b/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts index 89b0497ba..1f8c7eb38 100644 --- a/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts @@ -229,6 +229,20 @@ describe("storing a requestless call, whose copies nothing bounds in time", () = expect(await readdir(join(root, "ledger"))).toEqual(["2026-10-04.jsonl"]); }); + it("reads every day for a batch mixing a requestless call with one that has a request id", async () => { + const ledger = new UsageLedgerAdapter(join(root, "ledger"), new PrivateStorageAdapter()); + await storeCalls(ledger, [stored("msg_1:s-1", "2026-10-01T10:00:00.000Z", 1)]); + await storeCalls(ledger, [ + stored("msg_2:req_2", "2026-10-04T09:00:00.000Z", 5), + stored("msg_1:s-1", "2026-10-04T10:00:00.000Z", 2), + ]); + expect((await ledger.load()).records.map((record) => record.key).sort()).toEqual([ + "msg_1:s-1", + "msg_2:req_2", + ]); + expect(await readdir(join(root, "ledger"))).toEqual(["2026-10-04.jsonl"]); + }); + it("still reads only the days around a call with a request id", async () => { const ledger = new UsageLedgerAdapter(join(root, "ledger"), new PrivateStorageAdapter()); await storeCalls(ledger, [stored("msg_1:req_1", "2026-10-01T10:00:00.000Z", 1)]); diff --git a/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts b/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts index 327a934da..e7a910072 100644 --- a/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts @@ -1,4 +1,4 @@ -import { appendFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { appendFile, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import * as fc from "fast-check"; @@ -13,6 +13,7 @@ import { DeclaredBindings } from "../../../../../src/contexts/telemetry/applicat import { reportEnvelopeOf } from "../../../../../src/contexts/telemetry/application/report/report-envelope.js"; import { ReportUsageUseCase } from "../../../../../src/contexts/telemetry/application/report/report-usage-use-case.js"; import { SnapshotBindingsUseCase } from "../../../../../src/contexts/telemetry/application/snapshot-bindings-use-case.js"; +import { currentKeyOf } from "../../../../../src/contexts/telemetry/domain/legacy/legacy-usage-key.js"; import type { UsageLedger } from "../../../../../src/contexts/telemetry/domain/ports/usage-ledger.js"; import { attribute } from "../../../../../src/contexts/telemetry/domain/report/attribution.js"; import { @@ -385,6 +386,23 @@ describe("calls stored by ingests, a requestless call's copies days apart, some const inside = (records: readonly StoredUsage[]) => records.filter((record) => inPeriod(record.at, asked) && !exempt.has(record.key)); expect(inside(bounded)).toEqual(inside(full)); + // What the limit allows for those calls, and no less: the bounded read holds such a call + // once when a day of its window holds a copy of it, read off the disk here, and never + // drops it; the full read holds it once. + const window = windowOf(asked) ?? (() => true); + const onDisk = new Set(); + for (const name of await readdir(dir)) { + if (!/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(name) || !window(name.slice(0, 10))) continue; + for (const line of (await readFile(join(dir, name), "utf8")).split("\n")) { + if (line !== "") onDisk.add(currentKeyOf(JSON.parse(line))); + } + } + for (const key of exempt) { + const count = (records: readonly StoredUsage[]) => + records.filter((record) => record.key === key).length; + expect(count(bounded)).toBe(onDisk.has(key) ? 1 : 0); + expect(count(full)).toBe(1); + } // One record per call, whatever its copies' spread. expect(full.length).toBe(calls.filter((c) => c.copies.length > 0).length); } diff --git a/cli/tests/contexts/telemetry/domain/legacy/repository-aliases.unit.test.ts b/cli/tests/contexts/telemetry/domain/legacy/repository-aliases.unit.test.ts index bdf78796e..ce261e787 100644 --- a/cli/tests/contexts/telemetry/domain/legacy/repository-aliases.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/legacy/repository-aliases.unit.test.ts @@ -24,7 +24,18 @@ describe("the table of ids an earlier version gave repositories", () => { it.each([ ["missing", null], ["not JSON", "{"], - ["another format", JSON.stringify({ version: 2, complete: true, aliases: {} })], + [ + "another format, all else valid", + JSON.stringify({ version: 2, complete: true, aliases: {}, pending: [] }), + ], + [ + "a completion that is no boolean, all else valid", + JSON.stringify({ version: 1, complete: "yes", aliases: {}, pending: [] }), + ], + [ + "no table, all else valid", + JSON.stringify({ version: 1, complete: true, aliases: null, pending: [] }), + ], ["no completion", JSON.stringify({ version: 1, aliases: {} })], ["a completion that is no boolean", JSON.stringify({ version: 1, complete: 1, aliases: {} })], ["no table", JSON.stringify({ version: 1, complete: true })], From 8086176b17cb2602acfae357a0d39a40180ce917 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 12:31:59 +0200 Subject: [PATCH 15/19] fix(telemetry): pin the settle of a re-keyed day and a bare session key Stryker on the lines changed since next left two guards unpinned: a day holding an older CLI's timestamped key must be rewritten when it is settled, and a key that is only its own session and time is no earlier key to strip. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../application/ledger-upkeep.integration.test.ts | 11 +++++++++++ .../domain/legacy/legacy-usage-key.unit.test.ts | 1 + 2 files changed, 12 insertions(+) diff --git a/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts b/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts index 1f8c7eb38..fb480ce33 100644 --- a/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/ledger-upkeep.integration.test.ts @@ -243,6 +243,17 @@ describe("storing a requestless call, whose copies nothing bounds in time", () = expect(await readdir(join(root, "ledger"))).toEqual(["2026-10-04.jsonl"]); }); + it("rewrites a day holding an older CLI's timestamped key when it settles that day", async () => { + const dir = join(root, "ledger"); + await mkdir(dir, { recursive: true }); + const older = stored("msg_1:s-1:2026-10-01T10:00:00.000Z", "2026-10-01T10:00:00.000Z", 1); + await writeFile(join(dir, "2026-10-01.jsonl"), `${JSON.stringify(older)}\n`); + await settleLedger(new UsageLedgerAdapter(dir, new PrivateStorageAdapter())); + expect(await readFile(join(dir, "2026-10-01.jsonl"), "utf8")).toBe( + `${JSON.stringify({ ...older, key: "msg_1:s-1" })}\n` + ); + }); + it("still reads only the days around a call with a request id", async () => { const ledger = new UsageLedgerAdapter(join(root, "ledger"), new PrivateStorageAdapter()); await storeCalls(ledger, [stored("msg_1:req_1", "2026-10-01T10:00:00.000Z", 1)]); diff --git a/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts b/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts index 9543e1e65..0a292b40c 100644 --- a/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/legacy/legacy-usage-key.unit.test.ts @@ -32,6 +32,7 @@ describe("a key an earlier version stored, as it is keyed now", () => { ); expect(currentKeyOf(call(`msg_A:xs-1:${AT}`))).toBe(`msg_A:xs-1:${AT}`); expect(currentKeyOf(call(`s-1:${AT}`))).toBe(`s-1:${AT}`); + expect(currentKeyOf(call(`:s-1:${AT}`))).toBe(`:s-1:${AT}`); }); it("re-keys only what Claude Code keyed so, never another tool's key", () => { From 102baf9816c73381fbf2b217cae883ba31008dc9 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 12:35:48 +0200 Subject: [PATCH 16/19] docs(telemetry): record the changed-line Stryker runs and round three Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../evidence.md | 51 +++++++ .../review.md | 133 ++++++++---------- 2 files changed, 108 insertions(+), 76 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md index 16c374e5c..0ca754db2 100644 --- a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md @@ -244,3 +244,54 @@ The n3 two-digit case, the n4 generator and the retry test passed against the co | M1-h | the retry writes its table complete before the rewrite | stopped at any write of a pending root's retry; writes its table before anything else | Re-run with them: D1-a to D1-d, m1, M1-a, M1-b, M1-d to M1-g, all killed again (M1-c gone with its guard). + +## Review round 3 (90/100): r1 to r4 + +### r4 and r1: tests, each proven by a mutation applied alone, five runs each + +| # | Test | Mutation | Runs killed | +| --- | --- | --- | --- | +| r4 | a fixed batch mixing a requestless call with one that has a request id; the requestless call's earlier copy is three days away | `incoming.some(isKeyedBySession)` to `every` | 5 of 5 | +| r4 | a table of another version, all else valid, is rejected | the version check dropped | 5 of 5 | +| r4 | a table whose completion is no boolean, all else valid, is rejected | the completion check dropped | 5 of 5 | +| r1 | the window property no longer skips a call an older CLI stored a copy of. It asserts the limit: the bounded read holds the call exactly once when a day of its window holds a copy of it on disk (read off the files, not through the adapter), never otherwise; the full read holds it once | a bounded read drops re-keyed lines (`usage-ledger-adapter.ts:96`) | 5 of 5 | +| r1 | same | every read drops re-keyed lines | 5 of 5 | + +These tests passed against the code as it stood: the behaviour was right, but before them the r4 trigger was pinned only by chance, and the r1 exemption hid an undercount. + +### r3: Stryker on exactly the changed lines, under the lock + +`node scripts/run-mutation.mjs --changed ` mutates the `git diff -U0` ranges of `cli/src` since the merge base. The first runs: + +```txt +--changed 57169330: Report: reports/mutation/changed/ (score 98.8, 31 changed range(s) since 57169330) +--changed origin/next: Report: reports/mutation/changed/ (score 97.2, 44 changed range(s) since origin/next) +``` + +The `origin/next` run left 7 survivors. Two were not equivalent; tests were added for both (commit `8086176b`), each proven by a mutation applied alone: + +| Survivor | Now | +| --- | --- | +| `legacy-usage-key.ts:17` `base.length > earlier.length` to `>=`, and to `true` | killed: a key that is only `:session:at` is no earlier key to strip | +| `usage-ledger-adapter.ts:96` the re-keyed partition no longer marked unsettled (two mutants) | killed: a day holding an older CLI's timestamped key is rewritten when it is settled. The migration's full save had hidden it since `57169330` | + +Final runs, after `8086176b`: + +```txt +--changed 57169330: Instrumented 11 source file(s) with 83 mutant(s); Report: reports/mutation/changed/ (score 98.8, 31 changed range(s) since 57169330) +--changed origin/next: Instrumented 15 source file(s) with 252 mutant(s); Report: reports/mutation/changed/ (score 98.8, 44 changed range(s) since origin/next) +``` + +Survivors left, all equivalent: + +| Survivor | Why it cannot be killed | +| --- | --- | +| `claude-transcript-usage.ts:16` `/#advisor\d+$/` to `/#advisor\d+/` | `#advisor` is only ever written as a key's suffix; message and session ids hold no `#` | +| `repository-aliases.ts:20` `text ?? ""` to `?? "Stryker was here!"` | a missing file then parses as no JSON, and `null` is returned either way | +| `legacy-repository-identity.ts:9` `address === null` to `false` | `path` is `null` whenever `address` is | + +The checker's 95.11 (11 survivors) mutated whole line ranges, including lines this change does not touch, such as `claude-transcript-usage.ts:20-62` and `ingest-usage-use-case.ts:89`. The two figures measure different scopes. + +### Hand mutations, all rounds + +45, each killed: 23 in the first build, 12 in round 1, 3 in round 2 (`ADVISOR`, P-a, M1-h), 7 in round 3 (the five above, and the two Stryker survivors). diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md index b08553c8c..18a01076f 100644 --- a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md @@ -2,120 +2,101 @@ status: changes-requested --- -# Review: One call, one key; one repository, one id (round 2) +# Review: One call, one key; one repository, one id (round 3) -Head `a06f9857`, base `origin/next`. Independent check, read-only on the code. Round 1 (62/100, head `57169330`) is superseded by this report. +Head `429948a3`, base `origin/next`. Independent check, read-only on the code. Round 2 (86/100, head `a06f9857`) is superseded by this report. -**Verdict: changes requested, minors only. Score 86/100.** +**Verdict: changes requested, minors only. Score 90/100.** -No deal breaker. D1 and M1 hold under every interleaving the new code owns. One interleaving it does not own, lines an older CLI writes after the migration, still breaks the window property, and the contract says it does not. The project's rule forbids that ("Never state ... anything not just observed"). Every other finding is cheap. +No deal breaker. n2 to n5 hold. n1 is documented, but the window property's exemption is wider than the limit it names: it also hides a bounded report that *loses* a call, which the limit does not allow. A hand mutation of that kind survives half the runs. The PR description still contradicts the new limit in two sentences. Its "98.8, one equivalent survivor" Stryker claim is unrecorded and not reproduced: a non-equivalent mutant of the D1 trigger survives a run. ## Deal breakers None. -## Round 1 findings, re-checked +## Round 2 findings, re-checked | # | Claim | Status | Evidence | | --- | --- | --- | --- | -| D1 | a requestless store reads the whole ledger | fulfilled for the new CLI | `ledger-upkeep.ts:22-23`. Probe of 300 random runs (requestless copies up to ±5 days apart, requestful ±12 h, 4 ingests, then a full settle, compared with one ingest of all copies): `PROBE byte-identity mismatches 0 of 300`. The round 1 repro is now a test (`ledger-upkeep.integration.test.ts:220`) | -| D1 | settle path | holds | `settleLedger` reads the window and folds. A call the new `storeCalls` stores is on disk once, so no window can miss a copy | -| D1 | legacy monthly file | moot | `cli-v5.5.0` writes daily files only (`git show cli-v5.5.0:…/usage-ledger-adapter.ts`: monthly is "as an earlier version wrote it"). The migration's unpartitioned `save()` splits and removes any monthly file. A requestless store's full read marks every monthly file `unsettled`, and that store splits it | -| D1 | deadlock, hook cost | holds | `ledger.load` takes no lock: nothing nests under `exclusively`. Measured on 250,000 records: `PROBE cost ms withRequest= 13 requestless= 1672 heapMB= 307`. The hook allows 110 s and the lock goes stale after 10 min | -| M1 | a pending root is retried | fulfilled | `stored-usage-migration.ts:26-50`. Probe: retry twice is idempotent. A failure at each of writes 1 to 6, then a rerun, ends byte-identical to a clean retry: `crash mismatches= 0 of 6` | -| M1 | a clone replaced at the same path | no misattribution | the alias is keyed by the origin's legacy id, never by the root. Probe: the table holds only `legacyRepositoryIdOf(B) → repositoryIdOf(B)` (`09ae01a7… → aecf4b10…`, confirmed). `OLD_A` stays unmapped and its calls keep `OLD_A`. That is the documented split, not a wrong id | -| M1 | a root pending for good | cheap | `gone` costs one `realpath`, with no git spawn (`git-repository-locator-adapter.ts:33`). `unreadable` costs the git spawns per ingest. Nothing is written while the list is unchanged | -| m1 | re-keying limited to `claude-code` | fulfilled | `legacy-usage-key.ts:14`; mutant killed | -| m2 | damaged-table cost documented | fulfilled | `usage-contract.md` `repository-ids.json` row | -| m3 | first-run cost measured | fulfilled | evidence.md: 4.5 s and 20.8 s | -| m4 | wiring required | partial | see n5 | -| m5 | stale reference | fulfilled | `legacy-repository-identity.ts:5` | -| m6 | `period.ts` states both bounds | fulfilled | `period.ts:35-42` | - -Round 1 "Verified" properties re-checked, no regression: no two repositories share an id; no alias chains (a pending root adds only a legacy-to-new pair, and new ids carry no upper case and no `ssh.github.com`); no merge across key forms; write-then-rename crash order; the migration runs inside `ledger.exclusively`; hooks compute no id and no key. +| n1 | limit stated, claims qualified | fulfilled | `contract.md` Read-time row, older-CLI paragraph, new Limits row; `usage-contract.md` Key row, window paragraph, `repository-ids.json` row | +| n1 | window property generates older-CLI requestless lines | partial | generated and red run recorded (evidence.md). Exemption too wide: see r1 | +| n2 | six stale statements | fulfilled | `usage-contract.md` :59, :127, :164; `usage-ledger-adapter.ts:42-43` (header); `telemetry.md:55`; `contract.md` flowchart and Limits row now cite 1,672 ms vs 13 ms | +| n3 | one `ADVISOR` pattern, `#advisor10` pinned | fulfilled | `claude-transcript-usage.ts:16`, imported by `legacy-usage-key.ts:1`; tests at `claude-transcript-usage.unit.test.ts:334`, `legacy-usage-key.unit.test.ts:19` | +| n3 | guard deleted, no behaviour change | fulfilled | `stored-usage-migration.ts:29-38`: complete table, `pending: []` => `roots = []` (`[] ?? …` keeps `[]`), `locate([])` spawns no git, `ids.size === table.ids.size`, `0 < 0` false => return, zero writes. Idempotence and per-ingest cost unchanged. Only cost added: none (one `load()` was already there) | +| n4 | migration property with `unreadable`/`gone`; failing retry writes | fulfilled | `stored-usage-migration.property.integration.test.ts:148-156, 321-325`; `stored-ids-and-keys.integration.test.ts:250`. Mutation M1-h reproduced: killed (below) | +| n5 | one-line rationale | fulfilled | `usage-ledger-adapter.ts:65`, `resolution-store-adapter.ts:22` | -## Minor - -### n1. An older CLI's requestless lines, written after the migration, still break the window property (Medium) - -- **Where:** - - the claims: `contract.md:74` (Read-time row), `contract.md:77`, `usage-contract.md:59` (Key row: "the ledger holds it once"); - - the cause: `ledger-upkeep.ts:22`, which takes the full read only when an incoming record is requestless. -- **Failure, reproduced:** - - Setup: the table is complete. cli-v5.5.0 then stores `msg_1:s-1:` on 10-01 (output 9) and on 10-04 (output 1). - - A report bounded to 10-04 re-keys and counts the 10-04 copy. The full read folds both copies to the 10-01 one. - - Probe: `PROBE old-cli bounded= 1 full= 0`. - - Only a report with no period heals it, or a later requestless ingest by the new CLI. -- **Likelihood:** low. 0 requestless lines were measured, and it needs a downgrade or a pinned older binary. -- **Fix:** add a Limits row ("requestless lines an older CLI writes after the migration: a bounded report may count a copy until a report with no period settles the ledger"), and qualify `contract.md:74` and `usage-contract.md:59` to match. +Earlier rounds, no regression: the `cli/src` diff since `a06f9857` is the regex move, the guard deletion and two comments. Identity, keys, fold, lock, crash order, hook path and M1 retry code are untouched. The D1 full read (`ledger-upkeep.ts:23`) is still pinned by the window property despite the exemption (mutation D1 below, killed 3 of 3). The six changed test files pass. -### n2. Stale docs: the window and the cost claims no longer hold for every ingest +## Minor -| Where | Says | Now | -| --- | --- | --- | -| `usage-contract.md:164` | "Ingest reads its calls' days and the day on each side" | a requestless store reads every day and splits every monthly file. This contradicts the Key row at `:59` | -| `usage-contract.md:127` | "The copies of one call have been observed less than a day apart" | true only of calls with a request id. Qualify it as `period.ts` does | -| `usage-ledger-adapter.ts:41-42` | "An ingest reads the days around its calls … so its cost does not grow with the ledger" | false for a requestless store | -| `cli/aidd_docs/memory/telemetry.md:55` | "rewritten once by the first ingest" | rewritten again when a pending root adds an id | -| `contract.md:59` (flowchart) | `complete? yes → ingest as usual` | complete with roots pending → locate the pending roots, and maybe rewrite | -| `contract.md:94` | "storing one reads the whole ledger (4.5 s at 250,000 records)" | 4.5 s is the migration's first run, git included. The requestless store measured 1,672 ms at 250,000 records. Cite the figure that was observed | +### r1. The window property's exemption also hides an undercount (Medium) -### n3. Survivors in the changed lines (Stryker, narrow scope: 97.12 %, 101 killed, 3 survived) +- **Where:** `report-window.property.integration.test.ts:380-387`. Every record of a call with an older-CLI copy is dropped from both sides of `expect(inside(bounded)).toEqual(inside(full))`. +- **The documented limit** (`contract.md` Limits row, `usage-contract.md` `repository-ids.json` row): a bounded report "may count such a copy as well as the call's other copy". An over-count only. Reasoned: when the full read's winner is in the period, its day is in the window and no in-window copy can beat it, so the bounded read must return that same record. A bounded read never legitimately loses the call. +- **Failure, reproduced by hand mutation:** `usage-ledger-adapter.ts:96`, insert before it `if (record !== read && holds !== undefined) continue;`, so every bounded read drops each re-keyed older-CLI line. + - A report with a period then silently omits those calls. + - The window property passed in 2 of 4 runs. It went red only when a later requestful store rewrote the older line's day and so deleted it from disk (`full.length` assertion), not through the exempted comparison. + - The other 74 telemetry test files passed with the mutant. +- **Not an older-CLI case:** none found. The exemption also covers the new-CLI copies of an exempt call, but non-exempt requestless calls still kill the D1 mutant 3 of 3. +- **Fix:** for exempt keys, assert the limit instead of skipping it. Each in-period full record of an exempt call must equal the bounded record of its key. The bounded read may hold an extra exempt record only when the full winner of that key is outside the period. -- **`claude-transcript-usage.ts:80`, `\d+$` mutated to `\d$`: survives.** A key ending `#advisor10` is not recognised. This is harmless in practice: the main record of the same message is in the same batch and triggers the full read. It is still a guard the tests do not pin. - - Fix: add a two-digit case to `claude-transcript-usage.unit.test.ts:333`. - - Better: reuse `ADVISOR` from `legacy-usage-key.ts:4`. The regex is duplicated (DRY). -- **`claude-transcript-usage.ts:80`, with the `$` anchor removed: equivalent.** `#advisor` only ever appears as the suffix the format appends (`:139`). Message and session ids carry no `#advisor`. -- **`stored-usage-migration.ts:28` mutated to `if (false) return`: equivalent.** A complete table with nothing pending reaches `locate([])`. The sizes are then equal and nothing is pending, so the run returns without writing. The guard is redundant ("a guard nothing fails for is not a guard", `coding-assertions.md`). - - Fix: delete it, or keep it and label it as a fast path. +### r2. The PR description contradicts the new limit -The "12 of 12 hand mutations" claim stands. They were not exhaustive. +- `## How it works`, Read-time rules: "Figures stay right in a half-migrated state, and when an older CLI writes again afterwards." Since n1 this holds only for a report with no period. +- Requestless calls: "A bounded report then agrees with the full one." Same qualifier missing. The Limits bullet in the same body says the opposite. +- "hand mutations (23, then 12 more, all killed)": round 3 added n3, P-a and M1-h. +- **Fix:** qualify both sentences ("in a report with no period; see Limits") and update the count. -### n4. Generator gaps +### r3. Stryker 98.8 is claimed in the PR, not recorded in evidence.md, and not reproduced -- The migration property (`stored-usage-migration.property.integration.test.ts`) generates no `unreadable` or `gone` roots. The retry path is covered only by example tests. -- No test fails a write during a retry rewrite. My probe covered that case (above). -- The window property drives `storeCalls` directly and never emits legacy-keyed lines written after the migration. That gap is why n1 went unseen. -- Fix: add a pending root that turns readable to the migration generator, and an older CLI's timestamped requestless lines to the window generator. The second one should go red until n1 is either fixed or excluded by name. +- The PR claims "Stryker on the lines changed since `57169330`: 98.8, one equivalent survivor". evidence.md has no such run: its last Stryker figure is 97.2. +- Rerun (scope below, wider than the changed lines): `All files | 95.11 | 214 killed | 11 survived`. In lines changed since `57169330`, two survive, not one: + - `claude-transcript-usage.ts:16`, `$` anchor removed: equivalent, as the PR says. + - `ledger-upkeep.ts:23`, `incoming.some(isKeyedBySession)` to `incoming.every(...)`: **not equivalent** (r4). +- **Fix:** record the command, scope, score and survivors in evidence.md, or drop the claim. "Never state … anything not just observed" (`coding-assertions.md`). -### n5. m4 is half done; the placement is arguable +### r4. The D1 trigger for a mixed batch is pinned only by chance (Medium) -- **m4:** `UsageLedgerAdapter` (`usage-ledger-adapter.ts:58`) and `ResolutionStoreAdapter` (`resolution-store-adapter.ts:22`) still build their own `RepositoryAliasStoreAdapter`. `BindingSnapshotStoreAdapter` has its store injected. All three read the same file, so nothing is incorrect, but the wiring is inconsistent. Fix: inject the store everywhere, or say in one line why the two adapters build their own. -- **Placement:** `ledger-upkeep.ts:1` makes the tool-agnostic upkeep import a Claude-format predicate. A per-format "copies unbounded in time" predicate would keep the format knowledge in its own module. No action is needed while there is only one format. +- **Where:** `ledger-upkeep.ts:23`. With `every`, a batch holding one requestless call and one with a `requestId` takes the one-day window, so the requestless call's far copies are not read and the ledger holds it twice: the D1 defect, back for any transcript that mixes both kinds. +- **Evidence:** survived the Stryker run. By hand, window property plus `ledger-upkeep.integration.test.ts`, three runs: passed, red, red (`× leave every bounded read equal to the full ledger filtered to its period`). Only the random generator catches it; no example test builds a mixed batch. +- **Fix:** an example test in `ledger-upkeep.integration.test.ts`: a requestless copy stored days earlier, then one batch of its other copy plus a call with a `requestId`; the ledger holds the requestless call once. It must go red under `every`. +- Also surviving, PR code outside the changed-since-`57169330` lines: `repository-aliases.ts:23`, dropping `object?.version !== FORMAT` or `typeof object.complete !== "boolean"`. A table with a wrong version or a non-boolean `complete` is read as valid. Low: only this CLI writes the file. Fix: one parse test each. `repository-aliases.ts:20` (`text ?? ""` to any non-JSON string) is equivalent. ## Tests-first -- evidence.md records red runs for D1 (the pinned case and the window property's counterexample), M1 (10 failed, 15 passed) and m1. -- Tests ship in the same commits as their code, as in round 1 (m7, accepted). -- The migration property was extended, but it passed on the unfixed code. evidence.md says so. +- n1: red run recorded before the exemption (evidence.md). +- n3, n4: tests passed on the code as it stood, and evidence.md says so. Each is proven by a mutation instead. M1-h re-run here: killed. ## Checklist | Item | Result | | --- | --- | -| No duplication | partial: the advisor regex is duplicated (n3) | -| No incoherence | partial: n1 and n2 | -| No over-engineering | partial: redundant guard at `stored-usage-migration.ts:28` (n3) | -| No dead code or debug leftovers | holds: `git diff 57169330..a06f9857 -- cli/src` has no logs and no TODOs | +| No duplication | holds: one `ADVISOR` | +| No incoherence | partial: PR body vs Limits (r2) | +| No over-engineering | holds. `olderOf` (`report-window…test.ts:335`) is a one-line helper, used twice, acceptable | +| No dead code or debug leftovers | holds: `git diff a06f9857..429948a3 -- cli/src` adds no logs and no TODOs | ## Bundle -`node scripts/check-bundle-size.mjs` prints `Bundle size: 700.7 KB / budget: 709 KB`, `OK: within budget`. `dist/cli.js` was built at the head commit's time. The budget is unchanged, so the headroom rule in `check-bundle-size.mjs` is not triggered. Headroom is now 1.2 %. +`node scripts/check-bundle-size.mjs`: `Bundle size: 700.6 KB / budget: 709 KB`, `OK: within budget`. `dist/cli.js` 717,408 B, built 09:38, after the head commit (09:33). Matches the PR. ## Commands +All CLI runs sandboxed (`HOME`, `XDG_CONFIG_HOME`, `AIDD_USER_CONFIG_DIR`, `AIDD_TELEMETRY_DIR`, `CLAUDE_CONFIG_DIR` in the scratchpad, `GIT_CONFIG_NOSYSTEM=1`). Mutations under `mutation.lock`, each file restored, `git status` clean after. + | Command | Decisive output | | --- | --- | -| `vitest run` on the 14 test files changed since `57169330` | `Test Files 14 passed`, `Tests 242 passed` | -| scratch probe: N ingests plus a full settle vs one ingest, 300 runs | `PROBE byte-identity mismatches 0 of 300` | -| scratch probe: requestless lines from an older CLI, after the migration | `PROBE old-cli bounded= 1 full= 0` | -| scratch probe: 250,000-record ledger, one store | `withRequest= 13 requestless= 1672 heapMB= 307` | -| scratch probe: M1 retry, failure at each write, replaced clone | `twice-idempotent= true crash mismatches= 0 of 6`; `OLD_A mapped? false` | -| `stryker run -m `, under `mutation.lock` | `97.12`, 101 killed, 3 survived | -| `node scripts/check-bundle-size.mjs` | `700.7 KB / budget: 709 KB` | +| `vitest run` on `application/legacy`, the window property, `claude-transcript-usage.unit`, `legacy-usage-key.unit`, `stored-ids-and-keys`, `ledger-upkeep` | `Test Files 6 passed`, `Tests 78 passed` | +| mutant D1, `ledger-upkeep.ts:23` full read disabled, window property x3 | red 3 of 3, counterexamples with two non-older requestless copies | +| mutant r1, `usage-ledger-adapter.ts:96` bounded read drops re-keyed lines, window property x3 then `vitest run tests/contexts/telemetry` | passed, red, passed; then `1 failed | 74 passed` (the window property only) | +| mutant M1-h, `stored-usage-migration.ts:40` `complete: table !== null` | `× stopped at any write of a pending root's retry …`, `1 failed | 14 passed` | +| `stryker run --mutate` on `ledger-upkeep.ts:1-24`, `stored-usage-migration.ts:10-70`, `claude-transcript-usage.ts:15-84`, `legacy-usage-key.ts:1-14`, `repository-aliases.ts:9-36`, `period.ts:35-42`, `ingest-usage-use-case.ts:74-98`, `usage-ledger-adapter.ts:42-66` | `95.11`, `214` killed, `11` survived; in-PR survivors listed in r3, r4. The rest (`claude-transcript-usage.ts:20-62`, `ingest-usage-use-case.ts:89`) are lines this PR does not change | +| mutant r4, `ledger-upkeep.ts:23` `some` to `every`, window property + `ledger-upkeep` x3 | passed, red, red | +| `node scripts/check-bundle-size.mjs` | `700.6 KB / budget: 709 KB` | -Not run: the full suite (pre-push passed it at `a06f9857`: 6,808 tests). +Not run: the full suite (pre-push passed it at `429948a3`: 6,809 tests). ## Score -86 = 100, minus 6 (n1: a contract claim falsified by a probe), minus 4 (n2: six stale statements), minus 2 (n3), minus 1 (n4), minus 1 (n5). +90 = 100, minus 3 (r1: the exemption hides an undercount, which a mutant survives), minus 2 (r2: the PR body contradicts the limit), minus 2 (r3: a mutation score unrecorded and contradicted by a rerun), minus 3 (r4: the D1 trigger for a mixed batch has no deterministic test; two unpinned table checks). From 8dee6830fceb77307c1441d6c5d576a8dd2096e3 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 14:20:18 +0200 Subject: [PATCH 17/19] fix(telemetry): a bounded read keeps the full read's copy of an older CLI's call The window property counted the records of a call an older CLI also stored, but never compared them. Once the full read's copy is in the period, the bounded read must keep that very copy: the stated limit allows over-counting only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../telemetry/domain/formats/claude-transcript-usage.ts | 3 ++- .../report/report-window.property.integration.test.ts | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts b/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts index 8e71c5a95..37fcbccf4 100644 --- a/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts +++ b/cli/src/contexts/telemetry/domain/formats/claude-transcript-usage.ts @@ -12,7 +12,8 @@ export interface ClaudeUsageLine { type Json = Record; const SYNTHETIC_MODEL = ""; -/** What an advisor iteration adds to its message's key. */ +/** What an advisor iteration adds to its message's key. Anchored for clarity only: the format + * writes it as a suffix alone, and message and session ids hold no `#`. */ export const ADVISOR = /#advisor\d+$/; const NONE: ClaudeUsageLine = { records: [], unrecognised: [] }; diff --git a/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts b/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts index e7a910072..e6b19cfef 100644 --- a/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts +++ b/cli/tests/contexts/telemetry/application/report/report-window.property.integration.test.ts @@ -402,6 +402,12 @@ describe("calls stored by ingests, a requestless call's copies days apart, some records.filter((record) => record.key === key).length; expect(count(bounded)).toBe(onDisk.has(key) ? 1 : 0); expect(count(full)).toBe(1); + // Over-counting is all the limit allows: once the full read's copy is in the period, + // its day is in the window and the bounded read keeps that very copy. + const kept = full.find((record) => record.key === key); + if (kept !== undefined && inPeriod(kept.at, asked)) { + expect(bounded.find((record) => record.key === key)).toEqual(kept); + } } // One record per call, whatever its copies' spread. expect(full.length).toBe(calls.filter((c) => c.copies.length > 0).length); From 2632e26588d0a852aeecb00436f6643ae9df9e0c Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 14:24:55 +0200 Subject: [PATCH 18/19] docs(telemetry): round four evidence, final gates, mutants counted Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../evidence.md | 47 ++++++- .../review.md | 124 +++++++++--------- 2 files changed, 108 insertions(+), 63 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md index 0ca754db2..1454fce0b 100644 --- a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md @@ -292,6 +292,49 @@ Survivors left, all equivalent: The checker's 95.11 (11 survivors) mutated whole line ranges, including lines this change does not touch, such as `claude-transcript-usage.ts:20-62` and `ingest-usage-use-case.ts:89`. The two figures measure different scopes. -### Hand mutations, all rounds -45, each killed: 23 in the first build, 12 in round 1, 3 in round 2 (`ADVISOR`, P-a, M1-h), 7 in round 3 (the five above, and the two Stryker survivors). + +## Review round 4 (94/100): r1b, r5, r6 + +### r1b: a bounded read must keep the full read's copy + +The window property counted the records of a call an older CLI also stored, but never compared them. It now asserts, for each such call whose full-read record lies in the period, that the bounded read's record deep-equals it: its day is then in the window, and the window's fold keeps that same copy. The limit allows over-counting only. + +The checker's mutant, applied alone before `usage-ledger-adapter.ts:96`: + +```ts +if (record !== read && holds !== undefined && heldIn.has(`${record.tool}\u0000${record.key}`)) continue; +``` + +| Test | Runs | +| --- | --- | +| window property as committed in `102baf98` | survived 3 of 3 | +| window property with the content check (`8dee6830`) | killed 3 of 3: `× leave every bounded read equal to the full ledger filtered to its period` | + +The `$` anchor of `ADVISOR` now carries a comment saying it is there for clarity only. + +### Hand mutations, all rounds, counted as mutants + +Each row of the tables above is one mutant: one replacement, applied alone. All 47 were killed. + +| Round | Mutants | +| --- | --- | +| First build | 23 (M7-a to M7-e, M10-a, K-a to K-c, L-a, L-b, R-a, S-a, G-a to G-i, I-a) | +| Canonical day order (`57169330`) | 1 (G-j) | +| Review round 1 | 12 (D1-a to D1-d, m1, M1-a to M1-g) | +| Review round 2 | 3 (`ADVISOR` `\d+` to `\d`, P-a, M1-h) | +| Review round 3 | 7 (mixed batch, version check, completion check, two re-keyed-line drops, `>=` on a bare session key, the unsettled mark) | +| Review round 4 | 1 (r1b) | + +## Gates at the last code commit (`8dee6830`) + +| Gate | Result | +| --- | --- | +| `cd cli && pnpm test` | `Test Files 522 passed | 1 skipped (523)`, `Tests 6813 passed | 1 skipped (6814)` | +| `node --test 'scripts/__tests__/**/*.test.js'` | 461 tests, 461 pass, 0 fail | +| `pnpm build` | `Bundle size: 700.6 KB / budget: 709 KB`; `dist/cli.js` 717,408 B of 726,016 B | +| `pnpm smoke` | `PASS: 132 FAIL: 0 SKIP: 1 · coverage 100%` | +| `node scripts/check-claude-tokens-end-to-end.cjs cli/dist/cli.js` | `2 calls, 393 tokens, all on task demo, ticket T-1, and on task demo in this repository` | +| same, `--attended` | same, then `asked, answered by the hook, kept across /clear` | +| `pnpm test:mutation:telemetry`, full scope, before the review fixes | 97.2 (see above) | +| Stryker, changed lines, after `8086176b` | 98.8 since `57169330` (83 mutants), 98.8 since `origin/next` (252 mutants) | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md index 18a01076f..52a41e0e6 100644 --- a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md @@ -2,101 +2,103 @@ status: changes-requested --- -# Review: One call, one key; one repository, one id (round 3) +# Review: One call, one key; one repository, one id (round 4) -Head `429948a3`, base `origin/next`. Independent check, read-only on the code. Round 2 (86/100, head `a06f9857`) is superseded by this report. +Head `102baf98`, base `origin/next`. Independent check, read-only on the code. Round 3 (90/100, head `429948a3`) is superseded by this report. -**Verdict: changes requested, minors only. Score 90/100.** +**Verdict: changes requested, minors only. Score 94/100.** -No deal breaker. n2 to n5 hold. n1 is documented, but the window property's exemption is wider than the limit it names: it also hides a bounded report that *loses* a call, which the limit does not allow. A hand mutation of that kind survives half the runs. The PR description still contradicts the new limit in two sentences. Its "98.8, one equivalent survivor" Stryker claim is unrecorded and not reproduced: a non-equivalent mutant of the D1 trigger survives a run. +No deal breaker. `cli/src` is unchanged since `429948a3` (`git diff --quiet 429948a3 HEAD -- src`), so every round-4 change is a test or a doc. r2, r3 and r4 hold. r1 is half done: the new assertion catches a bounded read that *drops* an older-CLI call, but not one that keeps the call with a *smaller* snapshot. A hand mutant of that kind survives 3 of 3 runs, and a probe shows it undercounting. Three numbers in the PR description are not in evidence.md. ## Deal breakers None. -## Round 2 findings, re-checked +## Round 3 findings, re-checked | # | Claim | Status | Evidence | | --- | --- | --- | --- | -| n1 | limit stated, claims qualified | fulfilled | `contract.md` Read-time row, older-CLI paragraph, new Limits row; `usage-contract.md` Key row, window paragraph, `repository-ids.json` row | -| n1 | window property generates older-CLI requestless lines | partial | generated and red run recorded (evidence.md). Exemption too wide: see r1 | -| n2 | six stale statements | fulfilled | `usage-contract.md` :59, :127, :164; `usage-ledger-adapter.ts:42-43` (header); `telemetry.md:55`; `contract.md` flowchart and Limits row now cite 1,672 ms vs 13 ms | -| n3 | one `ADVISOR` pattern, `#advisor10` pinned | fulfilled | `claude-transcript-usage.ts:16`, imported by `legacy-usage-key.ts:1`; tests at `claude-transcript-usage.unit.test.ts:334`, `legacy-usage-key.unit.test.ts:19` | -| n3 | guard deleted, no behaviour change | fulfilled | `stored-usage-migration.ts:29-38`: complete table, `pending: []` => `roots = []` (`[] ?? …` keeps `[]`), `locate([])` spawns no git, `ids.size === table.ids.size`, `0 < 0` false => return, zero writes. Idempotence and per-ingest cost unchanged. Only cost added: none (one `load()` was already there) | -| n4 | migration property with `unreadable`/`gone`; failing retry writes | fulfilled | `stored-usage-migration.property.integration.test.ts:148-156, 321-325`; `stored-ids-and-keys.integration.test.ts:250`. Mutation M1-h reproduced: killed (below) | -| n5 | one-line rationale | fulfilled | `usage-ledger-adapter.ts:65`, `resolution-store-adapter.ts:22` | +| r4 | a fixed mixed-batch test kills `some` to `every` at `ledger-upkeep.ts:23` | fulfilled | `ledger-upkeep.integration.test.ts:232`. `every`: red 3 of 3. Own mutants: `incoming.slice(0, 1).some(...)` red 3 of 3 (the fixed test); `incoming.slice(-1).some(...)`, which the fixed test's order lets through, red 3 of 3 (the window property) | +| r4 | the table's `version` and `complete` checks are tested | fulfilled | `repository-aliases.unit.test.ts:27-38`, each case valid but for the one field | +| r1 | the window property asserts the limit instead of skipping | partial | catches a drop; misses a smaller snapshot (r1b below) | +| r1 | assertion not flaky | fulfilled | `onDisk` reads the same day files `inWindow` selects (no monthly file is generated) and keys them with the same `currentKeyOf`. Exact for counts: the window property green in 4 runs | +| r2 | PR description consistent with the limit | fulfilled | "Read-time rules" and "Requestless calls" now qualified; matches `contract.md:77, :80, :98` and the usage contract | +| r3 | changed-line Stryker runs recorded | fulfilled | evidence.md "r3": command, both scopes, mutant counts, survivors. 98.8 % of 83 is 82 killed (1 survivor), of 252 is 249 (3 survivors): consistent | -Earlier rounds, no regression: the `cli/src` diff since `a06f9857` is the regex move, the guard deletion and two comments. Identity, keys, fold, lock, crash order, hook path and M1 retry code are untouched. The D1 full read (`ledger-upkeep.ts:23`) is still pinned by the window property despite the exemption (mutation D1 below, killed 3 of 3). The six changed test files pass. +### The three equivalence claims -## Minor +| Survivor | Holds? | Why | +| --- | --- | --- | +| `claude-transcript-usage.ts:16`, `$` dropped | yes, given an input invariant | the only writer appends `#advisor${n}` at the end (`:140`). Without `$` a match can differ only if a message, request or session id contains `#advisor`. Anthropic ids and Claude Code session UUIDs do not. Nothing enforces that | +| `repository-aliases.ts:20`, `text ?? ""` to any non-JSON string | yes | `tryParseJson` fails on both, so `object` is `null` and the result `null` either way | +| `legacy-repository-identity.ts:9`, `address === null` to `false` | yes | `path` is `null` at `:8` whenever `address` is, so `path === null` returns the same | + +### No regression in earlier rounds -### r1. The window property's exemption also hides an undercount (Medium) +No test weakened: the test diff since `429948a3` only adds assertions and cases, and the `repository-aliases` "another format" case is replaced by a stricter one (all else valid). Source untouched since round 3, which verified identity (one id, no alias chain), keys, fold, lock, crash order, the hook path and the M1 retry. Re-run green here: the migration properties (fresh ingest on six axes and bytes, twice equals once, stop at every write), the #1032 window property, the requestless window property, stored ids and keys, the lock and stream properties, the ingest e2e. -- **Where:** `report-window.property.integration.test.ts:380-387`. Every record of a call with an older-CLI copy is dropped from both sides of `expect(inside(bounded)).toEqual(inside(full))`. -- **The documented limit** (`contract.md` Limits row, `usage-contract.md` `repository-ids.json` row): a bounded report "may count such a copy as well as the call's other copy". An over-count only. Reasoned: when the full read's winner is in the period, its day is in the window and no in-window copy can beat it, so the bounded read must return that same record. A bounded read never legitimately loses the call. -- **Failure, reproduced by hand mutation:** `usage-ledger-adapter.ts:96`, insert before it `if (record !== read && holds !== undefined) continue;`, so every bounded read drops each re-keyed older-CLI line. - - A report with a period then silently omits those calls. - - The window property passed in 2 of 4 runs. It went red only when a later requestful store rewrote the older line's day and so deleted it from disk (`full.length` assertion), not through the exempted comparison. - - The other 74 telemetry test files passed with the mutant. -- **Not an older-CLI case:** none found. The exemption also covers the new-CLI copies of an exempt call, but non-exempt requestless calls still kill the D1 mutant 3 of 3. -- **Fix:** for exempt keys, assert the limit instead of skipping it. Each in-period full record of an exempt call must equal the bounded record of its key. The bounded read may hold an extra exempt record only when the full winner of that key is outside the period. +## Minor -### r2. The PR description contradicts the new limit +### r1b. The window property misses an older-CLI call kept with a smaller snapshot (Medium) -- `## How it works`, Read-time rules: "Figures stay right in a half-migrated state, and when an older CLI writes again afterwards." Since n1 this holds only for a report with no period. -- Requestless calls: "A bounded report then agrees with the full one." Same qualifier missing. The Limits bullet in the same body says the opposite. -- "hand mutations (23, then 12 more, all killed)": round 3 added n3, P-a and M1-h. -- **Fix:** qualify both sentences ("in a report with no period; see Limits") and update the count. +- **Where:** `report-window.property.integration.test.ts:400-405`. For exempt keys it checks `count(bounded)` and `count(full)` only, never the bounded record's content. Round 3 asked for the record itself: "each in-period full record of an exempt call must equal the bounded record of its key". +- **Why it matters:** the limit (`contract.md:98`) allows an over-count only. When the full winner of an exempt call is in the period, its day is in the window, so the bounded fold sees it and must return that same record. Anything smaller is an undercount the limit does not allow. +- **Failure, reproduced:** mutant inserted before `usage-ledger-adapter.ts:96`: -### r3. Stryker 98.8 is claimed in the PR, not recorded in evidence.md, and not reproduced + ```ts + if (record !== read && holds !== undefined && heldIn.has(`${record.tool}\u0000${record.key}`)) continue; + ``` -- The PR claims "Stryker on the lines changed since `57169330`: 98.8, one equivalent survivor". evidence.md has no such run: its last Stryker figure is 97.2. -- Rerun (scope below, wider than the changed lines): `All files | 95.11 | 214 killed | 11 survived`. In lines changed since `57169330`, two survive, not one: - - `claude-transcript-usage.ts:16`, `$` anchor removed: equivalent, as the PR says. - - `ledger-upkeep.ts:23`, `incoming.some(isKeyedBySession)` to `incoming.every(...)`: **not equivalent** (r4). -- **Fix:** record the command, scope, score and survivors in evidence.md, or drop the claim. "Never state … anything not just observed" (`coding-assertions.md`). + A bounded read skips an older-CLI copy whenever another copy of the call is already held, whatever its size. + - Window property, `ledger-upkeep`, `usage-ledger-adapter`, `stored-ids-and-keys`: `Tests 88 passed (88)`, 3 runs of 3. + - Scratchpad probe, new copy `output 1` on 2026-10-01, older-CLI copy `output 5` on 2026-10-02, window holding both: `PROBE bounded [ 'msg_1:s-1/1' ] full [ 'msg_1:s-1/5' ]`. On the real code the probe passes. + - Under the same mutant, a report with a period would also settle those days from the smaller record, so the better copy leaves the disk. +- **Fix:** keep the count check and add, for each exempt key whose full record is in the period, `expect(bounded.find((r) => r.key === key)).toEqual(full.find((r) => r.key === key))`. Prove it with the mutant above. -### r4. The D1 trigger for a mixed batch is pinned only by chance (Medium) +### r5. Three PR-description numbers have no record in evidence.md (Low) -- **Where:** `ledger-upkeep.ts:23`. With `every`, a batch holding one requestless call and one with a `requestId` takes the one-day window, so the requestless call's far copies are not read and the ledger holds it twice: the D1 defect, back for any transcript that mixes both kinds. -- **Evidence:** survived the Stryker run. By hand, window property plus `ledger-upkeep.integration.test.ts`, three runs: passed, red, red (`× leave every bounded read equal to the full ledger filtered to its period`). Only the random generator catches it; no example test builds a mixed batch. -- **Fix:** an example test in `ledger-upkeep.integration.test.ts`: a requestless copy stored days earlier, then one batch of its other copy plus a call with a `requestId`; the ledger holds the requestless call once. It must go red under `every`. -- Also surviving, PR code outside the changed-since-`57169330` lines: `repository-aliases.ts:23`, dropping `object?.version !== FORMAT` or `typeof object.complete !== "boolean"`. A table with a wrong version or a non-boolean `complete` is read as valid. Low: only this CLI writes the file. Fix: one parse test each. `repository-aliases.ts:20` (`text ?? ""` to any non-JSON string) is equivalent. +- "`cd cli && pnpm test`: 6,813 passed": evidence.md's last full run is 6,789. +- "`node --test …`: 461 of 461": evidence.md has 460 of 460. +- "Bundle: 717,408 B": evidence.md has 716,597 B. Only the round-3 review held 717,408, and this report replaces it. Measured here: `dist/cli.js` 717,408 B, built 12:37, after the head commit (12:35). So the figure is right but unrecorded. +- "Gates on the final commit": smoke and the tokens end-to-end runs are recorded once, at the first build, not at `102baf98`. +- **Fix:** add the final-commit gate runs to evidence.md (`Gates` table), or say which commit each figure comes from. -## Tests-first +### r6. The hand-mutation count is loose (Low) -- n1: red run recorded before the exemption (evidence.md). -- n3, n4: tests passed on the code as it stood, and evidence.md says so. Each is proven by a mutation instead. M1-h re-run here: killed. +- evidence.md: "45: 23 … 12 … 3 … 7 in round 3 (the five above, and the two Stryker survivors)". The "two survivors" are four mutants (`>=`, `true`, and "two mutants" at `usage-ledger-adapter.ts:96`). G-j (the merge fix) is listed as killed but in no count. +- **Fix:** count mutants, not rows, and include G-j; update the PR's "45". ## Checklist | Item | Result | | --- | --- | -| No duplication | holds: one `ADVISOR` | -| No incoherence | partial: PR body vs Limits (r2) | -| No over-engineering | holds. `olderOf` (`report-window…test.ts:335`) is a one-line helper, used twice, acceptable | -| No dead code or debug leftovers | holds: `git diff a06f9857..429948a3 -- cli/src` adds no logs and no TODOs | - -## Bundle - -`node scripts/check-bundle-size.mjs`: `Bundle size: 700.6 KB / budget: 709 KB`, `OK: within budget`. `dist/cli.js` 717,408 B, built 09:38, after the head commit (09:33). Matches the PR. +| No duplication | holds | +| No incoherence | partial: PR numbers vs evidence.md (r5, r6) | +| No over-engineering | holds. The `onDisk` scan is 8 lines and reads only what the assertion needs | +| No dead code or debug leftovers | holds: no `cli/src` change since round 3; the test diff adds no logs or TODOs | +| Public repo | holds: the caller's local-path grep on `git diff origin/next...HEAD`, plus the username and the session-id prefix, finds nothing (exit 1). The same grep on this file finds nothing | ## Commands -All CLI runs sandboxed (`HOME`, `XDG_CONFIG_HOME`, `AIDD_USER_CONFIG_DIR`, `AIDD_TELEMETRY_DIR`, `CLAUDE_CONFIG_DIR` in the scratchpad, `GIT_CONFIG_NOSYSTEM=1`). Mutations under `mutation.lock`, each file restored, `git status` clean after. +All runs sandboxed (`HOME`, `XDG_CONFIG_HOME`, `AIDD_USER_CONFIG_DIR`, `AIDD_TELEMETRY_DIR`, `CLAUDE_CONFIG_DIR`, `TMPDIR` in the scratchpad, `GIT_CONFIG_NOSYSTEM=1`). Mutations under `mutation.lock`, each file restored with `git checkout --`, `git status` clean after. | Command | Decisive output | | --- | --- | -| `vitest run` on `application/legacy`, the window property, `claude-transcript-usage.unit`, `legacy-usage-key.unit`, `stored-ids-and-keys`, `ledger-upkeep` | `Test Files 6 passed`, `Tests 78 passed` | -| mutant D1, `ledger-upkeep.ts:23` full read disabled, window property x3 | red 3 of 3, counterexamples with two non-older requestless copies | -| mutant r1, `usage-ledger-adapter.ts:96` bounded read drops re-keyed lines, window property x3 then `vitest run tests/contexts/telemetry` | passed, red, passed; then `1 failed | 74 passed` (the window property only) | -| mutant M1-h, `stored-usage-migration.ts:40` `complete: table !== null` | `× stopped at any write of a pending root's retry …`, `1 failed | 14 passed` | -| `stryker run --mutate` on `ledger-upkeep.ts:1-24`, `stored-usage-migration.ts:10-70`, `claude-transcript-usage.ts:15-84`, `legacy-usage-key.ts:1-14`, `repository-aliases.ts:9-36`, `period.ts:35-42`, `ingest-usage-use-case.ts:74-98`, `usage-ledger-adapter.ts:42-66` | `95.11`, `214` killed, `11` survived; in-PR survivors listed in r3, r4. The rest (`claude-transcript-usage.ts:20-62`, `ingest-usage-use-case.ts:89`) are lines this PR does not change | -| mutant r4, `ledger-upkeep.ts:23` `some` to `every`, window property + `ledger-upkeep` x3 | passed, red, red | -| `node scripts/check-bundle-size.mjs` | `700.6 KB / budget: 709 KB` | - -Not run: the full suite (pre-push passed it at `429948a3`: 6,809 tests). +| `vitest run` window property, `ledger-upkeep`, `usage-ledger-adapter`, `stored-ids-and-keys`, `domain/legacy`, `application/legacy`, `claude-transcript-usage.unit`, `repository-identity.unit` | `Test Files 13 passed`, `Tests 227 passed` | +| `vitest run` ingest unit, bindings lock, `application/report`, ingest e2e, transcript-stream-ingest property | `Test Files 11 passed`, `Tests 169 passed` | +| `vitest run` ledger lock, transcript-stream property, `application/legacy` | `Test Files 3 passed`, `Tests 29 passed` | +| window property + `application/legacy`, 2 more runs | `Tests 6 passed`, twice | +| `ledger-upkeep.ts:23` to `incoming.every(...)`, `ledger-upkeep` x3 | `× reads every day for a batch mixing …`, 3 of 3 | +| `ledger-upkeep.ts:23` to `incoming.slice(0, 1).some(...)`, `ledger-upkeep` + window property x3 | fixed test red 3 of 3 | +| `ledger-upkeep.ts:23` to `incoming.slice(-1).some(...)`, same x3 | `× leave every bounded read equal …`, 3 of 3 | +| `usage-ledger-adapter.ts:96`, `if (record !== read && holds !== undefined) continue;` (round 3's), window property x3 | red 3 of 3 | +| `usage-ledger-adapter.ts:96`, drop a re-keyed copy only when none is held yet, window property x3 | red 3 of 3 | +| `usage-ledger-adapter.ts:96`, drop a re-keyed copy only when one is held (r1b), four files x3 | `Tests 88 passed (88)`, 3 of 3: **survives** | +| scratchpad probe under that mutant, then on the real code | `bounded [ 'msg_1:s-1/1' ] full [ 'msg_1:s-1/5' ]`; then `Tests 1 passed` | +| `stat dist/cli.js` | `717408`, built after `102baf98` | + +Not run: the full suite (pre-push passed it at `102baf98`: 6,813 tests, per the caller), Stryker (r3's record checked for consistency, not reproduced). ## Score -90 = 100, minus 3 (r1: the exemption hides an undercount, which a mutant survives), minus 2 (r2: the PR body contradicts the limit), minus 2 (r3: a mutation score unrecorded and contradicted by a rerun), minus 3 (r4: the D1 trigger for a mixed batch has no deterministic test; two unpinned table checks). +94 = 100, minus 3 (r1b: the limit's "never an undercount" is pinned for a drop only; a smaller-snapshot mutant survives), minus 2 (r5: three PR figures and the final-commit gates unrecorded), minus 1 (r6: mutation count loose). From e9f19e7a50e92b733300b90f03f351df74bdca70 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 17 Oct 2026 14:31:36 +0200 Subject: [PATCH 19/19] docs(telemetry): review round 5 approves, and the mutation count says what it counts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VWNxk63AGKkqE8HRqHLjGp --- .../evidence.md | 2 +- .../review.md | 123 +++++++++--------- 2 files changed, 62 insertions(+), 63 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md index 1454fce0b..13c4fe62f 100644 --- a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/evidence.md @@ -315,7 +315,7 @@ The `$` anchor of `ADVISOR` now carries a comment saying it is there for clarity ### Hand mutations, all rounds, counted as mutants -Each row of the tables above is one mutant: one replacement, applied alone. All 47 were killed. +The 47 counts the hand mutations, each one replacement applied alone; all were killed. Two rows of the round-3 survivor table hold two Stryker mutants each (`>=` and `true`; the two at `:96`), all four killed too. | Round | Mutants | | --- | --- | diff --git a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md index 52a41e0e6..f1300298a 100644 --- a/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md +++ b/aidd_docs/tasks/2026_10/2026_10_17_one-call-one-key-one-repository-one-id/review.md @@ -1,104 +1,103 @@ --- -status: changes-requested +status: approved --- -# Review: One call, one key; one repository, one id (round 4) +# Review: One call, one key; one repository, one id (round 5) -Head `102baf98`, base `origin/next`. Independent check, read-only on the code. Round 3 (90/100, head `429948a3`) is superseded by this report. +Head `2632e265`, base `origin/next`. Independent check, read-only on the code. Round 4 (94/100, head `102baf98`) is superseded by this report. -**Verdict: changes requested, minors only. Score 94/100.** +**Verdict: approve. Score 99/100.** -No deal breaker. `cli/src` is unchanged since `429948a3` (`git diff --quiet 429948a3 HEAD -- src`), so every round-4 change is a test or a doc. r2, r3 and r4 hold. r1 is half done: the new assertion catches a bounded read that *drops* an older-CLI call, but not one that keeps the call with a *smaller* snapshot. A hand mutant of that kind survives 3 of 3 runs, and a probe shows it undercounting. Three numbers in the PR description are not in evidence.md. +No deal breaker. r1b and r5 hold; r6 holds but for its wording (n1). `cli/src` changed only by the `ADVISOR` comment since `429948a3`. Four non-blocking nits remain, all in docs. ## Deal breakers None. -## Round 3 findings, re-checked +## Round 4 findings, re-checked | # | Claim | Status | Evidence | | --- | --- | --- | --- | -| r4 | a fixed mixed-batch test kills `some` to `every` at `ledger-upkeep.ts:23` | fulfilled | `ledger-upkeep.integration.test.ts:232`. `every`: red 3 of 3. Own mutants: `incoming.slice(0, 1).some(...)` red 3 of 3 (the fixed test); `incoming.slice(-1).some(...)`, which the fixed test's order lets through, red 3 of 3 (the window property) | -| r4 | the table's `version` and `complete` checks are tested | fulfilled | `repository-aliases.unit.test.ts:27-38`, each case valid but for the one field | -| r1 | the window property asserts the limit instead of skipping | partial | catches a drop; misses a smaller snapshot (r1b below) | -| r1 | assertion not flaky | fulfilled | `onDisk` reads the same day files `inWindow` selects (no monthly file is generated) and keys them with the same `currentKeyOf`. Exact for counts: the window property green in 4 runs | -| r2 | PR description consistent with the limit | fulfilled | "Read-time rules" and "Requestless calls" now qualified; matches `contract.md:77, :80, :98` and the usage contract | -| r3 | changed-line Stryker runs recorded | fulfilled | evidence.md "r3": command, both scopes, mutant counts, survivors. 98.8 % of 83 is 82 killed (1 survivor), of 252 is 249 (3 survivors): consistent | +| r1b | the window property deep-compares the bounded record to the full record of an older-CLI call | fulfilled | `report-window.property.integration.test.ts:405-410`. Round-4 mutant before `usage-ledger-adapter.ts:96`: red 3 of 3, failing at `:409:68` (the new `toEqual`). Own mutant, `found.push(record)` at `:97` to `found.push(record !== read && holds !== undefined ? { ...record, output: Math.max(0, record.output - 1) } : record)` (a bounded read shrinks an older-CLI copy, count unchanged): red 3 of 3, failing at `:409:68` | +| r1b | assertion not flaky | fulfilled | real code, unseeded fast-check (no `seed` or `configureGlobal`), 150 runs per test: green 4 of 4 | +| r5 | every PR number is in evidence.md and matches | fulfilled | see the table below | +| r6 | hand mutations counted as mutants | partial | evidence.md "Hand mutations, all rounds": 23 + 1 + 12 + 3 + 7 + 1 = 47, G-j included. But "each row is one mutant" is false for the r3 survivor table (n1). All its mutants are killed, so wording only | +| - | comment on `ADVISOR`'s `$` | fulfilled | `claude-transcript-usage.ts:15-16`, the only `cli/src` change since `429948a3` | -### The three equivalence claims +### PR description against evidence.md -| Survivor | Holds? | Why | -| --- | --- | --- | -| `claude-transcript-usage.ts:16`, `$` dropped | yes, given an input invariant | the only writer appends `#advisor${n}` at the end (`:140`). Without `$` a match can differ only if a message, request or session id contains `#advisor`. Anthropic ids and Claude Code session UUIDs do not. Nothing enforces that | -| `repository-aliases.ts:20`, `text ?? ""` to any non-JSON string | yes | `tryParseJson` fails on both, so `object` is `null` and the result `null` either way | -| `legacy-repository-identity.ts:9`, `address === null` to `false` | yes | `path` is `null` at `:8` whenever `address` is, so `path === null` returns the same | +| PR figure | evidence.md | +| --- | --- | +| 6,813 passed, 1 skipped | `:333` `Tests 6813 passed \| 1 skipped (6814)` | +| 461 of 461 | `:334` | +| smoke PASS 132, FAIL 0 | `:336` | +| tokens end-to-end, plain and `--attended` | `:337-338` | +| 97.2, floor 96 | `:100-103`, `:339` | +| 98.8 (83 mutants), 98.8 (252 mutants), three equivalent survivors | `:281-282`, survivors table, `:340` | +| 512,820 lines, 184,502 ids, 0.2687 h | D1 section | +| 1,672 ms against 13 ms | `:223`, number only (n2) | +| 4.5 s / 50 roots / 250,000; 20.8 s / 200 / 1,000,000; 110 s | `:203-212` | +| 717,408 B of 726,016 B | `:335`; measured here `dist/cli.js` 717,408 B, built 14:24:56, the head commit is 14:24:55 | +| five days; 3 and 9 days | `:174` | +| 47 mutants | `:318-327` | -### No regression in earlier rounds +### No regression -No test weakened: the test diff since `429948a3` only adds assertions and cases, and the `repository-aliases` "another format" case is replaced by a stricter one (all else valid). Source untouched since round 3, which verified identity (one id, no alias chain), keys, fold, lock, crash order, the hook path and the M1 retry. Re-run green here: the migration properties (fresh ingest on six axes and bytes, twice equals once, stop at every write), the #1032 window property, the requestless window property, stored ids and keys, the lock and stream properties, the ingest e2e. +- `git diff --stat 429948a3 HEAD -- cli`: `cli/src` only `claude-transcript-usage.ts | 3 ++-` (comment). Tests since then only add assertions and cases. +- `git diff --stat 102baf98 HEAD`: evidence.md, review.md, the comment, and 6 added test lines. No test weakened. +- Regression set re-run green on the real code (below). -## Minor +## Non-blocking nits -### r1b. The window property misses an older-CLI call kept with a smaller snapshot (Medium) +### n1. "Each row is one mutant" vs the round-3 survivor table (Nit) -- **Where:** `report-window.property.integration.test.ts:400-405`. For exempt keys it checks `count(bounded)` and `count(full)` only, never the bounded record's content. Round 3 asked for the record itself: "each in-period full record of an exempt call must equal the bounded record of its key". -- **Why it matters:** the limit (`contract.md:98`) allows an over-count only. When the full winner of an exempt call is in the period, its day is in the window, so the bounded fold sees it and must return that same record. Anything smaller is an undercount the limit does not allow. -- **Failure, reproduced:** mutant inserted before `usage-ledger-adapter.ts:96`: +- **Where:** evidence.md `:318` and `:326`; the r3 survivor table above them. +- **Issue:** that table has two rows holding two mutants each (`>=` and `true`; "two mutants" at `usage-ledger-adapter.ts:96`). The round-3 count names `>=` and "the unsettled mark" once each, so "each row is one mutant" is false for that table, and 47 counts 2 of its 4. +- **Fix:** say the count is mutants applied by hand, and that `true` and the second `:96` mutant were killed by the Stryker rerun only. Or count all four (49). - ```ts - if (record !== read && holds !== undefined && heldIn.has(`${record.tool}\u0000${record.key}`)) continue; - ``` +### n2. The 1,672 ms measurement has no record in the tree (Nit, beyond criterion 2) - A bounded read skips an older-CLI copy whenever another copy of the call is already held, whatever its size. - - Window property, `ledger-upkeep`, `usage-ledger-adapter`, `stored-ids-and-keys`: `Tests 88 passed (88)`, 3 runs of 3. - - Scratchpad probe, new copy `output 1` on 2026-10-01, older-CLI copy `output 5` on 2026-10-02, window holding both: `PROBE bounded [ 'msg_1:s-1/1' ] full [ 'msg_1:s-1/5' ]`. On the real code the probe passes. - - Under the same mutant, a report with a period would also settle those days from the smaller record, so the better copy leaves the disk. -- **Fix:** keep the count check and add, for each exempt key whose full record is in the period, `expect(bounded.find((r) => r.key === key)).toEqual(full.find((r) => r.key === key))`. Prove it with the mutant above. +- **Where:** PR "the checker measured 1,672 ms at 250,000 records, against 13 ms"; evidence.md `:223` cites the number only. +- **Issue:** the number is in evidence.md and matches, so r5 holds. Its command and output lived in the round-2 review, which every round overwrites. It survives in git history only. +- **Fix:** copy the command and both timings into evidence.md, next to m3. -### r5. Three PR-description numbers have no record in evidence.md (Low) +### n3. "Gates on the final commit" (Nit) -- "`cd cli && pnpm test`: 6,813 passed": evidence.md's last full run is 6,789. -- "`node --test …`: 461 of 461": evidence.md has 460 of 460. -- "Bundle: 717,408 B": evidence.md has 716,597 B. Only the round-3 review held 717,408, and this report replaces it. Measured here: `dist/cli.js` 717,408 B, built 12:37, after the head commit (12:35). So the figure is right but unrecorded. -- "Gates on the final commit": smoke and the tokens end-to-end runs are recorded once, at the first build, not at `102baf98`. -- **Fix:** add the final-commit gate runs to evidence.md (`Gates` table), or say which commit each figure comes from. +- **Where:** PR "How to verify"; evidence.md `:329` says `8dee6830`. +- **Issue:** the gates ran at `8dee6830`, not `2632e265`. `2632e265` changes only evidence.md and review.md (checked), so the figures carry over, but the heading should say so. +- **Fix:** "Gates on the last code commit (`8dee6830`)". -### r6. The hand-mutation count is loose (Low) +### n4. The `ADVISOR` comment states an unenforced invariant as fact (Nit) -- evidence.md: "45: 23 … 12 … 3 … 7 in round 3 (the five above, and the two Stryker survivors)". The "two survivors" are four mutants (`>=`, `true`, and "two mutants" at `usage-ledger-adapter.ts:96`). G-j (the merge fix) is listed as killed but in no count. -- **Fix:** count mutants, not rows, and include G-j; update the PR's "45". +- **Where:** `claude-transcript-usage.ts:15-16`, "message and session ids hold no `#`". +- **Issue:** nothing validates it; it holds for Anthropic message ids and Claude Code session UUIDs today. +- **Fix:** "hold no `#` in the formats seen so far", or leave as is. ## Checklist | Item | Result | | --- | --- | | No duplication | holds | -| No incoherence | partial: PR numbers vs evidence.md (r5, r6) | -| No over-engineering | holds. The `onDisk` scan is 8 lines and reads only what the assertion needs | -| No dead code or debug leftovers | holds: no `cli/src` change since round 3; the test diff adds no logs or TODOs | -| Public repo | holds: the caller's local-path grep on `git diff origin/next...HEAD`, plus the username and the session-id prefix, finds nothing (exit 1). The same grep on this file finds nothing | +| No incoherence | holds, but n1 and n3 (wording) | +| No over-engineering | holds: the r1b fix is 6 test lines | +| No dead code or debug leftovers | holds: one comment in `cli/src`, no logs or TODOs in the test diff | +| Public repo | holds: the caller's local-path grep on `git diff origin/next...HEAD` exits 1; same for the username, the session-id prefix and the scratchpad id, exit 1 | ## Commands -All runs sandboxed (`HOME`, `XDG_CONFIG_HOME`, `AIDD_USER_CONFIG_DIR`, `AIDD_TELEMETRY_DIR`, `CLAUDE_CONFIG_DIR`, `TMPDIR` in the scratchpad, `GIT_CONFIG_NOSYSTEM=1`). Mutations under `mutation.lock`, each file restored with `git checkout --`, `git status` clean after. +All runs sandboxed (`HOME`, `XDG_CONFIG_HOME`, `AIDD_USER_CONFIG_DIR`, `AIDD_TELEMETRY_DIR`, `CLAUDE_CONFIG_DIR`, `TMPDIR` in the scratchpad, `GIT_CONFIG_NOSYSTEM=1`). Mutations under `mutation.lock`, the file restored with `git checkout --`, lock removed, `git status --short` empty after. | Command | Decisive output | | --- | --- | -| `vitest run` window property, `ledger-upkeep`, `usage-ledger-adapter`, `stored-ids-and-keys`, `domain/legacy`, `application/legacy`, `claude-transcript-usage.unit`, `repository-identity.unit` | `Test Files 13 passed`, `Tests 227 passed` | -| `vitest run` ingest unit, bindings lock, `application/report`, ingest e2e, transcript-stream-ingest property | `Test Files 11 passed`, `Tests 169 passed` | -| `vitest run` ledger lock, transcript-stream property, `application/legacy` | `Test Files 3 passed`, `Tests 29 passed` | -| window property + `application/legacy`, 2 more runs | `Tests 6 passed`, twice | -| `ledger-upkeep.ts:23` to `incoming.every(...)`, `ledger-upkeep` x3 | `× reads every day for a batch mixing …`, 3 of 3 | -| `ledger-upkeep.ts:23` to `incoming.slice(0, 1).some(...)`, `ledger-upkeep` + window property x3 | fixed test red 3 of 3 | -| `ledger-upkeep.ts:23` to `incoming.slice(-1).some(...)`, same x3 | `× leave every bounded read equal …`, 3 of 3 | -| `usage-ledger-adapter.ts:96`, `if (record !== read && holds !== undefined) continue;` (round 3's), window property x3 | red 3 of 3 | -| `usage-ledger-adapter.ts:96`, drop a re-keyed copy only when none is held yet, window property x3 | red 3 of 3 | -| `usage-ledger-adapter.ts:96`, drop a re-keyed copy only when one is held (r1b), four files x3 | `Tests 88 passed (88)`, 3 of 3: **survives** | -| scratchpad probe under that mutant, then on the real code | `bounded [ 'msg_1:s-1/1' ] full [ 'msg_1:s-1/5' ]`; then `Tests 1 passed` | -| `stat dist/cli.js` | `717408`, built after `102baf98` | - -Not run: the full suite (pre-push passed it at `102baf98`: 6,813 tests, per the caller), Stryker (r3's record checked for consistency, not reproduced). +| window property, real code, x4 | `Tests 2 passed (2)`, 4 of 4 | +| round-4 mutant before `usage-ledger-adapter.ts:96`; window property, `ledger-upkeep`, `usage-ledger-adapter`, `stored-ids-and-keys`, x3 | `Tests 1 failed \| 87 passed (88)`, `× leave every bounded read equal …`, 3 of 3; stack `:409:68` | +| own shrink mutant at `usage-ledger-adapter.ts:97`, same four files, x3 | `Tests 1 failed \| 87 passed (88)`, 3 of 3; stack `:409:68` | +| `application/legacy`, `domain/legacy`, `ledger-upkeep` (fixed and property), `stored-ids-and-keys`, `usage-ledger-adapter`, `claude-transcript-usage.unit`, `repository-identity` | `Test Files 13 passed (13)`, `Tests 226 passed (226)` | +| `git diff --stat 429948a3 HEAD -- cli` | `cli/src`: `claude-transcript-usage.ts \| 3 ++-` only | +| `stat -f '%z %Sm' cli/dist/cli.js` | `717408 Oct 17 14:24:56 2026` | + +Not run: the full suite (pre-push passed it at `2632e265`: 6,813 tests, per the caller), Stryker, smoke, the tokens end-to-end runs (their records checked for consistency, not reproduced). Nor the ingest, bindings lock, `application/report`, ingest e2e and stream and lock properties round 4 ran: `cli/src` changed only by a comment since then. ## Score -94 = 100, minus 3 (r1b: the limit's "never an undercount" is pinned for a drop only; a smaller-snapshot mutant survives), minus 2 (r5: three PR figures and the final-commit gates unrecorded), minus 1 (r6: mutation count loose). +99 = 100, minus 1 (n1: r6 partial, the mutant count wording contradicts the table it counts). n2 asks for more than criterion 2; n3 and n4 are wording. No deduction for them.