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
generateand an optional - KD-3 (R): Adapter exposes
idandversionas 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 (matchesDirective.idregex^[A-Z][A-Z0-9_-]$, typicallyPROJECT_<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 (seesrc/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 useDIRECTIVE_<NNN>, project-local usesPROJECT_<NNN>), so resolver falls to step 1.2 and regenerates every synthesized artifact whose provenance referencesdirective: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-philosophylanguage-scopeneutrality-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.
candidatesis bounded to the top 5 nearest matches by Levenshtein distance across all three forms.candidatesis always present and may be empty ([]) when no reasonable suggestion exists.attempted_formsfaithfully 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
ResolvedTopicrecord 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
targetsinto the same stage → validate → promote machinery used by full synthesis, withrun_idtagged 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 provenancesource_urnscontains 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 provenancesource_sectionequals 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--allin a later tranche, not by selector syntax. - No negation:
!tactic:premortem-risk-identificationis 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.