Contracts

charter-generate-json-diagnostics.md

Contract — spec-kitty charter generate --json (additive change)

Backed by: FR-004 (mission charter-generation-drops-scoped-references-01M3M1KF, issue #5257)

Status

This is an additive, backward-compatible change to an existing, already-shipped JSON contract (src/specify_cli/cli/commands/charter/generate.py's --json emit block). No existing key is removed, renamed, or changed in type. A new top-level key is added.

Before (existing shape, unchanged fields)

{
  "result": "success",
  "success": true,
  "charter_path": "...",
  "interview_source": "...",
  "mission": "...",
  "template_set": "...",
  "selected_paradigms": [...],
  "selected_directives": [...],
  "available_tools": [...],
  "references_count": 42,
  "library_files": [...],
  "files_written": [...],
  "diagnostics": ["Unresolved reference: styleguide/java-conventions (scope_filtered): ..."]
}

diagnostics remains a flat list[str] — every existing consumer that treats it as such continues to work unchanged. Per-line content is extended (FR-002) to carry a (cause) suffix and a short detail, but the field's own type and key name never change.

After (new field, additive)

{
  "...": "... all fields above, unchanged ...",
  "unresolved_references": [
    {
      "kind": "styleguide",
      "id": "java-conventions",
      "cause": "scope_filtered",
      "detail": "artifact 'java-conventions' is present but its applies_to_languages scope does not include the active language set ('python')."
    }
  ]
}

unresolved DRG-backed references) — matching diagnostics's own always-present convention, so a consumer can rely on key presence rather than using .get() with a default. It is also present on the whole-kind fail-closed error payload (see below).

directive, tactic, styleguide, toolguide, procedure, agent_profile. The unattributable and graph-load entries below use a reserved _-prefixed sentinel, or (for an unrecognized graph.unresolved prefix) the raw prefix verbatim; every other value is a singular ArtifactKind value.

.kittify/config.yaml, not the CharterReference.id field's KIND:id form).

.value string. That enum has four members; this call site emits three of them: "missing_artifact", "typo_suspected", or "scope_filtered". ("schema_validation_suspected" is reserved for callers with ground-truth loader-drop knowledge, which this call site does not have — see _catalog_miss.py's own docstring — so it does not appear here.) The sentinel entries below add three plain-string causes of their own: graph_load_failed, malformed_urn, unattributed_kind.

already produces (unchanged). The other causes have no suggestion text of their own, so detail is readable default advice matching what charter context prints for the same miss: missing_artifact → "no artifact with this id in any doctrine layer (project, org, built-in); run spec-kitty doctrine validate to rule out a silent schema-validation drop"; typo_suspected → "did you mean '<closest id>'?".

  • unresolved_references is always present on the success payload (empty list [] when there are no
  • kind is, for a per-artifact miss, one of the DRG-backed kinds _render_kind_references covers:
  • id is the bare activated id (no KIND: prefix — matches the raw id as configured in
  • cause is, for a per-artifact miss, the charter.activation._catalog_miss.CatalogMissCause enum's
  • detail is never empty. For scope_filtered it is the suggestion text classify_scope_filtered_miss

Fail-closed error payload

When every activated reference of one tracked kind is unresolvable, compile_charter raises WholeKindUnresolvedError and charter generate exits non-zero without writing a catalog. With --json the payload is the command's existing error envelope plus the same structured records:

{
  "result": "error",
  "success": false,
  "error": "every activated 'toolguide' reference is unresolvable (<ids>); refusing to write a silently-empty catalog section.",
  "unresolved_references": [{"kind": "toolguide", "id": "<id>", "cause": "missing_artifact", "detail": "..."}]
}

unresolved_references on this path lists every record collected before the check ran (not only the tripping kind's), in the same entry shapes as the success payload. Other error exits (not a git repository, symlinked charter.md, …) do not carry the key.

Why additive-alongside, not widening diagnostics entries into objects

Any existing consumer (CI scripts, readiness probes, _finalize_sync_result's own sync-warning concatenation, which appends plain strings into the same diagnostics list) that treats diagnostics as list[str] keeps working unchanged. Widening the existing field's element type to str | dict would be a breaking shape change for every such consumer, with no compile-time signal in a dynamically-typed JSON contract to catch the break. A new, always-present key is additive and detectable ("unresolved_references" in payload) without touching the existing key's shape at all.

Round-3 addition — graph-load-failure diagnostic entry (operator ruling on PLAN-FRESH2-001)

unresolved_references gains one additional, distinct entry shape for the case where the WHOLE DRG graph fails to load (charter.activation.compiler._resolve_transitive_reference_graph's except Exception: return fallback branch — see plan.md's "WP-CORE reconciliation" point 3, round-3 amendment, part (c)). This is not a per-artifact miss — no per-kind breakdown exists to attribute it to a specific kind or id — so it uses reserved sentinel values instead of a real kind/id pair:

{
  "kind": "_graph",
  "id": "_load_failure",
  "cause": "graph_load_failed",
  "detail": "<short exception summary>"
}

distinct from any real DRG-backed kind/id — no built-in kind or activated id is ever literally named _graph/_load_failure), so a consumer can specifically detect this entry (or filter it out) without confusing it for a per-artifact miss.

values documented above (missing_artifact, typo_suspected, scope_filtered) — this entry is not per-id classified via classify_catalog_miss/ classify_scope_filtered_miss at all.

CatalogMissCause enum type** (closes PLAN-FRESH3-004). CatalogMissCause (src/charter/activation/_catalog_miss.py:129-161) is a closed 4-member str, Enum with no "graph_load_failed" member, and CatalogMissDiagnosis.cause (_catalog_miss.py:164-174) is strictly typed CatalogMissCause, not str. Typing the new structured-records sink's cause field as str lets the three real CatalogMissCause.value strings (produced for the per-artifact-miss entries above) and this new sentinel value coexist in the same JSON field without adding a member to CatalogMissCause — _catalog_miss.py stays REUSED, NOT MODIFIED (see plan.md's Project Structure section and the "binding mechanism decisions retained from prior rounds" list under "WP-CORE reconciliation" point 3).

suggestion — there is no id to classify).

PLAN-FRESH2-001 is that a total graph-load failure is loud and structured, not fail-closed; it also does NOT participate in the whole-kind aggregate fail-closed count (plan.md's round-3 amendment, part (c), explains why: the aggregate check counts per-kind ids that were actually classified, and a total graph-load failure has no per-kind breakdown to classify).

(FR-002's reason-bearing convention applies to this diagnostic as well, e.g. "Graph load failed: <exception summary>. Transitive closure not resolved; direct-root ids only.").

  • kind: "_graph" / id: "_load_failure" are reserved sentinel values (leading underscore,
  • cause: "graph_load_failed" is a new value in this field, distinct from the CatalogMissCause
  • **Type: cause is a plain str on the compiler-internal structured record, NOT the
  • detail is a short summary of the underlying exception (not a _catalog_miss classifier
  • This entry does NOT cause generate to exit non-zero. The operator's explicit ruling on
  • The same diagnostic text appears as a plain-string entry in diagnostics: list[str] too

Round-5 addition — unattributable graph.unresolved entries (closes PLAN-FRESH4-003, invariant I3)

unresolved_references gains four further entry shapes for graph.unresolved URNs that cannot be attributed to one of the six DRG-backed kinds _render_kind_references tracks (directive/tactic/ styleguide/toolguide/procedure/agent_profile). Per plan.md's invariant I3 ("every structured diagnostic record carries a defined kind, id and cause"), each of these still gets a real entry — never silently dropped:

1. No ":" in the URN, or nothing after it (malformed). ``json {"kind": "_unattributed", "id": "<the full URN string>", "cause": "malformed_urn", "detail": "malformed URN, no kind prefix"} ` detail is "malformed URN, no artifact id" instead when the ":" is present but the bare id after it is empty (e.g. tactic:). kind: "_unattributed" is a reserved sentinel (same leading-underscore convention as _graph/ _load_failure` above), used only when no kind could be parsed from the URN at all. (Items 2-4 below carry a real or raw kind, not this sentinel.)

2. kind_prefix has no ArtifactKind counterpart (ArtifactKind(kind_prefix) raises ValueError — e.g. action, glossary_scope, glossary, mission_type, or any other unrecognized string). ``json {"kind": "<the raw kind_prefix, verbatim>", "id": "<the bare id after the \":\" split>", "cause": "unattributed_kind", "detail": "unrecognized artifact kind: <kind_prefix>"} ``

3. kind_prefix is a valid ArtifactKind whose repository genuinely resolves to None (template, asset, anti_pattern — whose repository is None by design, per DoctrineService.raw_repository(kind)'s documented behavior for kinds outside the nine gated raw-repository kinds). ``json {"kind": "<the ArtifactKind's singular value, verbatim>", "id": "<the bare id>", "cause": "unattributed_kind", "detail": "no repository for kind: <kind_prefix>"} ``

4. kind_prefix is a valid ArtifactKind that DOES resolve to a real, non-None repository, but whose plural is outside the six kinds _render_kind_references tracks (a valid-but-untracked kind such as paradigm, mission_step_contract, glossary_pack — round-6 split, closes PLAN-FRESH5-003: the detail text must not claim "no repository" for a kind that has one). ``json {"kind": "<the ArtifactKind's singular value, verbatim>", "id": "<the bare id>", "cause": "unattributed_kind", "detail": "kind '<kind_prefix>' is not one of the six DRG-backed kinds tracked for reference resolution"} ``

Both new cause values (malformed_urn, unattributed_kind) are plain str, distinct from the CatalogMissCause values and from "graph_load_failed" — same typing rule as the round-3 addition above (_catalog_miss.py stays REUSED, NOT MODIFIED). detail in all four cases is a short human-readable string explaining why the entry could not be attributed (not a _catalog_miss classifier suggestion — there is nothing to classify); items 3 and 4 share the cause value "unattributed_kind" but carry distinct, accurate detail text for their distinct root causes. None of these four entries participates in the six-kind whole-kind aggregate fail-closed count (plan.md's I2) — same exemption reasoning as the graph-load-failure entry: there is no per-kind bucket among the six tracked kinds to honestly attribute them to.

Relationship to CharterPreflightResult

Not related. This contract governs charter generate --json's own stdout payload. The preflight contract (contracts/charter-preflight-json.md in kitty-specs/charter-ux-and-org-pack-vocabulary-01KSAF14/) governs a structurally different, unrelated command (spec-kitty charter preflight) and is not modified by this mission — see plan.md's Charter Check section.