Trail Model
Operator reference for the Phase 4 runtime consumption baseline.
Overview
Every standalone Spec Kitty dispatch leaves an auditable trail. The trail serves three purposes:
- Local accountability: operators can reconstruct what happened on any checkout without SaaS connectivity.
- Hosted coherence (design intent): a hosted timeline could show the same history as the local audit log. No hosted projection path is live today; see SaaS Read-Model Policy.
- Governance provenance: downstream retrospective and doctrine work can reference specific invocations.
minimal viable trail
One JSONL file per invocation, written locally before the executor returns.
Every spec-kitty dispatch "<request>" call writes a started event to:
kitty-ops/{invocation_id}.jsonl
When spec-kitty profile-invocation complete is called, a completed event is appended to the same file.
This is the unconditional minimum — it is always written, regardless of SaaS connectivity, charter state, or sync configuration. The data model is defined in src/specify_cli/invocation/record.py.
Mission-scoped dispatchers may also record mission_id, wp_id, and the
catalog-winning model_id on the started event. An implement or review claim
that supplies --invocation-id treats those values as a provenance assertion:
the requested ULID and the record's embedded ID, mission, WP, action, profile,
and model must agree exactly. Uncorrelated legacy records remain readable, but
cannot prove a resolved model actual for a claim.
Mode-of-Work Taxonomy
Every invocation belongs to one of four work modes. The mode determines which optional trail tiers are eligible — it does not override the mandatory Tier 1 rule.
| Mode | Description | Example actions | Tier 2 eligible | Tier 3 eligible |
|---|---|---|---|---|
task_execution |
Standalone governed work, including advice, review, code changes, and test runs | dispatch |
Yes (caller-triggered) | No |
mission_step |
One step in a governed mission workflow | specify, plan, tasks, merge, accept |
Yes (caller-triggered) | Yes |
query |
Read-only, no execution | profiles list, invocations list |
No | No |
Mode-of-work is recorded on the started event as the mode_of_work field. New standalone dispatch records use task_execution. Runtime enforcement is active: profile-invocation complete --evidence is rejected for records that are not evidence-eligible (see "Mode Enforcement at Tier 2 Promotion" below).
Mode Enforcement at Tier 2 Promotion
spec-kitty profile-invocation complete --evidence <path> is rejected with InvalidModeForEvidenceError when the target invocation is not evidence-eligible. The enforcement runs before any write, so the invocation remains open and uncommitted — rerun complete without --evidence to close cleanly. Unsupported evidence (not UTF-8 text) is likewise rejected before any write, with UnsupportedEvidenceError surfacing as complete_failed (exit 1).
Pre-mission records (invocations opened before this enforcement landed) have no mode_of_work field and are accepted by enforcement — legacy behaviour is preserved. See ADR-002-mode-derivation.md for the full derivation table and rationale.
Trail Tiers
Tier 1 — Every Invocation (mandatory)
Written unconditionally before the executor returns.
- Storage:
kitty-ops/{invocation_id}.jsonl - Content: Two JSONL lines — a
startedevent and (after completion) acompletedevent. - When: All standalone
dispatchinvocations.
Glossary Check Event (conditional, Tier 1)
When the invocation executor's glossary chokepoint scan detects at least one
conflict — or encounters an error — it appends a glossary_checked event to the
same Tier 1 JSONL file, immediately after the started event.
Written ONLY when:
all_conflictsis non-empty (one or more semantic conflicts detected), ORerror_msgis non-null (the chokepoint scan encountered an unexpected exception).
Clean invocations produce NO glossary_checked line. This keeps Tier 1
files minimal when there are no glossary issues to report.
Example glossary_checked event line:
{"event": "glossary_checked", "invocation_id": "01HXYZ...", "matched_urns": ["glossary:d93244e7"], "high_severity": [{"term": "lane", "conflict_type": "ambiguous_scope", "severity": "HIGH", "candidate_senses": ["execution lane (WP routing)", "git branch lane (worktree)"]}], "all_conflicts": [...], "tokens_checked": 8, "duration_ms": 2.7, "error_msg": null}
Readers that encounter "event": "glossary_checked" and do not recognise this
event type may safely skip the line — it is additive metadata and never affects
the started/completed pair.
Correlation Links (Tier 1 extension)
spec-kitty profile-invocation complete accepts two additional flags that append correlation events to the invocation JSONL:
--artifact <path>— repeatable. Each value appends one{event: "artifact_link", invocation_id, kind, ref, at}line tokitty-ops/<id>.jsonl. Refs are stored repo-relative when the resolved path is under the checkout, absolute otherwise.--commit <sha>— singular. Appends one{event: "commit_link", invocation_id, sha, at}line.
Both events are append-only (never mutate existing lines) and readable by a single-file scan. Readers that do not recognise these event types may safely skip the line — the same additive-reader invariant that protects glossary_checked.
SaaS projection status: Correlation events are local-only. The projection policy (POLICY_TABLE in src/specify_cli/invocation/projection_policy.py) assigns project=True for task_execution / mission_step correlation events, but no SaaS client is registered (see SaaS Read-Model Policy), so nothing leaves the checkout.
See ADR-001-correlation-contract.md for the design; contracts/profile-invocation-complete.md for the CLI shape.
Tier 2 — Evidence Artifact (optional, caller-triggered)
Created when the caller explicitly flags that the invocation produced checkable output.
- Trigger: Caller sets
--evidence <path>onspec-kitty profile-invocation complete. - Storage:
.kittify/evidence/{invocation_id}/evidence.mdand.kittify/evidence/{invocation_id}/record.json - Format: UTF-8 text only, checked before the
completedevent is recorded. - When:
task_executionandmission_stepmodes only.
Tier 3 — Durable Project State (optional, action-driven)
Promotion to kitty-specs/ or doctrine artifacts only when the invocation changes project-domain state.
- Trigger: Action is in
TIER_3_ACTIONS—{specify, plan, tasks, merge, accept}. - Storage:
kitty-specs/{mission_slug}/— existing spec/plan/tasks/status files. - When:
mission_stepmode only.
Promotion Rules
Tier 1 always written
|
+-- If caller sets evidence_ref --> Tier 2 artifact created
|
+-- If action in TIER_3_ACTIONS --> Tier 3 artifacts produced by workflow
Doctor-Sweep Closures and the Archive Freeze (#4397)
kitty-ops/ is one of the four immutable archive roots under the
historical-preservation gate (tests/architectural/test_archive_root_byte_identical.py):
a pre-existing kitty-ops/<invocation_id>.jsonl file may never be modified
— not on disk, not in the index, committed or not. An agent close appends its
completed event to the per-record file and that is legal only because the
record was minted in the same session (new archive history, A status). The
spec-kitty doctor ops --close-stale sweep closes Ops that are, by definition,
already committed — so recording the closure by mutating the per-record file
(and auto-committing it) reds the preservation gate.
The resolved design (#4397, option "record closure on the append-only spine"):
Per-record
kitty-ops/<id>.jsonlfiles are byte-frozen once committed. The sweep never touches them.Sweep closures are recorded on a dedicated append-only spine,
kitty-ops/op-closures.jsonl— one v2OpCompletedEventJSON line per closure (same frozen model as the per-recordcompletedevent, carryingoutcome="abandoned",closed_by="doctor_sweep"). The preservation gate admits this one path the same way it admitskitty-ops/lifecycle.jsonl: a modification is legal only as a byte-prefix-preserving append whose new rows parse as valid v2 completed events.The close still goes through the canonical executor path (
ProfileInvocationExecutor.complete_invocation, research R4): the spine append is that path's write for aclosed_by="doctor_sweep"close, the event is still submitted to the SaaS propagator, and the idempotent already-closed guard now checks both the per-record file (concurrent manual close) and the spine (concurrent sweep).Open-Op detection reads both surfaces: an Op is closed when its per-record file carries a
completedevent OR the spine carries a closure for its invocation id (list_orphan_ops).The sweep auto-commits only the spine (
op(<profile>): <action> [<id8>] (doctor sweep)). A refused commit (e.g. the protected-branch guard onmain) is reported per entry in the sweep output and stated in the command's output: the closure stays on disk, uncommitted. A committed one is visible only on branches that carry the commit. The agent close path is just as visible: a refused per-record auto-commit is printed as a warning (executor.last_op_commit) instead of dying in alogger.warning.The auto-commit runs only where the repository tracks Op records (#6410). Both close paths commit onto the checked-out branch, so they are skipped when:
- the exact path being committed (the record, or the spine) matches an
ignore rule (
git check-ignore --no-index), or - the primary branch tracks no
kitty-ops/<ULID>.jsonlrecord, locally or onorigin. Special files such asops-index.jsonl,lifecycle.jsonl,propagation-errors.jsonlandop-closures.jsonldo not count.
The primary branch is
origin/HEAD, elsemain,masterordevelop. It is never the checked-out branch, so an earlierop(...)commit on an issue branch cannot turn the commit back on. A skipped close keeps the record on disk, wherespec-kitty invocations listanddoctor opsfind it. The close output says so (OpCommitOutcome.skipped, andskipped:<reason>in the sweep entry). An uncommitted spine can still block a checkout of a branch that commitskitty-ops/op-closures.jsonl; move or commit it first. The decision lives insrc/specify_cli/invocation/op_commit_policy.py. A repository that tracks Op records, this one included, keeps the current-branch commit.- the exact path being committed (the record, or the spine) matches an
ignore rule (
Rejected alternatives: letting the sweep mutate per-record files without committing (still reds the gate, which compares the working tree against the merge-base — and leaves closures non-durable), and narrowing the archive roots themselves (weakens the preservation guarantee far beyond this surface).
SaaS Read-Model Policy
No SaaS projection path is live. The invocation propagator (src/specify_cli/invocation/propagator.py) asks an adapter seam for a SaaS client, and no package registers one, so every call is a no-op (#3030, the hosted-consent incident whose fix removed the dead registration). The sync transport that once sat behind it was retired. Whoever registers a real transport owns the auth and admission gate that makes it safe, because the payload carries request_text, the verbatim agent prompt.
The policy table below is what the propagator consults per (mode_of_work, event) once a client exists (src/specify_cli/invocation/projection_policy.py::POLICY_TABLE).
| mode_of_work | event | project | include_request_text | include_evidence_ref |
|---|---|---|---|---|
| task_execution | started | yes | yes | no |
| task_execution | completed | yes | yes | yes |
| task_execution | artifact_link | yes | no | no |
| task_execution | commit_link | yes | no | no |
| mission_step | started | yes | yes | no |
| mission_step | completed | yes | yes | yes |
| mission_step | artifact_link | yes | no | no |
| mission_step | commit_link | yes | no | no |
| query | any | no | — | — |
Pre-mission records (no mode_of_work) project under the task_execution rules (the legacy behaviour, kept for them).
Policy is additive and resolvable from code/config alone. See ADR-003-projection-policy.md for the rationale.
Projection is additive. Events accumulate; there is no deletion, replay-based overwrite, or idempotency-key gating.
Tier 2 SaaS Projection — Deferred
Status: Tier 2 evidence artifacts (.kittify/evidence/<invocation_id>/evidence.md and record.json) are local-only. They are not uploaded to SaaS. This decision was finalised by the Phase 4 closeout mission (ADR-004-tier2-saas-deferral.md).
Reasoning:
- The product has always behaved this way (since the 3.2.0a5 baseline); operators see local-only evidence.
- SaaS projection of evidence bodies requires privacy, redaction, and size-limit design that lies outside the Phase 4 closeout scope.
- Future projection remains possible without contract change — a later epic can read the existing local artifact and emit its own envelope.
Revisit trigger: any of (a) a named future epic accepts SaaS evidence projection as scope, (b) operators actively request the feature with a concrete use case, (c) a regulatory or audit requirement mandates centralised retention.
Retention and Redaction
| Field | Treatment |
|---|---|
request_text |
Retained as-written in local JSONL. No automatic redaction. |
governance_context_hash |
First 16 hex chars of SHA-256 only. Full governance context is never persisted. |
| JSONL files | Persist indefinitely unless the operator purges kitty-ops/. |
| SaaS propagation | None live today. The policy is additive, with no delete-on-disable. |
Propagation failures are written to kitty-ops/propagation-errors.jsonl and never affect the CLI exit code.
spec-kitty intake — Not a Profile Invocation
spec-kitty intake ingests a plan document into .kittify/mission-brief.md for use by /spec-kitty.specify brief-intake mode. It is not a standalone Op and produces no InvocationRecord. The governed trail begins when the host calls spec-kitty dispatch "<request>" — not when the user stages a brief.
Host surfaces that teach the trail
The standalone dispatch surface is taught to host LLMs through per-host skill packs. See docs/host-surface-parity.md for the authoritative matrix of supported hosts and each host's parity status.
spec-kitty explain — Deferred to Phase 5
spec-kitty explain (issue #534) is not part of this release. It requires Phase 5 DRG glossary addressability to produce fully-cited answers. A partial implementation without glossary citations would be misleading.