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
| Exit | Meaning |
|---|---|
0 | Summary produced (even if some missions had no retrospective). |
1 | Project root invalid (no .kittify/ and no kitty-specs/). |
2 | I/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
| Exit | Meaning |
|---|---|
0 | Dry-run completed; or apply succeeded with no conflicts and no apply-time rejections. |
1 | Mission handle unresolvable (MISSION_AMBIGUOUS_SELECTOR or not found). |
2 | I/O error reading retrospective record. |
3 | Retrospective record malformed; refuse to operate. |
4 | Apply attempted with --apply but conflicts present; nothing applied. |
5 | Apply 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.yamlandkitty-specs//status.events.jsonl). - The fact that no mutation is performed.
The help body for agent retrospect synthesize MUST mention:
- That
--dry-runis the default and that--applyis required to mutate. - That
flag_not_helpfulis 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
| Argument | Type | Notes |
|---|---|---|
mission_id | MissionId (ULID) | Canonical identity. |
feature_dir | Path | Path to kitty-specs/<slug>/; used to read the mission event log. |
repo_root | Path | Used 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
| Mode | Latest retrospective event for mission | Decision |
|---|---|---|
autonomous | none | block, missing_completion_autonomous |
autonomous | retrospective.completed | allow, completed_present |
autonomous | retrospective.skipped | block, silent_skip_attempted |
autonomous | retrospective.failed | block, facilitator_failure |
autonomous | only requested/started | block, missing_completion_autonomous |
human_in_command | none | block, 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_command | retrospective.completed | allow, completed_present_hic |
human_in_command | retrospective.skipped | allow, skipped_permitted |
human_in_command | retrospective.failed | block, 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
Modevalue 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 field | Type | Notes |
|---|---|---|
event_id | ULID string | Stable, sortable. |
event_name | one of the eight names below | Discriminator. |
at | ISO-8601 UTC timestamp | Wall clock. |
actor | ActorRef (see data-model.md) | Who emitted. |
mission_id | MissionId (ULID) | Required for retrospective events. |
mid8 | Mid8 | Convenience for log scanners. |
mission_slug | string | Convenience. |
payload | event-specific Pydantic model | See 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 field | Type | Notes |
|---|---|---|
mode | Mode | Resolved mode + source signal. |
terminus_step_id | string | The mission step that hit terminus. |
requested_by | ActorRef | Runtime in autonomous; operator in HiC. |
Event 2: retrospective.started
Emitted when the facilitator dispatch begins (after requested and before any finding is captured).
| Payload field | Type | Notes |
|---|---|---|
facilitator_profile_id | string | e.g., retrospective-facilitator. |
action_id | string | e.g., retrospect. |
Event 3: retrospective.completed
Emitted after retrospective.yaml is persisted with status: completed.
| Payload field | Type | Notes |
|---|---|---|
record_path | string | Absolute path to the persisted retrospective.yaml. |
record_hash | string | SHA-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_count | int |
Event 4: retrospective.skipped
HiC-only. Autonomous mode emitting this event MUST be rejected by the gate as a silent-skip attempt.
| Payload field | Type | Notes |
|---|---|---|
record_path | string | Path to the persisted skip record (yaml + this event are both required, FR-010). |
skip_reason | string | Operator-supplied; non-empty. |
skipped_by | ActorRef | Operator. |
Event 5: retrospective.failed
Emitted when the retrospective could not be produced.
| Payload field | Type | Notes |
|---|---|---|
failure_code | enum (see RetrospectiveFailure.code) | |
message | string | Human-readable. |
record_path | string \ | null |
Event 6: retrospective.proposal.generated
One per proposal at retrospective write time.
| Payload field | Type | Notes |
|---|---|---|
proposal_id | ULID | Stable across the proposal's lifecycle. |
kind | proposal kind enum | See retrospective_yaml_v1.md. |
record_path | string | Path 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 field | Type | Notes |
|---|---|---|
proposal_id | ULID | |
kind | proposal kind enum | |
target_urn | string | The artifact / edge / term that was created or modified. |
provenance_ref | string | URN-shape reference to the provenance metadata written alongside. |
applied_by | ActorRef | The 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.statusbecomesrejected(terminal). - Apply-time rejection: synthesizer attempted apply but conflict / staleness / invalidity blocked it.
state.statusstaysaccepted;apply_attemptsrecords the attempt.
| Payload field | Type | Notes |
|---|---|---|
proposal_id | ULID | |
kind | proposal kind enum | |
reason | enum: human_decline, conflict, stale_evidence, invalid_payload | |
detail | string | Free-form context. |
rejected_by | ActorRef | Operator (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_versionand ship a compatibility shim. - Adding a new proposal
kindis 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.kindis 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
| Argument | Type | Notes |
|---|---|---|
mission_id | MissionId | Source mission for provenance. |
repo_root | Path | Project root; doctrine/DRG/glossary mutations are scoped to project-local state under this root. |
proposals | list[Proposal] | Full proposal list from the source retrospective record. |
approved_proposal_ids | set[ProposalId] | Subset to attempt to apply. flag_not_helpful proposals are auto-included even if absent from this set. |
actor | ActorRef | Who approved the application. Recorded in provenance. |
dry_run | bool | Default 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.conflictsenumerates the conflict groups.SynthesisResult.appliedis empty even ifdry_run is False.- The synthesizer emits one
retrospective.proposal.rejectedevent per proposal in a conflict group, withreason: 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_helpfulannotates a doctrine artifact with a "flagged" provenance entry; it does not unilaterally remove the artifact. (Removal would be a separateremove_edgeproposal.) - 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 withspec-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_edge ↔ remove_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.