Status: Draft
This document defines the read-only data model (the “read model”) that the markharness CLI exposes externally. Its primary consumer is markharness-view and similar display tools, to be implemented in a separate repository in the future.
This design does not bring screen structure or UI concerns into markharness. markharness produces a reproducible read model from Git, Knowledge, relations, and judgment results. impact, coverage (and the new traceability) each build a struct in their own dedicated module, serialized directly to JSON from cli.rs via serde_json (§8). None of these commands currently has human-readable output (a Human-Presenter equivalent); if one is added later, it is generated from the same struct.
Knowledge / Git / Binding
↓
Domain judgment
↓
CLI read model (a struct owned by its command's module)
↓
JSON (direct serde_json serialization)
↓
CLI / markharness-view
markharness v2 provides Change Impact and Release Coverage over CLI/JSON, leaving views and dashboards to a separate tool. The CLI’s JSON output is therefore not just a log; it is designed as an explicit output contract for external tools to read.
At the same time, a read model is not the same thing as a Domain model or a UI model.
This document covers the middle one, the CLI read model.
A Domain type is never serialized directly. Each command’s own module (impact, coverage, traceability) produces its read-model struct, which is then serialized via serde_json. If human-readable output is ever added, it too is generated from that same read-model struct, not from the Domain type.
This localizes the impact of Domain-internal changes to the external output contract.
The read model does not include screen tabs, selection state, expand/collapse state, sort state, paging state, or the like. Those are markharness-view’s responsibility.
All Knowledge is not crammed into one giant KnowledgeReadModel. Instead, several small read models are defined, each corresponding to a question a consumer actually asks.
The initial three targets are:
TraceabilityReadModelChangeImpactReadModelReleaseCoverageReadModelThe initial implementation does not persist read models as new source-of-truth files under .markharness/. They are generated from Knowledge, Git, Binding, and so on at command run time, and printed to stdout as JSON.
Caching or persistent derived artifacts, if needed, are treated as a separate design decision.
The presence of an ExecutionBinding is never presented as meaning a test has been run or has passed. ReleaseScope is likewise never represented as an execution plan or an execution result.
record_kind/schema_version is not a newly invented envelope shape. impact (ChangeImpact in src/impact.rs) and coverage (ReleaseCoverage in src/coverage.rs) already emit it as-is, never going through CommandOutcome/Presenter (src/presentation.rs) at all — cli.rs serializes each struct directly via serde_json, producing {"schema_version": 1, "record_kind": "<tag>", ...fields}. record_kind reuses the name already established by markharness-v2-design.md §9.1 and ADR 0025 for persistent records (execution_binding, release_scope, etc.), and impact/coverage’s CLI output already follows that convention; see ADR 0032.
This document only adds traceability, following that same existing pattern. canonical_imported/generated/changes_computed (markharness changes compute) use a different, unrelated output shape from the CommandOutcome/Presenter path (the outcome field) — unrelated write commands, out of scope for this document’s read models.
{
"record_kind": "change_impact",
"schema_version": 1
}
record_kindIdentifies the kind of record. The three read models’ values are (change_impact and release_coverage are already emitted by impact/coverage):
traceability
change_impact
release_coverage
impact --worktree emits change_impact_worktree. Its fields are identical to change_impact; head_commit is the fixed string "worktree" and rejected_trailers is always empty (ADR 0046).
schema_versionStarts at 1. This is not a compatibility mechanism for distinguishing past formats; it is an identifier that prevents confusing one record kind, or a future different model, with another.
If reading past formats is ever needed, that is handled by adding a separate record kind or a separate Reader, not a branch inside the existing model.
Provides the relations among Requirement, Feature, Behavior, Scenario, and TestCase in a form external tools can browse.
It reads the working tree (ADR 0043). Unlike impact/coverage, traceability has no two-point-comparison or release-auditing requirement, so nothing stops it from reading the working tree directly, the same way generate/verify do. To read a specific committed point, use coverage --at.
src/traceability.rs)struct TraceabilityReadModel {
schema_version: u32, // already emitted alongside "record_kind": "traceability"
record_kind: &'static str,
requirements: Vec<RequirementNode>,
features: Vec<FeatureNode>,
behaviors: Vec<BehaviorNode>,
scenarios: Vec<ScenarioNode>,
test_cases: Vec<TestCaseNode>,
relations: Vec<TraceabilityRelation>,
}
Each Node carries at least a display ID and a UID (the UID may be None for an element identity migrate hasn’t run on yet). TestCase additionally carries the Case revision and its generated path.
traceability’s purpose is to provide relations “in a form external tools can browse” (§5.1), and identifiers (*_id/*_uid) alone are not browsable. Each Node therefore also carries a human-readable label. Feature/Behavior/Scenario’s label is required in Knowledge, so it is never None (String). Requirement’s label exists only for source: native — unlike source_locator/source_key, it is never added for external, since markharness never owns external content (ADR 0023).
struct RequirementNode {
requirement_id: String,
requirement_uid: Option<String>,
source: &'static str, // "native" | "external"
// Present only for source: "native". Never given a representative text
// for "external", since markharness doesn't own that content (ADR 0023).
label: Option<String>,
// Present only when source is "external" (ADR 0023): a way to reach the
// actual StrictDoc (or similar) content. Always None for "native".
source_locator: Option<String>, // repo-relative path of the referenced .sdoc file
source_key: Option<String>, // StrictDoc's MID (ADR 0030)
// case_uid of every TestCase related to this Requirement, sorted. The rule is
// `generate::testcases_for_requirement`, shared with `coverage` (ADR 0042).
case_uids: Vec<String>,
}
struct FeatureNode {
feature_id: String,
feature_uid: Option<String>,
label: String,
}
struct BehaviorNode {
behavior_id: String,
// None until `identity migrate` runs (same as the other uid fields).
behavior_uid: Option<String>,
feature_id: String,
// None while the parent Feature has no uid (not migrated). A `*_id` is
// unique only within its parent, so the parent is also named by uid.
feature_uid: Option<String>,
label: String,
}
struct ScenarioNode {
scenario_id: String,
scenario_uid: Option<String>,
behavior_id: String,
behavior_uid: Option<String>, // None while the parent Behavior is not migrated
label: String,
}
struct TestCaseNode {
case_id: String, // display id; matches the existing term (TestCase.case_id) used elsewhere (generate.rs etc.)
case_uid: Option<CaseUid>,
case_revision: CaseRevision, // a hash-string type, not u64
relative_path: String,
scenario_id: String,
scenario_uid: Option<String>, // None while the parent Scenario is not migrated
}
Relations are expressed as explicit Relation entries rather than each Node duplicating an array of the other side.
enum RelationKind {
ContributesTo, // Feature or Scenario to Requirement
GeneratedFrom, // TestCase to Scenario
}
struct TraceabilityRelation {
from_uid: String,
to_uid: String,
kind: RelationKind,
}
An element with no UID (not yet migrated) never appears on either side of a relation: relating two UID-less values would give an external reader nothing to match against a later re-run.
Example:
{
"from_uid": "feature-uid-1",
"to_uid": "requirement-uid-1",
"kind": "contributes_to"
}
TraceabilityReadModel does not include Binding presence or Coverage judgment results; those are ReleaseCoverageReadModel’s responsibility.
requirements, features, and behaviors cover everything in Knowledge, regardless of whether a generated TestCase exists (the same reasoning as coverage’s AC21: a Feature with nothing underneath it yet, and a Behavior that has no Scenario yet, are still made visible). scenarios and test_cases are derived from every generated TestCase; since generate rejects a Scenario with empty phases at generation time, an existing Scenario always corresponds to exactly one TestCase, so this derivation cannot miss one. knowledge reconcile can create a Behavior that has no Scenario, so deriving behaviors from TestCases would leave an external tool unable to look up the behavior_uid it needs to add a Scenario to that Behavior later.
TraceabilityReadModel is read-only; it is not shaped so it can be written straight back to Knowledge files. To modify an existing element, a minimal Knowledge Intent is generated from the read result and passed to knowledge reconcile.
Existing elements are selected by uid.
format: markharness/knowledge-intent/v1
mode: merge
features:
- uid: 01J8Z...
id: todo-create
label: Create a TODO
contributes_to: [01J8A..., 01J8B...]
This Intent’s semantics are:
uid is treated as an update to the existing element.uid cross-references others via a local key inside the Intent.axis, contributes_to, and procedures are fully replaced, but only when specified.id is a rename that preserves the UID.mode: merge, existing elements not present in the Intent are not deleted.So when a GUI generates an editing Intent, it includes at least:
There’s no need to unconditionally expand all of Knowledge into the Intent. Omitting unchanged fields keeps the Intent small and avoids unintended updates.
Applying from a GUI follows this path:
TraceabilityReadModel
↓
Minimal Intent with the edited UID
↓
knowledge reconcile --check
↓
User confirmation
↓
knowledge reconcile
If Knowledge or identity state has changed since --check, the real run stops as a stale plan. The GUI re-fetches the latest read model, re-confirms the edit, and regenerates the Intent. A GUI or view must never write to Knowledge files directly.
traceability show (the content of one chosen element)Each Node in traceability carries only an identifier and a label, not content (axes, descriptions, steps). When content is needed, as in the GUI’s detail pane, call traceability show with the uid of the element chosen in the tree and read just that one element’s content (ADR 0040).
markharness traceability show --uid <UID>
It reads the working tree, the same as traceability.
The output is one JSON document: schema_version, record_kind (traceability_detail), kind, and uid, plus fields that depend on the element’s kind.
kind |
Fields |
|---|---|
requirement |
requirement_id, axis, description |
feature |
feature_id, axis, description |
behavior |
behavior_id, axis, description, procedures |
scenario |
scenario_id, description, phases, implementation_note |
test_case |
case_id, case_revision, phases |
null. A Requirement with source: external has no description (the external document owns that content; ADR 0023).phases are returned as written in Knowledge. Each steps item carries action or use, and use is not expanded.phases are what actually runs, with use expanded; steps is an array of strings. A TestCase is looked up by case_uid and returns its current revision in the working tree.uid matches no element at that point, the command exits with code 2 and writes nothing to stdout. The GUI re-reads the tree.source_revision and related_issues and a Feature’s forked_from are not used for display, so they are not included.The current values for building an editing Intent (§5.4) also come from this output.
impact already implements this)Provides the Requirement changes, related Features/TestCases, and acknowledgment state computed by the impact command.
impact::ChangeImpact)ChangeImpactReadModel is not a new design; it refers to ChangeImpact in src/impact.rs, already implemented and emitted with record_kind/schema_version.
struct ChangeImpact {
schema_version: u32, // already emitted alongside "record_kind": "change_impact"
record_kind: &'static str,
rule_version: u32,
base_commit: String,
head_commit: String,
requirements: Vec<RequirementImpact>, // each carries its Feature/TestCase relations
stale_pins: Vec<StalePin>,
rejected_trailers: Vec<RejectedTrailerReport>,
}
struct RequirementImpact {
requirement_id: String,
requirement_uid: Option<String>,
source: &'static str, // "native" | "external"
spec_changed: bool, // whether the Requirement itself changed; false when reached only through a changed Feature
feature_ids: Vec<String>,
cases: Vec<CaseAlignment>,
}
struct CaseAlignment {
case_id: String,
case_uid: Option<String>,
case_changed: bool,
status: AlignmentStatus, // Confirmed | FollowedUp | Unconfirmed
}
The acknowledgment state derived from the Spec-Reviewed trailer (AlignmentStatus) keeps the following distinction (ADR 0019):
confirmed a valid Spec-Reviewed exists for this pair
followed_up both sides changed in the range, but no acknowledgment was recorded
unconfirmed the spec side or a related Feature changed with neither follow-up nor acknowledgment
impact as seen from markharness-viewimpact --base <ref> --head <ref> already returns the full ChangeImpact — each Requirement’s Feature/TestCase relations and acknowledgment state — not just a count. markharness-view can consume this output as-is. No further implementation is needed.
coverage already implements this)Provides Feature/TestCase/verification means/coverage gaps/ReleaseScope selection state for a given Requirement set and Git ref.
coverage::ReleaseCoverage)ReleaseCoverageReadModel is not a new design; it refers to ReleaseCoverage in src/coverage.rs, already implemented and emitted with record_kind/schema_version.
struct ReleaseCoverage {
schema_version: u32, // already emitted alongside "record_kind": "release_coverage"
record_kind: &'static str,
rule_version: u32,
at_commit: String,
requirements: Vec<RequirementCoverage>,
gaps: Vec<CoverageGap>,
release: Option<ReleaseView>, // omitted entirely when no release was requested
}
struct RequirementCoverage {
requirement_id: String,
requirement_uid: Option<String>,
source: &'static str, // "native" | "external"
feature_ids: Vec<String>,
cases: Vec<CaseCoverage>,
}
struct CaseCoverage {
case_id: String,
case_uid: Option<String>,
feature_id: String,
binding_mode: Option<String>, // omitted when undeclared
binding_reference: Option<String>, // omitted when undeclared
reference_status: Option<ReferenceStatus>, // only with binding_reference: exists | missing | not_checked
selected: Option<bool>, // only meaningful when a release was requested
}
struct CoverageGap {
kind: GapKind, // "requirement_has_no_feature" | "feature_has_no_case"
requirement_id: String,
feature_id: Option<String>,
}
struct ReleaseView {
release_id: String,
selected_case_uids: Vec<String>,
unselected_case_uids: Vec<String>,
absent_case_uids: Vec<String>,
}
binding_mode/binding_reference express ExecutionBinding (a declared verification means) as-is. These values must never be presented as “executed” or “passed” — pass/fail, execution time, executor, and execution environment are a separate tool’s responsibility (ADR 0025 §1). “Selected” is likewise never conflated with “executed” (ADR 0024 §5).
reference_status says whether binding_reference exists in the tree of the --at commit (exists, missing, not_checked). The working tree is not consulted. A URL is not_checked; reachability is not checked. exists states only that the target is there, never that anything ran or passed. The rules, and the patterns that can be misjudged, follow ADR 0041.
coverage as seen from markharness-viewcoverage --requirements <ids-or-all> [--release <id>] --at <ref> already returns the full ReleaseCoverage above. markharness-view can consume this output as-is. No further implementation is needed.
The existing CommandOutcome (src/presentation.rs) is a type with three variants — CanonicalImported, Generated, ChangesComputed — representing the result of the write commands canonical_import, generate, and changes compute. Presenter (HumanPresenter/JsonPresenter) serializes it.
The read models do not extend CommandOutcome. impact (ChangeImpact) and coverage (ReleaseCoverage) never went through CommandOutcome/Presenter to begin with — each command’s own module serializes its own struct directly from cli.rs via serde_json. The new traceability (TraceabilityReadModel) follows that same path (ADR 0032).
Note that the name src/traceability.rs is already taken by the TraceabilityIndex generation artifact generate builds (.markharness/generated/traceability-index.json, used by verify to check regeneration is deterministic). Since the two differ in responsibility — a persisted artifact versus a query result — the existing file is renamed to src/traceability_index.rs, and the freed-up src/traceability.rs is used for the new traceability command (ADR 0032 decision 2).
CommandOutcome (write commands: CanonicalImported / Generated / ChangesComputed)
→ Presenter (Human / Json)
Per-command modules for the read-only commands (unrelated to CommandOutcome)
impact::ChangeImpact → already implemented, serialized directly from cli.rs
coverage::ReleaseCoverage → already implemented, serialized directly from cli.rs
traceability::TraceabilityReadModel → new, follows the same path
traceability_index::TraceabilityIndex → existing (renamed from the old traceability.rs); generate's artifact, not a read-only command
The initial mapping is:
markharness traceability
→ TraceabilityReadModel (the working tree; ADR 0043)
markharness traceability show --uid <UID>
→ TraceabilityDetailReadModel (the content of one chosen element; ADR 0040)
markharness impact --base <ref> --head <ref>
→ ChangeImpactReadModel
markharness coverage --requirements <ids-or-all> [--release <id>] --at <ref>
→ ReleaseCoverageReadModel
traceability is new. The existing generate is a write command that produces artifacts; a successful generation result and a Knowledge-browsing result are not mixed into the same command. traceability is read-only: it reads the working tree’s Knowledge and already-generated TestCases (ADR 0043). impact and coverage take a commit through --at (or --base/--head), since comparing two points and auditing a release are their whole point.
impact and coverage already return the full ChangeImpactReadModel/ReleaseCoverageReadModel as JSON. There is no option to choose the output format (ADR 0043). If human-readable output is needed later, it is generated from the same struct at that point.
The command name and the JSON record_kind are kept aligned, but the JSON contract’s identity is determined by record_kind and schema_version, not the command name.
The initial read models do not save the following as new source-of-truth files.
.markharness/They are generated deterministically, when needed, from existing Knowledge, Binding, ReleaseScope, and Git history as input.
Read models are tested from these angles.
record_kind and schema_version are always present in the output.ExecutionBinding in a way that could be mistaken for an execution result.source_locator/source_key let a reader actually reach the underlying StrictDoc (or similar) content — distinguishing the two is not enough on its own.JSON fixtures are prepared per read model, and also serve as output examples the view repository can reference.
Fixtures live at tests/fixtures/read-models/<record_kind>/v1/. The markharness-side fixtures are the canonical example for each read model, and CLI integration tests verify that actual output matches them.
The following models are added only once a concrete need is confirmed on the view side.
These are not included in the initial models merely because they might be useful someday. A new read model or Reader is designed once a second real read shape, or a concrete usage gap, actually appears.
Each Node’s label (§5.2) is an exception: it’s identifier metadata directly required by traceability’s own purpose — being browsable — not independent content the way a TestCase’s body is, so it belongs in the initial model.
The model dedicated to detailed TestCase display has been added as traceability show (§5.5), because the GUI’s detail pane confirmed a concrete need (ADR 0040).
traceability is its own commandFolding it into generate is not adopted. generate is a write command that produces TestCases and artifacts from Knowledge, while traceability is a read command that reads Knowledge and already-generated TestCases. Keeping them separate lets a view that only wants to read avoid triggering generation or file writes.
RequirementNode, FeatureNode, BehaviorNode, ScenarioNode, and TestCaseNode are kept separate. Normalizing every type into a common Node { kind, uid, fields } is not adopted.
The reason is to avoid losing, from the JSON contract, the meaning of per-type required fields, external-Requirement constraints, and TestCase’s Case revision. Only relations are normalized into the common TraceabilityRelation.
The external contract is not managed by implementation and fixtures alone. The following JSON Schemas are placed in the existing schema/ directory.
schema/traceability-read-model.schema.json
schema/change-impact-read-model.schema.json
schema/release-coverage-read-model.schema.json
JSON Schema validates structure and types. Semantic invariants — that a UID exists, what a Git ref means, that a Binding must never be interpreted as an execution result — are verified by Rust Domain/Application tests.
Since markharness and view are separate repositories, no shared directory, submodule, or runtime cross-reference is set up at this initial stage.
markharness manages the read models’ JSON Schema and representative fixtures as a published contract. The CLI integration test tests/read_model_contract.rs validates actual CLI output against the Schema and compares it with the fixture. view pulls the fixtures for the matching record_kind and schema_version into its own repository and uses them for contract tests. Fixtures are updated explicitly, as a Schema change or a deliberate change to the output contract.
This separation keeps view’s build from depending on markharness’s working tree or a local path. On the markharness side, using the fixtures in CLI integration tests lets divergence between actual CLI output and the external contract be detected.
In connection with the CLI read-model design, the commands ultimately provided are:
| Command | Category | Writes | Primary use | Output read model |
|---|---|---|---|---|
markharness traceability |
New | No | Read Requirement/Feature/Behavior/Scenario/TestCase relations (the working tree) | TraceabilityReadModel |
markharness impact --base <ref> --head <ref> |
Existing (implemented) | No | Read change impact, affected TestCases, and acknowledgment state | ChangeImpactReadModel |
markharness coverage --requirements <ids-or-all> [--release <id>] --at <ref> |
Existing (implemented) | No | Read Release Coverage, verification means, and coverage gaps | ReleaseCoverageReadModel |
markharness knowledge reconcile <intent-file> [--check] |
Existing command | Yes (no with --check) |
Validate a UID-bearing Intent and create/update/rename Knowledge | The applied result; updates the read models’ input |
markharness-view uses these read commands.
markharness traceability # live preview while editing: reads the working tree (ADR 0043)
markharness traceability show --uid <UID> # the content of one element chosen in the tree (for the detail pane)
markharness impact --base main --head HEAD
markharness coverage --requirements all --at HEAD
Each prints a single JSON read model to stdout. view never reads Knowledge or .markharness/ directly; it takes these outputs as input. traceability differs from impact/coverage in that it can reflect what the user just edited, before it’s committed (ADR 0033). impact can also read uncommitted changes as a provisional result with --worktree (ADR 0046).
The GUI (ADR 0038) additionally uses markharness binding list --json to read verification means. The output of binding list is not one of the three initial read models of §3.3; it has outcome and schema_version at the top level and a record_kind in each element. That the GUI reads it is therefore decided within 0038, and whether to add it to the read models is decided separately.
When a GUI or view modifies test knowledge, it goes only through knowledge reconcile.
Fetch a read model
↓
Generate/edit a minimal Intent with the target UID
↓
markharness knowledge reconcile <intent-file> --check
↓
markharness knowledge reconcile <intent-file>
traceability, impact, and coverage never modify Knowledge files directly. Nor does view.
The following commands are not added as part of the initial read-model design.
markharness view: view ships as a separate tool in a separate repository. This means the main tool holds no viewer implementation; markharness gui, which only launches the separate GUI tool, is added by ADR 0038markharness serve: no local web server is built into the main toolmarkharness search: not built until a concrete search requirement is confirmedmarkharness edit: no additional editing path; everything goes through Intent and knowledge reconcile