Qualifier Specification

v0.4.0 — the complete record format and CLI specification.


Abstract

Qualifier is a deterministic system for recording, propagating, and querying typed metadata records against software artifacts. It provides a VCS-friendly file format (.qual), a Rust library (libqualifier), and a CLI binary (qualifier) that together enable humans and agents to annotate code with structured quality signals without waiting for a formal process. Each annotation carries a kind (concern, comment, suggestion, pass, fail, blocker, praise, waiver, resolve, or any custom string), letting tools filter, thread, and aggregate however they need. Records thread, persist, and compose through a single content-addressed model.

Records use the Metabox envelope format: a fixed envelope (metabox, type, subject, issuer, issuer_type, created_at, id) wrapping a type-specific body object. Records are content-addressed, append-only, and human-writable. No server, no database, no PKI required.

0. Why Qualifier

Software is full of structured observations that have no good home. A reviewer notices that a function panics on malformed input. A scanner reports a CVE in a transitive dependency. A profiler measures a regression on a hot path. A licensing audit confirms that a vendored file is MIT. Today, each of these observations lands in a different system — a PR comment, a SARIF report, a spreadsheet, a wiki page — and none of those systems talks to the others. The observations decay because they live somewhere code does not. Structured knowledge about code deserves the same rigor we apply to the code itself.

Consider the alternatives we currently reach for. GitHub PR comments are tied to a diff window and disappear from view the moment the PR merges; the URL still resolves, but nothing in the working tree points at it, no tool can query it, and a refactor that touches the same lines a year later has no idea the conversation ever happened. SARIF reports are produced once by a tool, then either ignored or archived; they have no notion of human reply, threading, or follow-up. // TODO: comments are unstructured prose hidden in code, with no type, no severity, no author beyond git blame, and no way to thread a discussion. Issue trackers are separate from the code they describe; they collect bit-rot, can't address a specific span, and require context-switching to a different application to learn anything about the file in front of you.

Each of these tools fails the same way: the observation is not a first-class, addressable, durable artifact alongside the code. Qualifier's wager is that if you make structured observations look like code — files in the repo, version controlled, content-addressed, append-only, threadable — they stop evaporating. A concern raised in February is still queryable in October. A reply written by an agent threads to the human comment that prompted it. A resolution supersedes the original signal without erasing it. Merges are clean under normal workflows because the file format is designed for it. Tooling can read every record because the envelope is uniform.

The same skeleton that holds a human concern also holds a license declaration, a security advisory, or a performance measurement. The format is a substrate; annotations are simply its first and most-developed application. The cost of adoption is one JSONL file per directory and a CLI; the payoff is that the structured knowledge you produce — by hand, by review, by tool, by agent — finally has somewhere to live where it accumulates instead of decays.

If you are forwarding this document to convince a teammate: the pitch is that your team already produces this metadata. It is currently scattered across five systems and lost on every merge. Qualifier gives it a single home, in files, that survives.

1. Design Principles

  1. Files are the API. The .qual format is the primary interface. Every tool — CLI, editor plugin, CI bot, coding agent — reads and writes the same files. No server, no database, no lock-in.

  2. VCS-native. .qual files are append-only JSONL. They merge cleanly, diff readably, and blame usefully. Conflicts are structurally impossible under normal workflows (append-only + file-per-artifact).

  3. Open record types. The format is a substrate, not a single application. The Metabox envelope is fixed; record bodies are typed and extensible. New record types extend the system without changing the envelope, and unrecognized types pass through harmlessly. Annotations are the primary record type and the reason qualifier exists, but the same skeleton supports license declarations, security advisories, performance measurements, build provenance, or any other structured observation about a software artifact. Choosing the format does not lock you into a single domain.

  4. Ambient annotation. Record observations the moment you see them. No PR required, no review window, no formal ceremony. A human reading code can leave a concern in five seconds; an agent finishing a task can leave a comment to flag a follow-up; a scanner can drop a security-advisory into the same file. The practice is structurally enabled by append-only JSONL plus content-addressed records — adding a record never conflicts with another, and every record has a stable, addressable identity from the moment it is written.

  5. Deterministic record IDs. A record's id is the BLAKE3 hash of its Metabox Canonical Form (§2.8). Identical inputs produce identical IDs on every implementation — no language-specific or library-specific drift.

  6. Propagation through the graph. Quality is more than local. Software has dependencies. An artifact's effective quality is a function of its own annotations AND the effective quality of everything it depends on. A pristine binary that links a cursed library inherits the curse.

  7. Human-first, agent-friendly. The CLI is designed for humans at a terminal. The JSONL format and library API are designed for agents and tooling. Both are first-class.

  8. Composable. The record format uses the Metabox envelope — a uniform frame (who said something about which subject) wrapping typed payloads (what they said). Records compose into threads via references, into chains via supersedes, and into graphs via dependency records.

  9. Interoperable. Qualifier records project losslessly into in-toto annotation predicates. SARIF results import into qualifier annotations. The format bridges the gap between supply-chain annotation frameworks and human-scale quality tracking.

2. Record Model

2.1 Records

A record is a single, immutable, content-addressed JSON object that says something about a software artifact. Records are the atoms of the system.

Every record has a Metabox envelope — a fixed set of fields that identify who said what kind of thing about which subject and when — plus a body object containing type-specific fields.

2.2 Metabox Envelope

Every record uses the Metabox envelope format with these fields:

Field Type Required Description
metabox string yes Envelope version. MUST be "1".
type string yes* Record type identifier (see 2.5). *May be omitted in .qual files; defaults to "annotation".
subject string yes Qualified name of the target artifact
issuer string yes Who or what created this record (URI)
issuer_type string no Issuer classification: human, ai, tool, unknown
created_at string yes RFC 3339 timestamp
id string yes Content-addressed BLAKE3 hash (see 2.8)
body object yes Type-specific payload — see §3 for body schemas by type

These eight fields form the uniform interface. They are the same for every record type, they are stable across spec revisions, and they are sufficient to answer the questions "who said what kind of thing about what and when?" without understanding the body.

2.3 Subject Names

A subject is any addressable unit of software that can be qualified. Subjects are identified by a qualified name (a string), which SHOULD correspond to a logical unit in the codebase:

Qualifier does not enforce a naming scheme. The names are opaque strings. Conventions are a project-level decision.

2.3.1 Subject Renames

Qualifier identifies subjects by their qualified name. Renaming a subject (e.g., src/parser.rs to src/ast_parser.rs) requires the following steps:

  1. Rename the .qual file to match the new subject name.
  2. Update dependency records to reference the new name wherever the old name appeared (both as subject and in depends_on arrays).
  3. Note: Existing records inside the renamed .qual file still contain the old subject field in their JSON. Since record IDs are content-addressed, changing the subject field would change the ID, breaking supersession chains.

The RECOMMENDED workflow after a rename is:

  1. Rename the .qual file and update dependency records.
  2. Run qualifier compact <new-name> --snapshot to collapse history into a fresh epoch under the new name.
  3. Commit the rename and compacted file together.

2.4 Spans

A span identifies a sub-range within a subject. When present in the body, the record addresses a specific region rather than the whole artifact.

"span": {
  "start": { "line": 42 },
  "end": { "line": 58 }
}

A span is an object with these fields:

Field Type Required Description
start object yes Start of the range (inclusive)
end object no End of the range (inclusive). Defaults to start.
content_hash string no BLAKE3 hash of the spanned lines (see 2.4.3)

Each position has:

Field Type Required Description
line integer yes 1-indexed line number
col integer no 1-indexed column number

2.4.1 Span Forms

// Lines 42 through 58:
"span": {"start": {"line": 42}, "end": {"line": 58}}

// Line 42 only (end defaults to start):
"span": {"start": {"line": 42}}

// Columns 5–15 on line 42:
"span": {"start": {"line": 42, "col": 5}, "end": {"line": 42, "col": 15}}

// Cross-line range with column precision:
"span": {"start": {"line": 42, "col": 5}, "end": {"line": 58, "col": 80}}

2.4.2 Span Normalization

Before hashing (see 2.8), spans are normalized:

After normalization, {"start":{"line":42}} and {"start":{"line":42},"end":{"line":42}} produce identical canonical forms and therefore identical record IDs.

2.4.3 Content Hashing

When content_hash is present, it records a BLAKE3 hash of the source lines covered by the span at the time the annotation was created. This enables freshness checking — detecting whether the annotated code has changed since the annotation was written.

Hash computation:

  1. Read the file identified by the record's subject.
  2. Extract lines start.line through end.line (inclusive, 1-indexed). Columns are ignored — full lines are always hashed.
  3. Join the extracted lines with \n (no trailing newline).
  4. Compute the BLAKE3 hash of the resulting byte string.
  5. Encode as lowercase hex.

When computed: The CLI auto-computes content_hash when creating span- addressed annotations (via flag, suggest, comment, approve, reject, attest --span, etc.) if the subject file exists and the span is within bounds. If the file does not exist or the span extends beyond EOF, content_hash is omitted.

Relationship to ref: The ref field pins an annotation to a VCS revision (e.g., git:3aba500). content_hash pins the annotation to specific file content. They are complementary: ref answers "which commit?" while content_hash answers "has the code changed?"

Freshness states:

State Meaning
Fresh content_hash matches current file content
Drifted content_hash differs from current file content
Missing File not found or span beyond EOF
No hash Annotation has no content_hash (older or whole-file annotations)

2.4.4 Spans Address Subjects

Span-addressed records attach to their parent subject. An annotation about src/parser.rs at span {start: {line: 42}, end: {line: 58}} is a record about src/parser.rs that happens to point at lines 42–58.

Spans are addressing granularity. They tell you where within the subject a signal applies but do not create separate addressing targets.

2.5 Record Types

The type field is a string that identifies the body schema. Implementations MUST support the annotation, epoch, and dependency types. Additional types defined in this spec are RECOMMENDED but not strictly required — implementations that don't understand them MUST still preserve them (forward compatibility).

Type Description
annotation A quality signal (see 2.6)
epoch A compaction snapshot (see 3.2)
dependency A dependency edge (see 3.4)
license A license declaration (see 3.5)
security-advisory A known vulnerability or weakness (see 3.6)
perf-measurement A performance measurement (see 3.7)

Implementations MUST ignore records with unrecognized types (forward compatibility). Unrecognized records MUST be preserved during file operations (compaction, rewriting) — they are opaque pass-through data.

When type is omitted in a .qual file, it defaults to "annotation". In canonical form (for hashing), type is always materialized.

2.6 Annotation Records

An annotation is a quality signal about a subject. It is the primary record type and the reason qualifier exists.

Metabox envelope fields (section 2.2) plus body fields:

Field Type Required Description
detail string no Extended description, markdown allowed
kind string yes The type of annotation (see 2.7)
ref string no VCS reference pin (e.g., "git:3aba500"). Opaque to qualifier.
references string no ID of a related record (see 2.11)
span object no Sub-artifact range (see 2.4)
suggested_fix string no Actionable suggestion for improvement
summary string yes Human-readable one-liner
supersedes string no ID of a prior record this replaces (see 2.9)
tags string[] no Freeform classification tags

Body fields are listed in alphabetical order, which matches the Metabox Canonical Form (MCF) serialization order.

Example:

{"metabox":"1","type":"annotation","subject":"src/parser.rs","issuer":"mailto:alice@example.com","issuer_type":"human","created_at":"2026-02-25T10:00:00Z","id":"a1b2c3d4...","body":{"kind":"concern","ref":"git:3aba500","span":{"start":{"line":42},"end":{"line":58}},"suggested_fix":"Use the ? operator instead of unwrap()","summary":"Panics on malformed input","tags":["robustness"]}}

Shorthand (equivalent): Since type defaults to "annotation", it may be omitted:

{"metabox":"1","subject":"src/parser.rs","issuer":"mailto:alice@example.com","created_at":"2026-02-25T10:00:00Z","id":"a1b2c3d4...","body":{"kind":"concern","summary":"Panics on malformed input"}}

2.7 Annotation Kinds

The kind field is an open enum. The following kinds are defined by the spec; implementations MUST support them and MAY define additional kinds.

Kind Meaning
pass The artifact meets a stated quality bar
fail The artifact does NOT meet a stated quality bar
blocker A blocking issue that must be resolved before release
concern A non-blocking issue worth tracking
comment An observation or discussion point with no scoring impact
praise Positive recognition of quality
resolve Closes a prior record via supersession
suggestion A proposed improvement (typically paired with suggested_fix)
waiver An acknowledged issue explicitly accepted (with rationale)

2.7.1 Sign Conventions

The kinds carry an implicit polarity that downstream tools (scoring, filtering, gating) can use. Implementations layering numeric signals on top SHOULD respect these signs:

Kind Polarity
pass positive
praise positive
waiver positive
comment neutral
resolve neutral
concern negative
suggestion negative
fail negative
blocker negative

The format itself does not carry a numeric score. Tools MAY add custom body fields (e.g., a score integer) and define their own evaluation semantics on top of the kind polarity — see Appendix A for one possible shape.

2.7.2 Custom Kinds

Any string is a valid kind. Implementations SHOULD detect likely typos (edit distance <= 2 from a built-in kind) and warn the user.

2.8 Record IDs & Canonical Form

A record ID is a lowercase hex-encoded BLAKE3 hash of the Metabox Canonical Form (MCF) of the record, with the id field set to the empty string "" during hashing. This makes IDs deterministic and content-addressed.

2.8.1 Metabox Canonical Form (MCF)

To ensure that every implementation — regardless of language or JSON library — produces identical bytes for the same record, the canonical serialization MUST obey the following rules:

  1. Normalization. Before serialization:

    • type MUST be materialized. If absent, set to "annotation".
    • metabox MUST be materialized. If absent, set to "1".
    • span.end MUST be materialized (in body). If absent, set equal to span.start.
    • id MUST be set to "" (the empty string).
  2. Envelope field order. Envelope fields MUST appear in this fixed order: metabox, type, subject, issuer, issuer_type, created_at, id, body. Optional envelope fields (issuer_type) are omitted when absent.

  3. Body field order. Body fields MUST appear in lexicographic (alphabetical) order. Nested objects (like span) also have their fields in lexicographic order.

  4. Absent optional fields. Optional fields whose value is absent (null, None, etc.) MUST be omitted entirely. tags MUST be omitted when the array is empty. The id field is the sole exception — it is always present (set to "").

  5. Whitespace. No whitespace between tokens. No space after : or ,. No trailing newline. The output is a single compact JSON line.

  6. No trailing commas. Standard JSON — no trailing commas.

  7. String encoding. Standard JSON escaping (RFC 8259 Section 7). Implementations MUST NOT add escapes beyond what JSON requires.

  8. Number encoding. Integers serialize as bare decimal with no leading zeros, no decimal point, no exponent. Negative values use a leading -.

See the Metabox specification for the full MCF definition.

2.8.2 Example

Given an annotation with no optional body fields, the MCF is:

{"metabox":"1","type":"annotation","subject":"src/parser.rs","issuer":"mailto:alice@example.com","created_at":"2026-02-24T10:00:00Z","id":"","body":{"kind":"concern","summary":"Panics on malformed input"}}

With a span and issuer_type:

{"metabox":"1","type":"annotation","subject":"src/parser.rs","issuer":"mailto:alice@example.com","issuer_type":"human","created_at":"2026-02-24T10:00:00Z","id":"","body":{"kind":"concern","span":{"start":{"line":42},"end":{"line":42}},"summary":"Panics on malformed input"}}

Note that span.end has been materialized (it was omitted in the input, defaulting to start), body fields appear in alphabetical order, and issuer_type is in the envelope between issuer and created_at.

Rationale. MCF extends the behavior of serde_json with #[serde(skip_serializing_if)] annotations. Alphabetical body field ordering is simpler than per-type field orders and eliminates the need for type-specific canonical form definitions.

2.9 Supersession

Records are immutable once written. To "update" a signal, you write a new annotation with a supersedes field (in the body) pointing to the prior record's id.

Constraints:

Resolve pattern: A resolve-kind annotation supersedes its target, withdrawing the target from the active set. This is the canonical way to close an issue — the superseded record is no longer surfaced and the resolve record stands as the visible tombstone.

2.10 The .qual File Format

A .qual file is a UTF-8 encoded file where each line is a complete JSON object representing one record. This is JSONL (JSON Lines).

Placement: A .qual file can contain records for any subjects in its directory or subdirectories. The subject field in each record is the authoritative identifier — not the filename.

Layout strategies:

Strategy Example Pros Cons
Per-directory (recommended) src/.qual Clean tree, good merge behavior Slightly more merge contention than 1:1
Per-file src/parser.rs.qual Maximum merge isolation Noisy file tree
Per-project .qual at repo root Simplest setup High merge contention

All layouts are backwards-compatible and can coexist in the same project.

Rules:

Example (mixed record types):

{"metabox":"1","type":"annotation","subject":"src/parser.rs","issuer":"mailto:alice@example.com","issuer_type":"human","created_at":"2026-02-24T10:00:00Z","id":"a1b2c3d4...","body":{"kind":"concern","ref":"git:3aba500","span":{"start":{"line":42},"end":{"line":58}},"suggested_fix":"Replace .unwrap() with proper error propagation","summary":"Panics on malformed UTF-8 input","tags":["robustness","error-handling"]}}
{"metabox":"1","type":"annotation","subject":"src/parser.rs","issuer":"mailto:bob@example.com","issuer_type":"human","created_at":"2026-02-24T11:00:00Z","id":"e5f6a7b8...","body":{"kind":"praise","summary":"Excellent property-based test coverage","tags":["testing"]}}

2.11 References

The references body field provides a lightweight "re:" pointer from one annotation to another. Unlike supersedes (which removes the referenced record from the active set), references is purely informational — both the original and the referencing record remain active.

Semantics:

Use cases:

Threading semantics: Records referencing the same parent form a thread. Implementations SHOULD display these as threaded conversations with tree-drawing characters (├──, └──). Reply depth is unbounded — a reply to a reply is a valid thread.

Example:

{"metabox":"1","type":"annotation","subject":"src/parser.rs","issuer":"mailto:bob@example.com","created_at":"2026-03-01T10:00:00Z","id":"b2c3d4e5...","body":{"kind":"comment","references":"a1b2c3d4...","summary":"This was addressed in the latest refactor"}}

Full lifecycle example (flag → reply → resolve):

{"metabox":"1","type":"annotation","subject":"src/parser.rs","issuer":"mailto:alice@example.com","created_at":"2026-03-01T09:00:00Z","id":"a1b2c3d4...","body":{"kind":"concern","span":{"start":{"line":42}},"summary":"Panics on malformed input"}}
{"metabox":"1","type":"annotation","subject":"src/parser.rs","issuer":"mailto:bob@example.com","created_at":"2026-03-01T10:00:00Z","id":"b2c3d4e5...","body":{"kind":"comment","references":"a1b2c3d4...","summary":"Good catch — fixed in latest commit"}}
{"metabox":"1","type":"annotation","subject":"src/parser.rs","issuer":"mailto:alice@example.com","created_at":"2026-03-01T11:00:00Z","id":"c3d4e5f6...","body":{"kind":"resolve","summary":"Resolved","supersedes":"a1b2c3d4..."}}

After the resolve, the original concern's -10 is withdrawn from scoring. The reply remains visible in the thread for context.

3. Record Type Specifications

3.1 Annotation (type: "annotation")

Defined in section 2.6. This is the primary record type.

3.2 Epoch (type: "epoch")

An epoch is a synthetic compaction summary produced by the compactor. It replaces a set of records with a single record that preserves their refs.

Body fields (alphabetical):

Field Type Required Description
refs string[] yes IDs of the compacted records
span object no Sub-artifact range
summary string yes "Compacted from N records"

Epoch records MUST set issuer to "urn:qualifier:compact" and issuer_type to "tool" (in the envelope).

Example:

{"metabox":"1","type":"epoch","subject":"src/parser.rs","issuer":"urn:qualifier:compact","issuer_type":"tool","created_at":"2026-02-25T12:00:00Z","id":"f9e8d7c6...","body":{"refs":["a1b2...","c3d4..."],"summary":"Compacted from 12 records"}}

The refs field exists for auditability — it lets you trace back (via VCS history) to the individual records that were folded in.

3.3 Compaction

Append-only files grow without bound. Compaction is the mechanism for reclaiming space.

A compaction rewrites a .qual file by:

  1. Pruning all superseded records. If record B supersedes A, only B is retained. The entire chain collapses to its tip.
  2. Optionally snapshotting. When --snapshot is passed, all surviving records for each subject are replaced by a single epoch record.

3.3.1 Compaction Rules

3.4 Dependency (type: "dependency")

A dependency record declares directed dependency edges from one subject to others.

Body fields:

Field Type Required Description
depends_on string[] yes Subject names this subject depends on

Example:

{"metabox":"1","type":"dependency","subject":"bin/server","issuer":"https://build.example.com","created_at":"2026-02-25T10:00:00Z","id":"1a2b3c4d...","body":{"depends_on":["lib/auth","lib/http","lib/db"]}}

The dependency graph implied by these records MUST be a DAG.

Dependency records are wire-format-level: they declare edges that downstream tools can use to propagate signals across artifacts. The reference CLI does not consume them today (the qualifier graph command and built-in graph engine were yanked along with scoring), but they round-trip through .qual files unchanged.

3.5 License (type: "license")

A license record declares the licensing terms that apply to a subject. License records are typically produced by a license scanner or written by hand during a licensing audit.

Body fields:

Field Type Required Description
confidence number no Detector confidence in [0.0, 1.0]. Omit for hand-asserted records.
evidence string no Free-form provenance for the assertion (e.g., "LICENSE file SHA256:abc...", "package.json#license").
spdx_id string yes SPDX license identifier (e.g., "MIT", "Apache-2.0", "GPL-3.0-or-later").

Example:

{"metabox":"1","type":"license","subject":"vendor/lodash","issuer":"https://license-scanner.example.com","issuer_type":"tool","created_at":"2026-03-01T10:00:00Z","id":"...","body":{"confidence":0.98,"evidence":"LICENSE file SHA256:9f86d081...","spdx_id":"MIT"}}

A license record documents an attribute of the subject; if a licensing problem warrants a quality signal, write a separate annotation (e.g., kind: "blocker") and optionally references the license record.

3.6 Security Advisory (type: "security-advisory")

A security-advisory record records a known vulnerability or weakness affecting a subject. Records of this type are typically produced by a vulnerability scanner, an SBOM tool, or written by hand when triaging a CVE.

Body fields:

Field Type Required Description
affected_versions string no Version range expression (e.g., "<1.4.2", ">=2.0.0,<2.3.1").
cve_id string no CVE identifier (e.g., "CVE-2024-1234").
cwe_id string no CWE identifier (e.g., "CWE-79").
severity string yes One of critical, high, medium, low, info.
summary string yes Human-readable one-line description of the issue.

At least one of cve_id or cwe_id SHOULD be present, but neither is strictly required (some advisories predate CVE assignment or describe project-specific issues).

Example:

{"metabox":"1","type":"security-advisory","subject":"vendor/openssl","issuer":"https://osv.dev","issuer_type":"tool","created_at":"2026-03-01T10:00:00Z","id":"...","body":{"affected_versions":"<3.0.8","cve_id":"CVE-2023-0286","severity":"high","summary":"X.400 address type confusion in X.509 GeneralName"}}

To turn a security advisory into a quality signal, write an annotation (e.g., kind: "blocker") on the same subject that references the advisory.

3.7 Performance Measurement (type: "perf-measurement")

A perf-measurement record captures a single performance measurement for a subject. Records of this type are typically produced by a benchmark harness, a profiler, or a CI job that records production telemetry.

Body fields:

Field Type Required Description
baseline number no Reference value to compare against (e.g., the previous measurement).
metric string yes Metric identifier (e.g., "latency_p99_ms", "throughput_rps", "binary_size_bytes").
unit string no Unit of measure (e.g., "ms", "req/s", "bytes"). May be embedded in the metric name; this field is for explicit cases.
value number yes The measured value.

Example:

{"metabox":"1","type":"perf-measurement","subject":"bin/server","issuer":"https://ci.example.com","issuer_type":"tool","created_at":"2026-03-01T10:00:00Z","id":"...","body":{"baseline":42.0,"metric":"latency_p99_ms","unit":"ms","value":47.3}}

A regression worth flagging should be expressed as an annotation (e.g., kind: "concern" or kind: "blocker") that may references the underlying measurement record.

3.8 Defining New Record Types

Per design principle 3 (Open record types), implementations and integrations MAY define new record types. New record types are identified by a string value in the type field. Types defined outside this spec SHOULD use a URI to avoid collisions:

{"metabox":"1","type":"https://example.com/qualifier/build-provenance/v1","subject":"bin/server","issuer":"https://build.example.com","created_at":"...","id":"...","body":{"builder":"github-actions","commit":"abc123"}}

Types defined in this spec use short unqualified names (annotation, epoch, dependency, license, security-advisory, perf-measurement). The spec reserves all unqualified type names (strings that do not contain : or /) for future standardization.

A record type specification MUST define the body fields, their types, and which are required. Body fields are always serialized in lexicographic order per MCF.

4. Layering Quality Signals on Top

The format itself does not prescribe a numeric model. Annotations carry a kind (with implicit polarity, see §2.7.1) and a free-form body; tools that want to compute aggregate quality signals layer on top by adding custom body fields and defining their own evaluation semantics.

This section is an example of one such layer. Nothing here is required of conforming implementations.

4.1 Example: A score body field

A tool MAY add a score: integer field to annotation bodies. Treat score as a signed quality delta — negative for problems, positive for positives, absent for neutral observations. A reasonable default mapping follows the polarity table in §2.7.1:

Kind Example default
pass +20
fail -20
blocker -50
concern -10
comment absent
praise +30
resolve absent
suggestion -5
waiver +10

These are illustrative. A tool may pick any range or mapping that suits its aggregation strategy.

4.2 Example: Aggregating across a subject

A tool that defines a score body field as above might define a raw score for a subject as the sum of score fields of its active (non-superseded) annotation records, clamped to a chosen range.

When a dependency graph (§3.4) is present, the tool might further define an effective score that propagates negative signals along edges (e.g., effective(A) = min(raw(A), min(effective(D) for D in deps(A)))), so a problem in a leaf subject lowers the score of everything that depends on it.

These are choices the tool makes, not invariants of the format. A different tool might weight by kind, decay by age, or ignore signed deltas entirely in favour of a categorical bar (e.g., "any active blocker fails the build").

4.3 Span behaviour

Span-addressed records (§2.4) attach to their subject. Whatever aggregation a tool defines, the span identifies where the signal applies within the subject; the tool may surface span-level views for display, but the canonical addressing unit is the subject.

5. Interoperability

5.1 in-toto Predicate Projection

Qualifier records project losslessly into in-toto v1 Statement predicates for use with DSSE signing and Sigstore distribution.

Mapping (annotation):

{
  "_type": "https://in-toto.io/Statement/v1",
  "subject": [
    {
      "name": "src/parser.rs",
      "digest": {"blake3": "<artifact-content-hash>"}
    }
  ],
  "predicateType": "https://qualifier.dev/annotation/v1",
  "predicate": {
    "qualifier_id": "a1b2c3d4...",
    "kind": "concern",
    "span": {"start": {"line": 42}, "end": {"line": 58}},
    "summary": "Panics on malformed input",
    "tags": ["robustness"],
    "issuer": "mailto:alice@example.com",
    "issuer_type": "human",
    "created_at": "2026-02-25T10:00:00Z",
    "ref": "git:3aba500",
    "supersedes": null
  }
}

Field mapping:

Qualifier field in-toto location
subject subject[0].name
body.span predicate.span
id predicate.qualifier_id
issuer predicate.issuer (also DSSE signer)
issuer_type predicate.issuer_type
All body fields predicate.*

The in-toto subject[0].digest contains the content hash of the artifact file. This is populated by the signing tool, not by qualifier itself. Qualifier's id is the hash of the record, not the artifact.

Predicate type URIs:

Qualifier type Predicate type URI
annotation https://qualifier.dev/annotation/v1
epoch https://qualifier.dev/epoch/v1
dependency https://qualifier.dev/dependency/v1

5.2 SARIF Import

SARIF v2.1.0 results can be converted to qualifier annotations:

SARIF field Qualifier field
result.locations[0].physicalLocation.artifactLocation.uri subject
result.locations[0].physicalLocation.region.startLine body.span.start.line
result.locations[0].physicalLocation.region.startColumn body.span.start.col
result.locations[0].physicalLocation.region.endLine body.span.end.line
result.locations[0].physicalLocation.region.endColumn body.span.end.col
result.ruleId body.kind (as custom kind)
result.level body.kind (errorfail, warningconcern, notecomment)
result.message.text body.summary
run.tool.driver.name issuer
(constant) issuer_type: "tool" (envelope)

6. CLI Interface

The CLI binary is named qualifier. Writes go through four verbs: record, reply, resolve, and emit.

6.1 Core Commands

Write commands:

qualifier record <kind> <location> [message]    Record an annotation
qualifier reply <target> <message>              Reply to an existing record
qualifier resolve <target> [message]            Resolve (close) an existing record
qualifier emit <type> <subject> --body '<JSON>' Emit a raw record of any type

Inspect commands:

qualifier show <artifact>                 Show annotations for an artifact
qualifier ls [--kind <k>]                 List subjects by kind
qualifier praise <artifact>               Show who annotated an artifact and why
                                          (also available as the `blame` alias)
qualifier review [subject]                Check freshness of annotations

Maintain commands:

qualifier compact <artifact> [options]    Compact a .qual file (prune/snapshot)

6.2 qualifier record

The unified annotation-write verb. Replaces the old attest, flag, comment, suggest, approve, and reject commands with a single shape: qualifier record <kind> <location> [message] [flags].

qualifier record concern src/parser.rs:42:58 "Panics on malformed input" \
  --suggested-fix "Use proper error propagation" \
  --tag robustness \
  --tag error-handling \
  --issuer "mailto:alice@example.com"

Arguments:

Argument Meaning
<kind> One of concern, comment, suggestion, pass, fail, blocker, praise, waiver, resolve. Custom strings are allowed (per spec §2.7.2).
<location> Subject path with optional span — see §6.2.1.
[message] One-line summary. Becomes body.summary. Required in non-interactive mode unless --stdin is set.

Flags: --detail TEXT, --ref REF, --tag T1 --tag T2 ..., --suggested-fix TEXT, --issuer URI, --issuer-type {human|ai|tool|unknown}, --file PATH, --span SPEC (overrides any span in <location>), --supersedes ID, --references ID, --stdin (batch JSONL).

Defaults:

6.2.1 Location and Span Syntax

The <location> argument folds the subject and an optional span into a single string:

Form Meaning
src/parser.rs Whole file
src/parser.rs:42 Line 42
src/parser.rs:15:28 Lines 15 through 28

The --span flag overrides any span parsed from <location> and accepts the same forms plus column granularity:

Form Meaning Equivalent span object
42 Line 42 {"start":{"line":42},"end":{"line":42}}
42:58 Lines 42 through 58 {"start":{"line":42},"end":{"line":58}}
42.5:58.80 Line 42 col 5 through line 58 col 80 {"start":{"line":42,"col":5},"end":{"line":58,"col":80}}

6.2.2 Batch Mode

qualifier record --stdin reads JSONL from stdin. Each line is one of:

6.3 qualifier reply

qualifier reply <target> <message>

Sugar over "kind=comment + references=<target-id>". The default kind is comment; override with --kind.

<target> is either:

Same body flags as qualifier record.

6.4 qualifier resolve

qualifier resolve <target> [message]

Sugar over "kind=resolve + supersedes=<target-id>". <target> follows the same id-prefix-or-location rules as qualifier reply. The default summary is "Resolved" when [message] is omitted.

6.5 qualifier emit

qualifier emit <type> <subject> --body '<JSON>'

A raw, script-oriented write for novel or uncommon record types. The body is passed through unchanged into the record's body field. For unknown types the record round-trips via Record::Unknown (preserving the body verbatim). For --type annotation, the body is validated against AnnotationBody.

qualifier emit license src/lib.rs --body '{"spdx":"MIT"}' \
  --issuer "https://ci.example.com"

qualifier emit https://example.com/lint/v1 src/parser.rs \
  --body '{"rule":"no-panic","matches":3}'

--stdin reads JSONL where each line is a complete record. The positional <type> and <subject>, when supplied, become defaults applied to lines missing those fields.

6.5.1 Example Workflow

# Record a concern at line 42
qualifier record concern src/parser.rs:42 "Panics on malformed input"

# See the concern
qualifier show src/parser.rs

# Reply to it (using ID prefix or location)
qualifier reply a1b2 "Good catch, fixed in latest commit"
qualifier reply src/parser.rs:42 "Good catch, fixed in latest commit"

# Close it
qualifier resolve a1b2

# The original concern is no longer surfaced
qualifier show src/parser.rs

6.6 qualifier show

qualifier show src/parser.rs

  src/parser.rs

  Records (4):
    concern  "Panics on malformed input"    alice  2026-02-24  a1b2c3d4
    ├── comment  "Good catch, fixed"        bob    2026-02-25  b2c3d4e5
    └── resolve  "Resolved"                 alice  2026-02-25  c3d4e5f6
    praise   "Excellent property test coverage"  bob  2026-02-24  e5f6a7b8

When annotations have spans, the line range is displayed. Use --line <n> to filter to annotations overlapping a specific line.

--all shows all records including resolved/superseded ones (default hides them). --pretty forces colored output when piped.

6.7 qualifier ls

qualifier ls --kind blocker
qualifier ls --unqualified

6.8 qualifier compact

qualifier compact src/parser.rs              # prune superseded records
qualifier compact src/parser.rs --snapshot   # collapse to a single epoch
qualifier compact src/parser.rs --dry-run    # preview without writing
qualifier compact --all                      # compact every .qual file
qualifier compact --all --dry-run            # preview repo-wide compaction

6.9 qualifier review

Check the freshness of span-addressed annotations against current file content.

qualifier review                          # check all annotations
qualifier review src/parser.rs            # check annotations for one subject
qualifier review --format json            # machine-readable output
qualifier review --no-ignore              # bypass ignore rules

Human output:

  FRESH    src/parser.rs:42    concern  "Panics on malformed input"
  DRIFTED  src/auth.rs:10:25   suggestion  "Consider using Result"
  MISSING  src/old.rs:1:20     blocker  "Memory leak"

3 annotations checked: 1 fresh, 1 drifted, 1 missing

Only active (non-superseded) annotations with spans that have a content_hash are checked. Annotations without spans or without content_hash are skipped.

JSON output includes status (fresh, drifted, missing) and detail with expected/actual hashes for drifted annotations or a reason for missing ones.

6.10 Configuration

Qualifier uses layered configuration. Precedence (highest wins):

Priority Source
1 (highest) CLI flags
2 Environment variables
3 Project config (.qualifier.toml)
4 User config (~/.config/qualifier/config.toml)
5 (lowest) Built-in defaults

Configuration keys:

Key CLI flag Env var Default
issuer --issuer QUALIFIER_ISSUER VCS identity (see 8.4)
format --format QUALIFIER_FORMAT human

6.11 qualifier praise

Show who recorded annotations against an artifact and why. Available under the alias qualifier blame; the canonical name is praise (the tool tracks who helped, not who to blame). With --vcs, delegates to the underlying VCS blame command for the subject's .qual file.

qualifier praise src/parser.rs
qualifier praise src/parser.rs --vcs

7. Library API

The qualifier crate exposes its library API from src/lib.rs. Library consumers add qualifier = { version = "0.4", default-features = false } to avoid pulling in CLI dependencies.

// qualifier::annotation — record types and core logic

/// A typed qualifier record. Dispatches on the `type` field in JSON.
pub enum Record {
    Annotation(Box<Annotation>),
    Epoch(Epoch),
    Dependency(DependencyRecord),
    Unknown(serde_json::Value),  // forward compatibility
}

impl Record {
    pub fn subject(&self) -> &str;
    pub fn id(&self) -> &str;
    pub fn supersedes(&self) -> Option<&str>;   // Annotation only
    pub fn references(&self) -> Option<&str>;   // Annotation only
    pub fn kind(&self) -> Option<&Kind>;        // Annotation only
    pub fn issuer_type(&self) -> Option<&IssuerType>;
    pub fn as_annotation(&self) -> Option<&Annotation>;
    pub fn as_epoch(&self) -> Option<&Epoch>;
}

pub struct Annotation {
    pub metabox: String,                    // always "1"
    pub record_type: String,                // "annotation"
    pub subject: String,
    pub issuer: String,
    pub issuer_type: Option<IssuerType>,
    pub created_at: DateTime<Utc>,
    pub id: String,
    pub body: AnnotationBody,
}

pub struct AnnotationBody {
    pub detail: Option<String>,
    pub kind: Kind,
    pub r#ref: Option<String>,
    pub references: Option<String>,
    pub span: Option<Span>,
    pub suggested_fix: Option<String>,
    pub summary: String,
    pub supersedes: Option<String>,
    pub tags: Vec<String>,
}

pub struct Epoch {
    pub metabox: String,                    // always "1"
    pub record_type: String,                // "epoch"
    pub subject: String,
    pub issuer: String,
    pub issuer_type: Option<IssuerType>,
    pub created_at: DateTime<Utc>,
    pub id: String,
    pub body: EpochBody,
}

pub struct EpochBody {
    pub refs: Vec<String>,
    pub span: Option<Span>,
    pub summary: String,
}

pub struct DependencyRecord {
    pub metabox: String,                    // always "1"
    pub record_type: String,                // "dependency"
    pub subject: String,
    pub issuer: String,
    pub issuer_type: Option<IssuerType>,
    pub created_at: DateTime<Utc>,
    pub id: String,
    pub body: DependencyBody,
}

pub struct DependencyBody {
    pub depends_on: Vec<String>,
}

pub struct Span {
    pub start: Position,
    pub end: Option<Position>,          // normalized to Some(start) before hashing
    pub content_hash: Option<String>,   // BLAKE3 of spanned lines
}

pub struct Position {
    pub line: u32,               // 1-indexed
    pub col: Option<u32>,        // 1-indexed, optional
}

pub enum Kind { Pass, Fail, Blocker, Concern, Comment, Resolve, Praise, Suggestion, Waiver, Custom(String) }
pub enum IssuerType { Human, Ai, Tool, Unknown }

pub fn generate_id(annotation: &Annotation) -> String;
pub fn generate_epoch_id(epoch: &Epoch) -> String;
pub fn generate_dependency_id(dep: &DependencyRecord) -> String;
pub fn generate_record_id(record: &Record) -> String;
pub fn validate(annotation: &Annotation) -> Vec<String>;
pub fn finalize(annotation: Annotation) -> Annotation;
pub fn finalize_epoch(epoch: Epoch) -> Epoch;
pub fn finalize_record(record: Record) -> Record;

// qualifier::qual_file
pub struct QualFile { pub path: PathBuf, pub subject: String, pub records: Vec<Record> }
pub fn parse(path: &Path) -> Result<QualFile>;
pub fn append(path: &Path, record: &Record) -> Result<()>;
pub fn discover(root: &Path, respect_ignore: bool) -> Result<Vec<QualFile>>;

// qualifier::content_hash — span freshness checking
pub fn compute_span_hash(file_path: &Path, span: &Span) -> Option<String>;
pub enum FreshnessStatus { Fresh, Drifted { expected, actual }, Missing { reason }, NoHash }
pub fn check_freshness(file_path: &Path, span: &Span) -> FreshnessStatus;

// qualifier::compact
pub struct CompactResult { pub before: usize, pub after: usize, pub pruned: usize }
pub fn filter_superseded(records: &[Record]) -> Vec<&Record>;
pub fn prune(qual_file: &QualFile) -> (QualFile, CompactResult);
pub fn snapshot(qual_file: &QualFile) -> (QualFile, CompactResult);

The library is the source of truth. The CLI is a thin wrapper around it.

8. VCS Integration

.qual files SHOULD be committed to version control. Qualifier is VCS-agnostic — the append-only JSONL format is friendly to any system that tracks text files.

8.1 General Principles

8.2 VCS-Specific Setup

VCS Configuration
Git Add *.qual merge=union to .gitattributes
Mercurial Add **.qual = union to .hgrc merge patterns
Other Configure equivalent union-merge behaviour for *.qual

8.3 qualifier blame

Delegates to the underlying VCS blame/annotate command:

8.4 Issuer Defaults

When --issuer is omitted:

9. Agent Integration

Qualifier is designed to be used by AI coding agents. Key affordances:

10. File Discovery

Qualifier discovers .qual files by walking the directory tree from the project root. Each .qual file may contain records for multiple subjects and multiple record types.

The project root is determined by searching upward for VCS markers (.git, .hg, .jj, .pijul, _FOSSIL_, .svn).

10.1 Ignore Rules

By default, qualifier respects ignore rules from two sources during file discovery:

  1. .gitignore — Standard Git ignore files, including:

    • .gitignore files at any level of the tree
    • .git/info/exclude (per-repo excludes)
    • The global gitignore file (e.g., ~/.config/git/ignore)
    • .gitignore files in parent directories above the project root (matching Git's own behavior in monorepos)
  2. .qualignore — A qualifier-specific ignore file using the same syntax as .gitignore. Place a .qualignore file anywhere in the tree to exclude paths from qualifier's discovery walk. Useful for ignoring vendored code, generated files, or example directories that have .qual files you want qualifier to skip without affecting Git.

Paths matched by either source are excluded from all discovery commands: show, ls, compact, review, and praise/blame.

10.2 --no-ignore

Pass --no-ignore to any discovery command to bypass all ignore rules. This forces qualifier to walk every non-hidden directory and discover all .qual files regardless of .gitignore or .qualignore entries.

10.3 Hidden Directories

Hidden directories (names starting with .) are always skipped during discovery, regardless of ignore settings. This prevents qualifier from descending into .git, .vscode, .idea, and similar tool directories.

Hidden files (like .qual) are not skipped — the per-directory .qual layout depends on this.

11. Crate Structure

A single crate published as qualifier on crates.io.

qualifier/
├── Cargo.toml
├── SPEC.md                    # This document
├── METABOX.md                 # Metabox envelope specification
└── src/
    ├── lib.rs                 # Public library API
    ├── annotation.rs         # Record types, body structs, Kind, IssuerType, validation
    ├── content_hash.rs        # Span content hashing and freshness checking
    ├── qual_file.rs           # .qual file parsing, appending, discovery
    ├── compact.rs             # Compaction: prune and snapshot, supersession filtering
    ├── bin/
    │   └── qualifier.rs       # Binary entry point
    └── cli/                   # CLI module (behind "cli" feature)
        ├── mod.rs
        ├── config.rs
        ├── output.rs
        ├── span_context.rs
        └── commands/
            ├── mod.rs
            ├── record.rs         # qualifier record (unified annotation write)
            ├── reply.rs          # qualifier reply (id-prefix or location)
            ├── resolve.rs        # qualifier resolve (id-prefix or location)
            ├── emit.rs           # qualifier emit (raw record write)
            ├── freshness.rs      # qualifier review (freshness checking)
            ├── show.rs
            ├── ls.rs
            ├── compact.rs
            ├── praise.rs         # qualifier praise (alias: blame)
            └── haiku.rs
[features]
default = ["cli"]
cli = ["dep:clap", "dep:comfy-table", "dep:figment"]

12. Future Considerations (Out of Scope)

These are explicitly not part of v0.3 but are anticipated:


The Koalafier has spoken. Now go qualify some code.