Op lifecycle records are one file per record
Status: Accepted (2026-11-01). Operator pre-rulings of 2026-11-01 on #7003.
Date: 2026-11-01
Deciders: Stijn Dejongh (operator).
Technical Story:
- #7003: every Mission PR appended to the shared
kitty-ops/lifecycle.jsonl, so two PRs in the merge queue conflicted at its end and the second was dropped (#6804, #6843 and #6892 had to land one at a time on 2026-11-01). - Extends ADR 2026-11-01-1 (pull requests touch no shared generated file) to the Op lifecycle log.
- Keeps the interim locked append of ADR 2026-10-22-1 (#6606) for every write.
Context and Problem Statement
spec-kitty next records a started lifecycle record when it issues an action and a paired
completed or failed record when the agent reports back (ProfileInvocationRecord,
src/specify_cli/invocation/lifecycle.py). The doctor orphan report and the NFR-006 pairing rate
read them. All of them were appended to one tracked file. Any two branches that each ran next
edited the end of that file, and git cannot merge two appends at the same place without a conflict.
The interim kitty-ops/lifecycle.jsonl merge=union attribute fixed local merges and rebases. It is not
known that GitHub's merge queue honours a merge driver attribute, and a union merge of two logs is
still a shared-file edit that ADR 2026-11-01-1 rules out.
Considered Options
- A. One file per record,
kitty-ops/lifecycle/<ulid>.json. - B. Fold lifecycle records into the per-Op files
kitty-ops/<op-ulid>.jsonl, which already never conflict. - C. Stop tracking the lifecycle log.
- D. One file per (Mission, action) group.
Decision
Option A. Each record is written to its own kitty-ops/lifecycle/<ulid>.json, holding the record's
one sorted-key JSON line and a line feed. No other branch writes that file, so two PRs never conflict
on lifecycle records and no merge driver is needed.
- Name. A ULID, not the record's
at(an ISO timestamp holds colons, which Windows refuses in a file name). The ULID is issued monotonically within one process, so two records with the sameatkeep their write order. - Write. Through the interim locked door (
kernel.event_log_append.append_event_log_lines), with the record directory as the family lock anchor, so lock files do not grow with the number of records. The write refuses a final-component symlink and refuses a name that already holds content (LifecycleRecordExistsError): a record file is created once and never rewritten. - Legacy log.
kitty-ops/lifecycle.jsonlstays tracked and readable, and is never written again. It is not split into record files: that would be a migration commit touching every lifecycle record in every project for no reader's benefit. - Read order.
read_lifecycle_recordsreturns the legacy log's lines in insertion order first, then the record files sorted by (atas UTC, ulid). A corrupt or unreadable record file is skipped like a corrupt line. Pairing, the orphan report and the pairing rate give the same results on the same records, whichever layout holds them. - Owned checkouts. The owned
nextcommit lists only the record files the checkout has not committed yet (git statusscoped to the record directory), never every record ever written. - Retention.
doctor event-logsnever scanskitty-ops/, and its path guard refuses the record directory and the legacy log. Committed lifecycle records are never removed. - Merge attribute. The interim
kitty-ops/lifecycle.jsonl merge=unionline stays in.gitattributeswhile open branches still append to the legacy log:mainno longer writes it, but two such branches would otherwise conflict with each other at the end of the file on a rebase or local merge. It is removed in a later change once no open branch appends to the legacy log.
Why not the others
- B needs every lifecycle record to carry exactly one Op id when it is issued. It does not:
ProfileInvocationRecordhas no Op id, andnextopens no Op (docs/context/ops-vs-missions.md). - C loses the history the
doctororphan report and the pairing-rate check read. - D still collides: a
startedrecord and its completion can land from different branches, and both would edit the same group file.
Consequences
- Two PRs that each ran
nextno longer conflict on lifecycle records, in the merge queue or locally. kitty-ops/lifecycle/grows by one small file per record. Readers list the directory once per read.- A branch cut before this change still appends to the legacy log. Without the union attribute, two
such branches can still conflict there until they are rebased onto a
mainthat has this change; after the rebase, their next records are files. - Pairing counts, the orphan count and the pairing rate do not depend on the order. Which
startedrecord of a group the orphan report names can: within the record files it followsat, not write order, which differs only when the clock steps back between two records of one group; and legacy lines always read first, even when an older CLI appended one after record files exist. - The archive preservation gate keeps admitting a byte-prefix append to the legacy log (an older CLI
still writes one) and treats every record file like any other
kitty-ops/file: it may be added, never changed. docs/architecture/event-ledger-writers-and-readers.mdstill names the legacy path. It is in the frozen set of ADR 2026-10-29-1 D-9, so it is corrected inspec-kitty-events, not here.