Contracts

cli_surfaces.md

Contract: CLI Surfaces

Status: pinned for this tranche. Operator-facing surface: spec-kitty retrospect summary (top-level, AD-003 / Q3-C). Agent-facing mutation surface: spec-kitty agent retrospect synthesize (under agent, AD-003 / Q3-C).

The retrospect action and retrospective-facilitator profile (FR-001, FR-002) are DRG artifacts, not CLI commands. They are invoked through spec-kitty next --agent <name> --mission <handle> and consume DRG context like any other action.


Command 1: spec-kitty retrospect summary

Top-level operator-facing read surface. Reads the project's mission corpus and emits a cross-mission summary.

Usage

spec-kitty retrospect summary [OPTIONS]

Options:
  --project PATH            Project root (default: current working directory).
  --json                    Emit JSON to stdout instead of Rich rendering.
  --json-out PATH           Emit JSON to a file in addition to whatever rendering
                            is selected. Useful for downstream tools.
  --limit N                 Top-N for ranked sections (default: 20, max: 100).
  --since DATE              ISO-8601 date; only include missions whose
                            mission_started_at is on or after DATE.
  --include-malformed       Include malformed records' detail in output
                            (default: counts only).
  --help                    Show help and exit.

Behavior

1. Discover retrospective records by globbing <project>/.kittify/missions/*/retrospective.yaml (NFR-003 ≤200 missions in <5 s). 2. For each, attempt schema-validating load. Malformed entries become MalformedSummaryEntry rows in the result. (NFR-004.) 3. Read proposal-lifecycle events from the mission's kitty-specs/<slug>/status.events.jsonl. Missing log → "no retrospective events" entry (no crash). 4. Reduce into a SummarySnapshot. 5. Render Rich output AND emit JSON if --json or --json-out is set. The Rich rendering and the JSON are informationally equivalent (CHK034). The JSON schema mirrors SummarySnapshot from data-model.md.

Output sections (Rich + JSON)

  • Counts: mission_count, completed, skipped, failed, in_flight, legacy_no_retro, terminus_no_retro, malformed.
  • Top-N "not helpful" targets.
  • Top-N "missing terms," "missing edges," "over-inclusion," "under-inclusion."
  • Proposal acceptance metrics: total / accepted / rejected / applied / pending / superseded.
  • Top-N skip reasons (HiC).
  • Malformed entries (counts always; detail when --include-malformed).

Exit codes

ExitMeaning
0Summary produced (even if some missions had no retrospective).
1Project root invalid (no .kittify/ and no kitty-specs/).
2I/O error reading the corpus (logs surfaced).

Examples

spec-kitty retrospect summary
spec-kitty retrospect summary --json --limit 10 > summary.json
spec-kitty retrospect summary --since 2026-01-01 --include-malformed

Command 2: spec-kitty agent retrospect synthesize

Agent-facing surface that applies staged proposals from a mission's retrospective record. Default is --dry-run. Mutation is opt-in via --apply (FR-021).

Usage

spec-kitty agent retrospect synthesize --mission <handle> [OPTIONS]

Required:
  --mission HANDLE          Mission handle (mission_id / mid8 / mission_slug).
                            Resolver disambiguates by mission_id.

Options:
  --dry-run                 (default) Plan + check; do not mutate.
  --apply                   Execute application after conflict + staleness checks pass.
  --proposal-id ID          Repeatable; restricts the batch to specific proposal ids.
                            Default: all proposals with state.status == "accepted",
                            plus all auto-applicable flag_not_helpful proposals.
  --json                    Emit JSON to stdout.
  --json-out PATH           Also write JSON to PATH.
  --actor-id ID             Override the actor recorded in provenance (default:
                            inferred from environment / user identity).
  --help                    Show help and exit.

Behavior

1. Resolve the mission via the standard handle resolver. Ambiguous handle → structured MISSION_AMBIGUOUS_SELECTOR error (no silent fallback). 2. Load the retrospective record from the canonical path. Missing or malformed → exit code 3. 3. Compute the proposal batch (per --proposal-id or default). 4. Call specify_cli.doctrine.synthesizer.apply_proposals(..., dry_run=<flag>). 5. Render SynthesisResult as Rich + (optional) JSON. Informationally equivalent. 6. Emit retrospective.proposal.applied / .rejected events as appropriate (only when --apply; dry-run emits no events). 7. Exit non-zero on conflicts/rejections when --apply was passed.

Exit codes

ExitMeaning
0Dry-run completed; or apply succeeded with no conflicts and no apply-time rejections.
1Mission handle unresolvable (MISSION_AMBIGUOUS_SELECTOR or not found).
2I/O error reading retrospective record.
3Retrospective record malformed; refuse to operate.
4Apply attempted with --apply but conflicts present; nothing applied.
5Apply attempted with --apply but staleness/invalid-payload rejections present; nothing applied.

Examples

# Dry-run (default) for a mission, all accepted proposals
spec-kitty agent retrospect synthesize --mission 01KQ6YEG

# Apply a specific accepted proposal after dry-run looked clean
spec-kitty agent retrospect synthesize \
    --mission 01KQ6YEG --apply --proposal-id 01KQ6YE...P1

# Machine-readable plan
spec-kitty agent retrospect synthesize --mission 01KQ6YEG --json > plan.json

Help text

Both commands ship Rich-rendered help. The help body for retrospect summary MUST mention:

  • The data sources read (.kittify/missions//retrospective.yaml and kitty-specs//status.events.jsonl).
  • The fact that no mutation is performed.

The help body for agent retrospect synthesize MUST mention:

  • That --dry-run is the default and that --apply is required to mutate.
  • That flag_not_helpful is the only auto-applied kind (Q2-A).
  • That conflict detection is fail-closed.

Output JSON shape

Both commands emit JSON whose top-level keys include a schema_version field ("1") so downstream tools can pin. The JSON schema mirrors SummarySnapshot (for summary) or SynthesisResult (for synthesize) plus a thin envelope:

{
  "schema_version": "1",
  "command": "retrospect.summary",
  "generated_at": "2026-04-27T11:35:00+00:00",
  "result": { ... }
}
{
  "schema_version": "1",
  "command": "agent.retrospect.synthesize",
  "generated_at": "2026-04-27T11:35:00+00:00",
  "dry_run": true,
  "result": { ... }
}

gate_api.md

Contract: Lifecycle Gate Python API

Status: pinned for this tranche. Source of truth: specify_cli.retrospective.gate. ADR: architecture/2.x/adr/2026-04-27-1-retrospective-gate-shared-module.md (drafted in WP for sub-problem 3).

The gate is the single source of truth (AD-001, Q1-C). Both specify_cli.next and any status-transition surface that ever needs mission-level mode policy MUST consult it through this API. Mission-level policy MUST NOT be re-implemented in WP-level status code.


Public API

# specify_cli.retrospective.gate

def is_completion_allowed(
    mission_id: MissionId,
    *,
    feature_dir: Path,
    repo_root: Path,
    mode_override: Mode | None = None,
) -> GateDecision: ...

Inputs

ArgumentTypeNotes
mission_idMissionId (ULID)Canonical identity.
feature_dirPathPath to kitty-specs/<slug>/; used to read the mission event log.
repo_rootPathUsed to find .kittify/missions/<mission_id>/retrospective.yaml.
mode_override`Mode \None`

Output

class GateDecision(BaseModel):
    allow_completion: bool
    mode: Mode
    reason: GateReason

(See ../data-model.md for GateReason.)

The decision is deterministic: same event log + same mode signals → same GateDecision. (NFR-008.)


Decision matrix

ModeLatest retrospective event for missionDecision
autonomousnoneblock, missing_completion_autonomous
autonomousretrospective.completedallow, completed_present
autonomousretrospective.skippedblock, silent_skip_attempted
autonomousretrospective.failedblock, facilitator_failure
autonomousonly requested/startedblock, missing_completion_autonomous
human_in_commandnoneblock, silent_auto_run_attempted if a next-driven completion is being attempted; otherwise the gate returns allow=False and the runtime offers the retrospective to the operator.
human_in_commandretrospective.completedallow, completed_present_hic
human_in_commandretrospective.skippedallow, skipped_permitted
human_in_commandretrospective.failedblock, facilitator_failure

Charter override: in autonomous mode, if the charter clause permits operator-authorized skip and a retrospective.skipped event carries an actor whose authorization matches the clause, the gate allows completion. The decision's reason.code = "skipped_permitted" and reason.charter_clause_ref is set. Without a permissive clause, autonomous + skipped is always silent_skip_attempted.

Silent auto-run detection (HiC): if a retrospective.completed event exists in HiC mode but the upstream requested event was emitted by actor.kind == "runtime" (not by an operator), the gate treats the completion as silent and returns silent_auto_run_attempted blocking. This is the operational predicate for "silent" called out in CHK005.


Performance

NFR-007: when retrospective.completed is already present, is_completion_allowed MUST return in < 500 ms (warm interpreter, SSD; a 200-mission corpus is not relevant — this is per-mission). Implementation-wise the gate reads at most:

1. meta.json (small). 2. kitty-specs/<slug>/status.events.jsonl filtered to retrospective events (constant-bounded for any single mission). 3. .kittify/missions/<mission_id>/retrospective.yaml (only if a completed event references it for hash verification).

No filesystem walk of the project is required. No network IO.


Determinism

The same (event log content, charter content, env vars, parent-process metadata, mode_override) MUST produce the same GateDecision. Tests assert this by replaying the same fixture twice in a process and comparing.


Caller patterns

From next (canonical control loop)

from specify_cli.retrospective import gate as retro_gate

decision = retro_gate.is_completion_allowed(
    mission_id=meta.mission_id,
    feature_dir=feature_dir,
    repo_root=repo_root,
)
if not decision.allow_completion:
    raise MissionCompletionBlocked(decision)

From a status-transition surface

from specify_cli.retrospective import gate as retro_gate

decision = retro_gate.is_completion_allowed(
    mission_id=mission_id,
    feature_dir=feature_dir,
    repo_root=repo_root,
)
if not decision.allow_completion and intends_mission_completion(transition):
    return TransitionRejected(reason=decision.reason)

Mission-level policy never lives inside specify_cli.status.transitions itself. WP-level transitions remain governed by the existing per-WP transition matrix.


Error contract

Errors raised by the gate are typed:

class GateError(Exception): ...
class MissionIdentityMissing(GateError): ...
class EventLogUnreadable(GateError): ...
class ModeResolutionError(GateError): ...

Errors are not silently converted to allow_completion=True. They propagate to the caller, which surfaces them as structured runtime errors. (FR-011, CHK005, CHK006.)


Fakes for tests

specify_cli.retrospective.gate exposes a tests namespace (or a sibling tests/retrospective/_fakes.py) with:

  • a fake event-log builder that takes a list of envelope dicts and writes a JSONL file;
  • a fake charter override loader that returns a Mode value without touching disk;
  • a clock fixture so timestamps are deterministic.

Tests for the gate exercise every row in the decision matrix above.

retrospective_events_v1.md

Contract: Retrospective Events v1

Status: pinned for this tranche. Source of truth (initial): specify_cli.retrospective.events (this tranche). Source of truth (post-cutover): spec_kitty_events.retrospective.* (after the upstream PR ships). Boundary test: tests/architectural/test_shared_package_boundary.py enforces single-home post-cutover (R-007).

The eight event names are stable. Renaming requires a deprecation cycle.


Common envelope

Retrospective events share the existing mission event envelope used in kitty-specs/<slug>/status.events.jsonl. A retrospective event's serialized JSON line MUST include:

Envelope fieldTypeNotes
event_idULID stringStable, sortable.
event_nameone of the eight names belowDiscriminator.
atISO-8601 UTC timestampWall clock.
actorActorRef (see data-model.md)Who emitted.
mission_idMissionId (ULID)Required for retrospective events.
mid8Mid8Convenience for log scanners.
mission_slugstringConvenience.
payloadevent-specific Pydantic modelSee per-event tables below.

Retrospective events do NOT use the to_lane/from_lane fields used by status transitions (they don't change WP lane state). They join the same JSONL log so the reducer reads them in order.

The reducer surfaces retrospective state in the snapshot under a new retrospective field (additive; existing snapshot consumers see no change).


Event 1: retrospective.requested

Emitted at mission terminus (autonomous: by the runtime hook) or on operator action (HiC: when the operator chooses to run / skip).

Payload fieldTypeNotes
modeModeResolved mode + source signal.
terminus_step_idstringThe mission step that hit terminus.
requested_byActorRefRuntime in autonomous; operator in HiC.

Event 2: retrospective.started

Emitted when the facilitator dispatch begins (after requested and before any finding is captured).

Payload fieldTypeNotes
facilitator_profile_idstringe.g., retrospective-facilitator.
action_idstringe.g., retrospect.

Event 3: retrospective.completed

Emitted after retrospective.yaml is persisted with status: completed.

Payload fieldTypeNotes
record_pathstringAbsolute path to the persisted retrospective.yaml.
record_hashstringSHA-256 of the canonical-bytes of the persisted YAML.
findings_summary{helped: int, not_helpful: int, gaps: int}Counts only; payload is in the file.
proposals_countint

Event 4: retrospective.skipped

HiC-only. Autonomous mode emitting this event MUST be rejected by the gate as a silent-skip attempt.

Payload fieldTypeNotes
record_pathstringPath to the persisted skip record (yaml + this event are both required, FR-010).
skip_reasonstringOperator-supplied; non-empty.
skipped_byActorRefOperator.

Event 5: retrospective.failed

Emitted when the retrospective could not be produced.

Payload fieldTypeNotes
failure_codeenum (see RetrospectiveFailure.code)
messagestringHuman-readable.
record_pathstring \null

Event 6: retrospective.proposal.generated

One per proposal at retrospective write time.

Payload fieldTypeNotes
proposal_idULIDStable across the proposal's lifecycle.
kindproposal kind enumSee retrospective_yaml_v1.md.
record_pathstringPath to the retrospective record this proposal lives in.

Event 7: retrospective.proposal.applied

Emitted by the synthesizer when a proposal is materialized into doctrine/DRG/glossary state.

Payload fieldTypeNotes
proposal_idULID
kindproposal kind enum
target_urnstringThe artifact / edge / term that was created or modified.
provenance_refstringURN-shape reference to the provenance metadata written alongside.
applied_byActorRefThe operator who approved (for non-auto kinds) or runtime (for flag_not_helpful).

Event 8: retrospective.proposal.rejected

Two distinct origins; same event name, distinguished by rejected_by + reason:

  • Human rejection: operator declines the proposal; state.status becomes rejected (terminal).
  • Apply-time rejection: synthesizer attempted apply but conflict / staleness / invalidity blocked it. state.status stays accepted; apply_attempts records the attempt.
Payload fieldTypeNotes
proposal_idULID
kindproposal kind enum
reasonenum: human_decline, conflict, stale_evidence, invalid_payload
detailstringFree-form context.
rejected_byActorRefOperator (human_decline) or runtime (others).

Reducer surface

After this contract is implemented, materialize(feature_dir) includes:

class StatusSnapshot(BaseModel):
    # ... existing fields ...
    retrospective: RetrospectiveSnapshot | None

class RetrospectiveSnapshot(BaseModel):
    status: Literal["completed", "skipped", "failed", "pending", "absent"]
    mode: Mode | None
    record_path: str | None
    proposals_total: int
    proposals_applied: int
    proposals_rejected: int
    proposals_pending: int

absent is reserved for missions that have not yet emitted any retrospective event (legacy + in-flight + terminus_no_retrospective; the cross-mission summary distinguishes them, but the per-mission snapshot reports the unified absent).


Append-only invariant

NFR-005: no operation MUST mutate or delete a previously persisted retrospective event. Re-runs append additional events. The reducer treats the latest completed/skipped/failed (by at + event_id) as the authoritative status while preserving prior history.


Cutover note (Q4-C)

Until the upstream spec_kitty_events release ships:

# in specify_cli.retrospective.events:
from pydantic import BaseModel
# ... local definitions matching this contract ...

Post-upstream:

# in specify_cli.retrospective.events (deleted after cutover):
from spec_kitty_events.retrospective import (
    Requested, Started, Completed, Skipped, Failed,
    ProposalGenerated, ProposalApplied, ProposalRejected,
)

The contract document does not change; only the import does.

retrospective_yaml_v1.md

Contract: retrospective.yaml schema v1

Status: pinned for this tranche. Source of truth: specify_cli.retrospective.schema (Pydantic v2 models). Mirrored from: ../data-model.md.

This contract is normative. Any change requires a schema_version bump and a documented compatibility shim.


Canonical path

.kittify/missions/<mission_id>/retrospective.yaml

<mission_id> is the canonical ULID from the mission's meta.json. The display-only mission_number MUST NOT appear in the path. (Spec FR-009, C-014.)


Top-level YAML shape

schema_version: "1"
mission:
  mission_id: 01KQ6YEGT4YBZ3GZF7X680KQ3V
  mid8: 01KQ6YEG
  mission_slug: mission-retrospective-learning-loop-01KQ6YEG
  mission_type: software-dev
  mission_started_at: 2026-04-27T07:46:18.715532+00:00
  mission_completed_at: 2026-04-27T11:00:00+00:00
mode:
  value: human_in_command
  source_signal:
    kind: charter_override
    evidence: "charter:mode-policy:hic-default"
status: completed              # completed | skipped | failed | pending(*)
started_at: 2026-04-27T10:55:00+00:00
completed_at: 2026-04-27T11:00:00+00:00
actor:
  kind: human
  id: rob@robshouse.net
  profile_id: null
helped: [...]                  # list of Finding (may be empty)
not_helpful: [...]             # list of Finding (may be empty)
gaps: [...]                    # list of Finding (may be empty)
proposals: [...]               # list of Proposal (may be empty)
provenance:
  authored_by: { kind: agent, id: claude-opus-4-7, profile_id: retrospective-facilitator }
  runtime_version: 3.2.0
  written_at: 2026-04-27T11:00:00+00:00
  schema_version: "1"
# Optional, status-conditional:
# skip_reason: "low-value docs fix"           # required iff status == skipped
# failure: { code: writer_io_error, ... }      # required iff status == failed
# successor_mission_id: null                   # set when this record is superseded

status: pending is not persistable. The writer refuses to materialize a pending record. (NFR-002.)


Finding shape

- id: F-01
  target:
    kind: drg_edge                # see allowed kinds below
    urn: "drg:edge:doctrine_directive_003->action_specify"
  note: "Directive 003 over-fired during research-only steps; surfaced no-op evidence."
  provenance:
    source_mission_id: 01KQ6YEGT4YBZ3GZF7X680KQ3V
    evidence_event_ids:
      - 01KQ6YE...A
      - 01KQ6YE...B
    actor:
      kind: agent
      id: claude-opus-4-7
      profile_id: retrospective-facilitator
    captured_at: 2026-04-27T10:58:00+00:00

Allowed target.kind values:

doctrine_directive | doctrine_tactic | doctrine_procedure
drg_edge | drg_node
glossary_term
prompt_template
test
context_artifact

provenance.evidence_event_ids MUST contain at least one entry. A mission that produced zero usable events MUST result in an empty helped/not_helpful/gaps list, not in synthetic evidence.


Proposal shape (envelope)

- id: 01KQ6YE...P1
  kind: add_glossary_term       # see allowed kinds below
  payload:
    # kind-specific; see "Proposal payload schemas" below
  rationale: "Term 'lifecycle terminus hook' was missing in 4/5 missions."
  state:
    status: pending             # pending | accepted | rejected | applied | superseded
    decided_at: null
    decided_by: null
    apply_attempts: []
  provenance:
    source_mission_id: 01KQ6YEGT4YBZ3GZF7X680KQ3V
    source_evidence_event_ids: [01KQ6YE...C]
    authored_by: { kind: agent, id: claude-opus-4-7, profile_id: retrospective-facilitator }
    approved_by: null

Allowed kind values (closed set; any unlisted future kind defaults to staged per Q2-A):

synthesize_directive | synthesize_tactic | synthesize_procedure
rewire_edge | add_edge | remove_edge
add_glossary_term | update_glossary_term
flag_not_helpful

Proposal payload schemas (per kind)

Pinned minimums. Implementations may add fields; they MUST NOT remove these.

synthesize_directive / synthesize_tactic / synthesize_procedure

payload:
  artifact_id: <directive_or_tactic_or_procedure_id>     # e.g. "DIRECTIVE_NEW_EXAMPLE"
  body: |
    <markdown body>
  body_hash: sha256:...                                  # normalized-body hash for conflict detection (R-006)
  scope:
    actions: [...]                                       # action ids this artifact applies to
    profiles: [...]                                      # profile ids this artifact applies to

add_edge / remove_edge

payload:
  edge:
    from_node: drg:node:<urn>
    to_node: drg:node:<urn>
    kind: <edge_kind>                                    # closed enum from src/doctrine/graph.yaml

rewire_edge

payload:
  edge_old:
    from_node: drg:node:<a>
    to_node: drg:node:<b>
    kind: <edge_kind>
  edge_new:
    from_node: drg:node:<a>
    to_node: drg:node:<c>
    kind: <edge_kind>

edge_old and edge_new MUST share from_node and kind. Otherwise this is a remove_edge + add_edge.

add_glossary_term / update_glossary_term

payload:
  term_key: lifecycle-terminus-hook
  definition: |
    <markdown definition>
  definition_hash: sha256:...
  related_terms: []

flag_not_helpful

payload:
  target:
    kind: <Target.kind>                                  # see Finding target kinds
    urn: <urn>

flag_not_helpful is the only auto-applicable kind (Q2-A, FR-020). Auto-application still records a ProposalApplyAttempt and writes provenance.


Required vs. optional fields (canonical list)

For an automated reader, the explicit lists:

Required at top level:

schema_version, mission, mode, status, started_at, actor,
helped, not_helpful, gaps, proposals, provenance

Optional at top level (status-conditional):

completed_at      # required iff status in (completed, skipped, failed)
skip_reason       # required iff status == skipped
failure           # required iff status == failed
successor_mission_id

Required on every Finding:

id, target, note, provenance

Required on every Proposal:

id, kind, payload, rationale, state, provenance

Forward-compatibility rules

  • Adding a field at any level is a non-breaking change; readers MUST ignore unknown fields silently.
  • Removing or renaming a required field is a breaking change; bump schema_version and ship a compatibility shim.
  • Adding a new proposal kind is a non-breaking change for readers; the synthesizer's auto-apply allowlist remains closed ({flag_not_helpful}) by default. (FR-020.)
  • Adding a new target.kind is a non-breaking change for readers; calibration updates may be needed.

Privacy

provenance.evidence_event_ids are opaque references to entries in the mission event log. The schema disallows embedding payload content from those events directly into the retrospective record. Redaction at the event-log layer therefore propagates without the retrospective record needing modification. (CHK019, Open Risk: privacy of evidence references.)

synthesizer_hook.md

Contract: Synthesizer Hook

Status: pinned for this tranche. Source of truth: specify_cli.doctrine.synthesizer (new subpackage). Caller: specify_cli.cli.commands.agent_retrospect (spec-kitty agent retrospect synthesize).

The synthesizer is the only path that mutates project-local doctrine, DRG, or glossary state from a retrospective finding. It does not auto-run. (FR-021.)


Public API

# specify_cli.doctrine.synthesizer

def apply_proposals(
    *,
    mission_id: MissionId,
    repo_root: Path,
    proposals: list[Proposal],
    approved_proposal_ids: set[ProposalId],
    actor: ActorRef,
    dry_run: bool = True,
) -> SynthesisResult: ...

Inputs

ArgumentTypeNotes
mission_idMissionIdSource mission for provenance.
repo_rootPathProject root; doctrine/DRG/glossary mutations are scoped to project-local state under this root.
proposalslist[Proposal]Full proposal list from the source retrospective record.
approved_proposal_idsset[ProposalId]Subset to attempt to apply. flag_not_helpful proposals are auto-included even if absent from this set.
actorActorRefWho approved the application. Recorded in provenance.
dry_runboolDefault True. When True, the synthesizer plans application, runs conflict detection, and returns a result without mutating any file. When False, application is performed.

Output

class SynthesisResult(BaseModel):
    dry_run: bool
    planned: list[PlannedApplication]      # always populated
    applied: list[AppliedChange]           # populated only when dry_run is False
    conflicts: list[ConflictGroup]         # may be non-empty in either mode
    rejected: list[RejectedProposal]       # apply-time rejections (stale evidence, invalid payload)
    events_emitted: list[EventId]          # the EventIds of `retrospective.proposal.{applied,rejected}` events written
class PlannedApplication(BaseModel):
    proposal_id: ProposalId
    kind: str                              # proposal kind
    targets: list[str]                     # URNs that will be created or modified
    diff_preview: str                      # short human-readable description; used by --dry-run report

class AppliedChange(BaseModel):
    proposal_id: ProposalId
    target_urn: str
    artifact_path: str                     # path on disk that was modified
    provenance_path: str                   # path to provenance metadata sidecar (FR-022)

class ConflictGroup(BaseModel):
    proposal_ids: list[ProposalId]
    reason: str                            # describes the conflict (per R-006 predicates)

class RejectedProposal(BaseModel):
    proposal_id: ProposalId
    reason: Literal["conflict", "stale_evidence", "invalid_payload"]
    detail: str

Behavior

1. Conflict detection (always runs, even in dry_run)

The synthesizer applies the conflict predicates from research.md R-006 across the set of approved proposals plus any flag_not_helpful proposals. If any conflict is found:

  • The entire batch fails closed. (FR-023.)
  • SynthesisResult.conflicts enumerates the conflict groups.
  • SynthesisResult.applied is empty even if dry_run is False.
  • The synthesizer emits one retrospective.proposal.rejected event per proposal in a conflict group, with reason: conflict.

2. Staleness check (always runs)

For every approved proposal, the synthesizer verifies that the evidence_event_ids referenced by the proposal's provenance still exist in the source mission's event log. If any referenced event id is unreachable, the proposal is rejected with reason: stale_evidence. The proposal stays in state.status == "accepted"; only the apply attempt is recorded.

3. Apply order (when dry_run is False)

After conflict + staleness checks pass:

1. Group proposals by target surface (doctrine, DRG, glossary). 2. Within each group, apply in a deterministic order (sorted by proposal_id). 3. After each apply, write a provenance metadata sidecar (see "Provenance" below). 4. Emit a retrospective.proposal.applied event. 5. If a single proposal's apply itself fails (e.g., file write error), the synthesizer halts the remaining batch, records the failure as RejectedProposal(reason="invalid_payload", detail=...), and returns. Already-applied changes are not rolled back; the synthesizer is forward-only and designed to be idempotent on retry. (See "Idempotency" below.)

4. Idempotency

Re-running the synthesizer with the same approved proposal ids on the same project state MUST be safe. The synthesizer detects "already applied" by inspecting provenance sidecars: if a sidecar with (source_mission_id, proposal_id) already exists at the target artifact, the synthesizer treats the proposal as already-applied, emits no new event, and reports it under applied with a re_applied: true marker (see provenance below).

5. flag_not_helpful auto-application

flag_not_helpful proposals are auto-applicable. They are processed in the same batch as approved proposals, but they do NOT require explicit approved_proposal_ids membership. They still:

  • run through conflict detection (none currently conflict; reserved for future);
  • run through staleness check;
  • write provenance;
  • emit retrospective.proposal.applied.

6. What the synthesizer does not do

  • It does not change runtime behavior at apply time. Applying a flag_not_helpful annotates a doctrine artifact with a "flagged" provenance entry; it does not unilaterally remove the artifact. (Removal would be a separate remove_edge proposal.)
  • It does not introduce prompt-builder filtering. (C-011.)
  • It does not modify any artifact outside the project's local scope (src/doctrine/graph.yaml, project-local graph overlays, project-local glossary state under .kittify/). It does not modify the global, packaged DRG/glossary that ships with spec-kitty.

Provenance (FR-022, NFR-006)

For every applied change, the synthesizer writes a sidecar provenance record colocated with the target artifact's storage:

# in .kittify/<surface>/.provenance/<artifact-id>.yaml (or analogous per surface)
artifact_id: <artifact-id>
source: retrospective
source_mission_id: 01KQ6YEGT4YBZ3GZF7X680KQ3V
source_proposal_id: 01KQ6YE...P1
source_evidence_event_ids: [01KQ6YE...A, 01KQ6YE...B]
applied_by:
  kind: human
  id: rob@robshouse.net
  profile_id: null
applied_at: 2026-04-27T11:30:00+00:00
re_applied: false

Reversibility (C-012): the provenance sidecar contains enough context to roll back via the inverse proposal (add_edgeremove_edge, etc.). Rollback is a future operator action, not part of this tranche; the contract here ensures the data needed for rollback is captured.


Caller contract

spec-kitty agent retrospect synthesize MUST:

1. Resolve the mission via --mission <handle> (mission_id / mid8 / mission_slug). 2. Load the retrospective record from the canonical path. 3. Display proposals in state.status == "accepted" plus auto-applicable flag_not_helpful proposals. 4. Default to --dry-run. Operator must pass --apply to mutate. 5. Pass results through to apply_proposals(...). 6. Print SynthesisResult as Rich + JSON (informational equivalence; see CLI contract). 7. Exit non-zero if conflicts or rejected is non-empty AND --apply was passed.