Contracts

adapter.py

""" Frozen contract: Charter Synthesizer Adapter Seam.

This file is a PLANNING ARTIFACT, not production code. It lives under kitty-specs/<mission>/contracts/ so reviewers can lock the exact shape of the seam before WP3.1 implementation begins. Changes to this contract after WP3.1 lands require an ADR amendment (per KD-6 and DIRECTIVE_003).

Implementation target: src/charter/synthesizer/adapter.py

Key decisions locked here (see plan.md §Key Decisions):

generate_batch. No asyncio in this tranche.

may carry per-call overrides. Orchestration uses override-first with adapter-attribute fallback.

Protocol itself does NOT specify fixture behavior; that is a detail of the FixtureAdapter implementation. """

  • KD-3: Synchronous Protocol with a mandatory generate and an optional
  • KD-3 (R): Adapter exposes id and version as attributes. AdapterOutput
  • KD-4: The fixture adapter keys fixtures by normalized-request hash. The

from __future__ import annotations

from dataclasses import dataclass from datetime import datetime from typing import Any, Protocol, runtime_checkable from collections.abc import Mapping, Sequence

-- Inputs / Outputs -----------------------------------------------------

@dataclass(frozen=True) class SynthesisTarget: """One unit of synthesis. See data-model.md §E-2."""

kind: str # Literal["directive", "tactic", "styleguide"] slug: str source_section: str | None source_urns: tuple[str, ...] title: str

@dataclass(frozen=True) class SynthesisRequest: """Input envelope handed to a single generate call. See data-model.md §E-1."""

target: SynthesisTarget interview_snapshot: Mapping[str, Any] doctrine_snapshot: Mapping[str, Any] drg_snapshot: Mapping[str, Any] adapter_hints: Mapping[str, str] | None = None run_id: str = "" # ULID. Excluded from fixture-hash per R-0-6 rule 4. evidence: Any = None # EvidenceBundle | None — added by WP01 (charter phase 3)

@dataclass(frozen=True) class AdapterOutput: """What generate returns. See data-model.md §E-3."""

body: Mapping[str, Any] generated_at: datetime # tz-aware, UTC adapter_id_override: str | None = None adapter_version_override: str | None = None notes: str | None = None

-- The seam -------------------------------------------------------------

@runtime_checkable class SynthesisAdapter(Protocol): """ The narrow seam between deterministic orchestration and model-driven generation.

Implementations MUST expose id and version attributes; these are the default provenance identity for every call. Implementations MAY optionally define generate_batch for efficiency; orchestration detects its presence via hasattr(adapter, "generate_batch") and uses it when available.

Implementations MUST be synchronous. No asyncio in this tranche (KD-3). """

id: str version: str

def generate(self, request: SynthesisRequest) -> AdapterOutput: """ Produce a single artifact body for the given request.

Must be pure w.r.t. its inputs modulo the adapter's own model backend: a fixture adapter MUST produce byte-identical output for byte-identical normalized input. Production adapters are not required to be byte-identical across calls, but MUST carry enough identity in AdapterOutput for provenance to be useful (overrides or adapter-level attributes). """ ...

class BatchCapableSynthesisAdapter(SynthesisAdapter, Protocol): """ Optional extension. Orchestration detects and uses generate_batch via hasattr rather than by type narrowing, so adapters do NOT need to declare they implement this Protocol — they just need a compatible method. This stub exists for documentation / static-analysis aid only. """

def generate_batch( self, requests: Sequence[SynthesisRequest] ) -> Sequence[AdapterOutput]: """ Produce outputs for a batch of requests. Returned sequence MUST be the same length as the input sequence and element-aligned. If an adapter cannot satisfy any one request, it MUST raise rather than returning a partial sequence (orchestration will fall back to per-request generate for recovery if it chooses). """ ...

-- Test-only FixtureAdapter exception (declared here for contract clarity) --

@dataclass(frozen=True) class FixtureAdapterMissingError(Exception): """ Raised by the test-only FixtureAdapter when no recorded fixture matches the normalized request hash. The CLI fixture-opt-in path (--adapter fixture) surfaces this as a user-facing error with the expected path so operators can record a new fixture. """

expected_path: str kind: str slug: str inputs_hash: str

def __str__(self) -> str: return ( f"no fixture found for {self.kind}:{self.slug} " f"(inputs_hash={self.inputs_hash}); expected at {self.expected_path}" )

provenance.schema.yaml

$schema: "https://json-schema.org/draft/2020-12/schema" $id: "spec-kitty://charter/synthesizer/provenance/v1" title: "ProvenanceEntry (Charter Synthesizer) v1" description: > Per-artifact provenance sidecar for a synthesized charter artifact. One file per artifact at .kittify/charter/provenance/<kind>-<slug>.yaml (synthesis BOOKKEEPING tree). The artifact the sidecar describes lives under the synthesized DOCTRINE tree at .kittify/doctrine/<kind-dir>/ with a filename matching the existing repository glob for that kind (.directive.yaml, .tactic.yaml, *.styleguide.yaml). Cross-reference: data-model.md §E-4. type: object additionalProperties: false required:

properties: schema_version: type: string const: "1" artifact_urn: type: string pattern: "^(directive|tactic|styleguide):[a-z][a-z0-9-]$" artifact_kind: type: string enum: [directive, tactic, styleguide] artifact_slug: type: string pattern: "^[a-z][a-z0-9-]$" artifact_content_hash: type: string pattern: "^[0-9a-f]{64}$" description: "blake3-256 hex digest of the committed artifact YAML bytes." inputs_hash: type: string pattern: "^[0-9a-f]{64}$" description: "blake3-256 hex digest of the normalized SynthesisRequest." adapter_id: type: string minLength: 1 adapter_version: type: string minLength: 1 source_section: type: [string, "null"] description: "Interview section label this artifact derives from (if any)." source_urns: type: array items: type: string pattern: "^[a-z_]+:[A-Za-z0-9_.-]+$" description: "DRG URNs this artifact derives from. May be empty only if source_section is set." generated_at: type: string format: date-time description: "ISO 8601 UTC timestamp from AdapterOutput.generated_at." adapter_notes: type: [string, "null"] description: "Verbatim copy of AdapterOutput.notes; never used for validation." allOf:

anyOf:

properties: source_section: type: string minLength: 1

source_urns: type: array minItems: 1

  • schema_version
  • artifact_urn
  • artifact_kind
  • artifact_slug
  • artifact_content_hash
  • inputs_hash
  • adapter_id
  • adapter_version
  • source_urns
  • generated_at
  • description: "At least one of source_section or source_urns must be non-empty."
  • required: [source_section]
  • properties:

synthesis-manifest.schema.yaml

$schema: "https://json-schema.org/draft/2020-12/schema" $id: "spec-kitty://charter/synthesizer/manifest/v1" title: "SynthesisManifest (Charter Synthesizer) v1" description: > Authoritative commit marker for a synthesis run. Written last in the stage→promote pipeline (KD-2). The manifest itself lives under the synthesis BOOKKEEPING tree (.kittify/charter/) but lists content paths under the synthesized DOCTRINE tree (.kittify/doctrine/) — it is the explicit bridge between the two trees. The live tree is authoritative only when this file is present AND all listed content_hash values match on-disk artifact bytes at their .kittify/doctrine/ paths. Location: .kittify/charter/synthesis-manifest.yaml Cross-reference: data-model.md §E-6. type: object additionalProperties: false required:

properties: schema_version: type: string const: "1" mission_id: type: [string, "null"] description: "Optional: mission that ran the synthesis. ULID." created_at: type: string format: date-time run_id: type: string description: "ULID. Matches the staging dir under .kittify/charter/.staging/." adapter_id: type: string description: > Primary adapter id for the run. May be empty string for mixed-identity runs (per-artifact provenance is authoritative in that case). adapter_version: type: string description: > Primary adapter version for the run. May be empty string for mixed-identity runs. artifacts: type: array items: $ref: "#/$defs/ManifestArtifactEntry" $defs: ManifestArtifactEntry: type: object additionalProperties: false required:

properties: kind: type: string enum: [directive, tactic, styleguide] slug: type: string pattern: "^[a-z][a-z0-9-]*$" path: type: string description: > Repo-relative path to the synthesized artifact YAML under the .kittify/doctrine/ tree. Filename matches the existing repository glob for the artifact kind: directive → .kittify/doctrine/directives/<NNN>-<slug>.directive.yaml tactic → .kittify/doctrine/tactics/<slug>.tactic.yaml styleguide → .kittify/doctrine/styleguides/<slug>.styleguide.yaml provenance_path: type: string description: > Repo-relative path to the provenance sidecar under the .kittify/charter/ tree: .kittify/charter/provenance/<kind>-<slug>.yaml content_hash: type: string pattern: "^[0-9a-f]{64}$" description: "blake3-256 hex over the emitted artifact YAML bytes."

  • schema_version
  • created_at
  • run_id
  • adapter_id
  • adapter_version
  • artifacts
  • kind
  • slug
  • path
  • provenance_path
  • content_hash

topic-selector.md

Contract — --topic Selector Grammar and Error Shape

Mission: phase-3-charter-synthesizer-pipeline-01KPE222 Surface: spec-kitty charter resynthesize --topic <selector> (FR-011) Data model ref: data-model.md §E-7 / §E-8 Spec refs: FR-012, FR-013, US-6, SC-008, C-004

This contract freezes (a) the grammar of structured --topic selectors and (b) the structured-error shape that surfaces when a selector cannot resolve. Both are user-facing. Changes require an ADR amendment.


1. Grammar

A selector is a single string argument. Free-text selectors are rejected (C-004).

Three forms, with a local-first resolution order for synthesizable artifact kinds (directive / tactic / styleguide).

1.1 Artifact kind + slug (project-local, synthesizable kinds)

<artifact-kind>:<slug>
  • <artifact-kind>{"directive", "tactic", "styleguide"} (C-005 bound).
  • <slug> matches ^[a-z][a-z0-9-]$ for tactic / styleguide. For directive, <slug> is the directive artifact's canonical id (matches Directive.id regex ^[A-Z][A-Z0-9_-]$, typically PROJECT_<NNN>).
  • Matches only against the project-local artifact set (i.e. the committed .kittify/doctrine/<kind-dir>/ content for this project).

When the LHS is one of the three synthesizable artifact kinds and a project-local artifact with that id exists, this form wins — before any DRG URN interpretation. This is the rule that makes tactic:how-we-apply-directive-003 route to the project artifact the operator actually wants to regenerate, rather than being misinterpreted as a DRG URN lookup.

Examples:

  • directive:PROJECT_001 (project-local synthesized directive)
  • tactic:how-we-apply-directive-003 (project-local synthesized tactic)
  • styleguide:python-testing-style (project-local synthesized styleguide)

1.2 DRG URN

<node-kind>:<identifier>
  • <node-kind> ∈ known DRG node kinds (see src/doctrine/drg/models.py :: NodeKind) — e.g. directive, tactic, paradigm, styleguide, toolguide, procedure, agent_profile, action, glossary_scope.
  • <identifier> matches ^[A-Za-z0-9_.-]+$.
  • Resolved against the merged shipped + project DRG graph.
  • Used when: (a) the LHS is a DRG node kind that is NOT in the synthesizable set (e.g. paradigm, procedure), OR (b) the LHS is in the synthesizable set but step 1.1 did not hit a project-local artifact (i.e. the operator is asking to regenerate every project-local artifact whose provenance references this shipped URN).

Examples:

  • directive:DIRECTIVE_003 — shipped directive URN; matches no project-local directive (shipped IDs use DIRECTIVE_<NNN>, project-local uses PROJECT_<NNN>), so resolver falls to step 1.2 and regenerates every synthesized artifact whose provenance references directive:DIRECTIVE_003.
  • paradigm:evidence-first — shipped paradigm; directly a DRG URN.

1.3 Interview section label

<section-label>
  • Must match an entry in the known interview-section label set (maintained alongside src/charter/interview.py).
  • Case-sensitive, exact match.
  • No colon → this form is tried only when forms 1.1 and 1.2 cannot apply (because they require a :).

Examples:

  • testing-philosophy
  • language-scope
  • neutrality-posture

1.4 Resolution order

1. If the string contains : AND LHS ∈ {"directive","tactic","styleguide"}, attempt 1.1 (project-local artifact set lookup). Hit → resolve, done. 2. If the string contains :, attempt 1.2 (DRG URN against the merged shipped+project graph). Hit → resolve, done. 3. If the string contains no :, attempt 1.3 (interview section label). Hit → resolve, done. 4. No hit in any form → TopicSelectorUnresolvedError (see §2).

No step is skipped. No silent fallback (FR-013).

This ordering is the critical correctness property: operators reading their own project doctrine and typing tactic:<slug> for an artifact they can see on disk under .kittify/doctrine/tactics/<slug>.tactic.yaml must always resolve to that artifact — regardless of whether a shipped DRG node happens to carry the same URN shape.


2. Error shape

All resolver errors carry structured fields. CLI renders them via rich panels; machine consumers (tests, JSON output) can use the structured form.

2.1 TopicSelectorUnresolvedError

Raised when steps 1–3 all fail.

error_kind: topic_selector_unresolved
raw: "<user-supplied topic string>"
attempted_forms:
  - kind_slug          # included only if string contained ":" AND LHS was synthesizable
  - drg_urn            # included only if string contained ":"
  - interview_section  # included only if string contained no ":"
candidates:
  - kind: kind_slug
    value: "tactic:how-we-apply-directive-003"
    distance: 2
  - kind: drg_urn
    value: "directive:DIRECTIVE_003"
    distance: 3
  - kind: interview_section
    value: "testing-philosophy"
    distance: 4
remediation: >
  Use one of the enumerated candidates, or run
  `spec-kitty charter resynthesize --list-topics` to see all valid selectors.
  • candidates is bounded to the top 5 nearest matches by Levenshtein distance across all three forms.
  • candidates is always present and may be empty ([]) when no reasonable suggestion exists.
  • attempted_forms faithfully records which forms were tried, for debuggability.

2.2 Exit code + CLI surface

  • Exit code 2 (invalid usage).
  • Rendered panel title: Cannot resolve --topic "<raw>".
  • No files are written. No model calls occur. No staging directory is created.

2.3 Observable SLA (SC-008)

From invocation to structured-error return: < 2 seconds on a cold cache. Verified by tests/charter/synthesizer/test_topic_resolver.py::test_unresolved_sla.


3. Success semantics

On a successful resolution:

  • The resolver returns a ResolvedTopic record carrying:
  • targets: list[SynthesisTarget] — the bounded slice to regenerate.
  • matched_form: Literal["kind_slug","drg_urn","interview_section"].
  • matched_value: str — normalized form of the successful selector.
  • Orchestration pipes targets into the same stage → validate → promote machinery used by full synthesis, with run_id tagged as a resynthesis run.
  • The updated manifest rewrites only the entries for regenerated artifacts; other entries retain prior content_hash (FR-017).

3.1 Target expansion rules per form

  • kind_slug (project-local hit): targets = [the matched artifact]. One artifact regenerated.
  • drg_urn: targets = every project-local artifact whose provenance source_urns contains the URN. Zero artifacts → the resolver returns a structured "no-op with diagnostic" result (EC-4); no writes, no model call.
  • interview_section: targets = every project-local artifact whose provenance source_section equals the section label.

4. Non-goals (repeated from spec for clarity)

  • No free-text: "tighten security wording" is rejected outright (C-004).
  • No globs/regex: directive:* is not valid. Per-run batch is captured by --all in a later tranche, not by selector syntax.
  • No negation: !tactic:premortem-risk-identification is not valid.

If operators want batch semantics, they run spec-kitty charter synthesize (full run). If they want a single slice, they use a structured selector.