diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index a784731..6077123 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -83,16 +83,15 @@ jobs: run: cd site && pnpm install && pnpm run build - name: Deploy to Cloudflare Pages - if: github.event_name == 'push' && github.ref == 'refs/heads/main' id: deploy uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: pages deploy site/_site --project-name=qualifier-dev --branch=main + command: pages deploy site/_site --project-name=qualifier-dev --branch=${{ github.event_name == 'push' && 'main' || github.head_ref }} - name: Comment preview URL on PR - if: github.event_name == 'pull_request' && steps.deploy.outputs.deployment-url + if: github.event_name == 'pull_request' uses: actions/github-script@v7 with: script: | diff --git a/METABOX.md b/METABOX.md index 279d180..86c186c 100644 --- a/METABOX.md +++ b/METABOX.md @@ -9,7 +9,7 @@ ## Abstract Metabox is a minimal envelope format for content-addressed records. It defines -seven fixed fields that answer "who said what, when" plus a `body` object for +eight fixed fields that answer "who said what, when" plus a `body` object for domain-specific payload. Records are JSONL, IDs are BLAKE3 hashes of a canonical form. @@ -19,20 +19,22 @@ that benefits from content addressing and a uniform envelope. ## 1. Envelope Fields -Every Metabox record is a JSON object with exactly seven top-level fields, in +Every Metabox record is a JSON object with exactly eight top-level fields, in this canonical order: -| # | Field | Type | Required | Description | -| --- | ------------ | ------ | -------- | ---------------------------------------------- | -| 1 | `metabox` | string | yes | Envelope version. Always `"1"`. | -| 2 | `type` | string | yes | Body schema identifier. | -| 3 | `subject` | string | yes | What this record is about. | -| 4 | `author` | string | yes | Who or what created this record. | -| 5 | `created_at` | string | yes | RFC 3339 timestamp. | -| 6 | `id` | string | yes | Content-addressed BLAKE3 hash (see section 3). | -| 7 | `body` | object | yes | Type-specific payload. | +| # | Field | Type | Required | Description | +| --- | -------------- | ------ | -------- | ---------------------------------------------- | +| 1 | `metabox` | string | yes | Envelope version. Always `"1"`. | +| 2 | `type` | string | yes | Body schema identifier. | +| 3 | `subject` | string | yes | What this record is about. | +| 4 | `issuer` | string | yes | Who or what created this record (URI). | +| 5 | `issuer_type` | string | no | Issuer classification (see 1.5). | +| 6 | `created_at` | string | yes | RFC 3339 timestamp. | +| 7 | `id` | string | yes | Content-addressed BLAKE3 hash (see section 3). | +| 8 | `body` | object | yes | Type-specific payload. | -All seven fields are required. All seven are present in every record. +Seven fields are required. `issuer_type` is optional. All eight are present in +the canonical field order. ### 1.1 `metabox` @@ -66,29 +68,38 @@ conventions are a project-level decision. Examples: `"src/parser.rs"`, `"pkg:npm/lodash@4.17.21"`, `"service/health"`, `"https://example.com/api/v2"`. -### 1.4 `author` +### 1.4 `issuer` -Who or what created this record. Typically an email address, tool identifier, -or service account name. The string is opaque to Metabox. +Who or what created this record (URI). Typically a `mailto:` URI, `https:` +service URL, or other URI-scheme identifier. The string is opaque to Metabox. -### 1.5 `created_at` +### 1.5 `issuer_type` + +Optional classification of the issuer entity. When present, it is one of: +`"human"`, `"ai"`, `"tool"`, or `"unknown"`. Omitted when not applicable. + +This field lives in the envelope (not the body) because it answers an +envelope-level question — "what kind of entity issued this record?" — and is +uniform across all record types. + +### 1.6 `created_at` An RFC 3339 timestamp indicating when the record was created. -### 1.6 `id` +### 1.7 `id` A lowercase hex-encoded BLAKE3 hash of the record's Metabox Canonical Form (section 3), 64 characters. Content-addressed: the same record always produces the same ID. -### 1.7 `body` +### 1.8 `body` A JSON object containing the type-specific payload. The body is always present. Types with no fields use an empty object `{}`. The envelope never looks inside the body. Generic Metabox tooling (indexers, -replicators, filters) can operate on the six envelope fields without -understanding or parsing the body. +replicators, filters) can operate on the envelope fields without understanding +or parsing the body. ## 2. File Format @@ -120,13 +131,15 @@ obey the following rules: Before serialization: - `id` MUST be set to `""` (the empty string). -- All seven envelope fields MUST be present. +- All eight envelope fields MUST be present (optional fields use their absent + representation: `issuer_type` is omitted when not set). - `body` MUST be present (empty `{}` if the type has no fields). ### 3.2 Field Order 1. **Envelope fields** appear in the fixed order defined in section 1: - `metabox`, `type`, `subject`, `author`, `created_at`, `id`, `body`. + `metabox`, `type`, `subject`, `issuer`, `issuer_type`, `created_at`, `id`, + `body`. Optional envelope fields (`issuer_type`) are omitted when absent. 2. **Body fields** are sorted lexicographically by key. Sorting is recursive: nested objects also have their keys sorted lexicographically. @@ -192,22 +205,23 @@ define: A Qualifier attestation in Metabox format: ```json -{"metabox":"1","type":"attestation","subject":"src/parser.rs","author":"alice@example.com","created_at":"2026-02-24T10:00:00Z","id":"a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8","body":{"author_type":"human","kind":"concern","ref":"git:3aba500","score":-30,"summary":"Panics on malformed input"}} +{"metabox":"1","type":"attestation","subject":"src/parser.rs","issuer":"mailto:alice@example.com","issuer_type":"human","created_at":"2026-02-24T10:00:00Z","id":"a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8","body":{"kind":"concern","ref":"git:3aba500","score":-30,"summary":"Panics on malformed input"}} ``` -Note that body fields are sorted lexicographically: `author_type`, `kind`, -`ref`, `score`, `summary`. +Note that body fields are sorted lexicographically: `kind`, `ref`, `score`, +`summary`. The `issuer_type` field is in the envelope, between `issuer` and +`created_at`. A minimal record with an empty body: ```json -{"metabox":"1","type":"ping","subject":"service/health","author":"monitor","created_at":"2026-02-25T12:00:00Z","id":"f9e8d7c6b5a4f9e8d7c6b5a4f9e8d7c6b5a4f9e8d7c6b5a4f9e8d7c6b5a4f9e8","body":{}} +{"metabox":"1","type":"ping","subject":"service/health","issuer":"https://monitor.example.com","created_at":"2026-02-25T12:00:00Z","id":"f9e8d7c6b5a4f9e8d7c6b5a4f9e8d7c6b5a4f9e8d7c6b5a4f9e8d7c6b5a4f9e8","body":{}} ``` A dependency declaration: ```json -{"metabox":"1","type":"dependency","subject":"bin/server","author":"build-system","created_at":"2026-02-25T10:00:00Z","id":"1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b","body":{"depends_on":["lib/auth","lib/http","lib/db"]}} +{"metabox":"1","type":"dependency","subject":"bin/server","issuer":"https://build.example.com","created_at":"2026-02-25T10:00:00Z","id":"1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b","body":{"depends_on":["lib/auth","lib/http","lib/db"]}} ``` ## 7. Qualifier Mapping @@ -221,7 +235,7 @@ Qualifier v3 records map to Metabox as follows: | `v: 3` | `metabox: "1"` | Version field changes name/value | | `type` | `type` | Unchanged | | `artifact` | `subject` | Renamed for generality | -| `author` | `author` | Unchanged | +| `author` | `issuer` | Renamed to issuer (URI-based identity) | | `created_at` | `created_at` | Unchanged | | `id` | `id` | Unchanged | @@ -232,11 +246,11 @@ All non-frame fields move into the `body` object: **Attestation** (`type: "attestation"`): `span`, `kind`, `score`, `summary`, `detail`, `suggested_fix`, `tags`, -`author_type`, `ref`, `supersedes` → `body.*` +`ref`, `supersedes` → `body.*` **Epoch** (`type: "epoch"`): -`span`, `score`, `summary`, `refs`, `author_type` → `body.*` +`span`, `score`, `summary`, `refs` → `body.*` **Dependency** (`type: "dependency"`): diff --git a/SPEC.md b/SPEC.md index ec65c2c..9b3af6d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -16,8 +16,8 @@ structured quality signals — and to compute aggregate quality scores that propagate through dependency graphs. Records use the [Metabox](METABOX.md) envelope format: a fixed envelope -(`metabox`, `type`, `subject`, `author`, `created_at`, `id`) wrapping a -type-specific `body` object. Records are content-addressed, append-only, and +(`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. ## 1. Design Principles @@ -68,17 +68,18 @@ Every record has a **Metabox envelope** — a fixed set of fields that identify Every record uses the [Metabox](METABOX.md) 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 `"attestation"`. | -| `subject` | string | yes | Qualified name of the target artifact | -| `author` | string | yes | Who or what created this record | -| `created_at` | string | yes | RFC 3339 timestamp | -| `id` | string | yes | Content-addressed BLAKE3 hash (see 2.8) | -| `body` | object | yes | Type-specific payload (see 2.6, 3.2, 3.4) | - -These seven fields form the **uniform interface**. They are the same for every +| 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 `"attestation"`. | +| `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 2.6, 3.2, 3.4) | + +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. @@ -212,7 +213,6 @@ Metabox envelope fields (section 2.2) plus body fields: | Field | Type | Required | Description | |-----------------|----------|----------|-------------| -| `author_type` | string | no | Author classification: `human`, `ai`, `tool`, `unknown` | | `detail` | string | no | Extended description, markdown allowed | | `kind` | string | yes | The type of attestation (see 2.7) | | `ref` | string | no | VCS reference pin (e.g., `"git:3aba500"`). Opaque to qualifier. | @@ -229,14 +229,14 @@ Canonical Form (MCF) serialization order. **Example:** ```json -{"metabox":"1","type":"attestation","subject":"src/parser.rs","author":"alice@example.com","created_at":"2026-02-25T10:00:00Z","id":"a1b2c3d4...","body":{"author_type":"human","kind":"concern","ref":"git:3aba500","score":-10,"span":{"start":{"line":42},"end":{"line":58}},"suggested_fix":"Use the ? operator instead of unwrap()","summary":"Panics on malformed input","tags":["robustness"]}} +{"metabox":"1","type":"attestation","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","score":-10,"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 `"attestation"`, it may be omitted: ```json -{"metabox":"1","subject":"src/parser.rs","author":"alice@example.com","created_at":"2026-02-25T10:00:00Z","id":"a1b2c3d4...","body":{"kind":"concern","score":-10,"summary":"Panics on malformed input"}} +{"metabox":"1","subject":"src/parser.rs","issuer":"mailto:alice@example.com","created_at":"2026-02-25T10:00:00Z","id":"a1b2c3d4...","body":{"kind":"concern","score":-10,"summary":"Panics on malformed input"}} ``` ### 2.7 Attestation Kinds @@ -303,7 +303,8 @@ obey the following rules: - `id` MUST be set to `""` (the empty string). 2. **Envelope field order.** Envelope fields MUST appear in this fixed order: - `metabox`, `type`, `subject`, `author`, `created_at`, `id`, `body`. + `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 @@ -332,17 +333,18 @@ See the [Metabox specification](METABOX.md) for the full MCF definition. Given an attestation with no optional body fields, the MCF is: ```json -{"metabox":"1","type":"attestation","subject":"src/parser.rs","author":"alice@example.com","created_at":"2026-02-24T10:00:00Z","id":"","body":{"kind":"concern","score":-30,"summary":"Panics on malformed input"}} +{"metabox":"1","type":"attestation","subject":"src/parser.rs","issuer":"mailto:alice@example.com","created_at":"2026-02-24T10:00:00Z","id":"","body":{"kind":"concern","score":-30,"summary":"Panics on malformed input"}} ``` -With a span and author_type: +With a span and issuer_type: ```json -{"metabox":"1","type":"attestation","subject":"src/parser.rs","author":"alice@example.com","created_at":"2026-02-24T10:00:00Z","id":"","body":{"author_type":"human","kind":"concern","score":-30,"span":{"start":{"line":42},"end":{"line":42}},"summary":"Panics on malformed input"}} +{"metabox":"1","type":"attestation","subject":"src/parser.rs","issuer":"mailto:alice@example.com","issuer_type":"human","created_at":"2026-02-24T10:00:00Z","id":"","body":{"kind":"concern","score":-30,"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`), and body fields appear in alphabetical order. +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 @@ -401,8 +403,8 @@ All layouts are backwards-compatible and can coexist in the same project. **Example (mixed record types):** ```jsonl -{"metabox":"1","type":"attestation","subject":"src/parser.rs","author":"alice@example.com","created_at":"2026-02-24T10:00:00Z","id":"a1b2c3d4...","body":{"author_type":"human","kind":"concern","ref":"git:3aba500","score":-30,"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":"attestation","subject":"src/parser.rs","author":"bob@example.com","created_at":"2026-02-24T11:00:00Z","id":"e5f6a7b8...","body":{"author_type":"human","kind":"praise","score":40,"summary":"Excellent property-based test coverage","tags":["testing"]}} +{"metabox":"1","type":"attestation","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","score":-30,"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":"attestation","subject":"src/parser.rs","issuer":"mailto:bob@example.com","issuer_type":"human","created_at":"2026-02-24T11:00:00Z","id":"e5f6a7b8...","body":{"kind":"praise","score":40,"summary":"Excellent property-based test coverage","tags":["testing"]}} ``` ## 3. Record Type Specifications @@ -421,18 +423,18 @@ Body fields (alphabetical): | Field | Type | Required | Description | |---------------|----------|----------|-------------| -| `author_type` | string | no | Always `"tool"` for epochs | | `refs` | string[] | yes | IDs of the compacted records | | `score` | integer | yes | Raw score at compaction time | | `span` | object | no | Sub-artifact range | | `summary` | string | yes | `"Compacted from N records"` | -Epoch records MUST set `author` to `"qualifier/compact"`. +Epoch records MUST set `issuer` to `"urn:qualifier:compact"` and +`issuer_type` to `"tool"` (in the envelope). **Example:** ```json -{"metabox":"1","type":"epoch","subject":"src/parser.rs","author":"qualifier/compact","created_at":"2026-02-25T12:00:00Z","id":"f9e8d7c6...","body":{"author_type":"tool","refs":["a1b2...","c3d4..."],"score":10,"summary":"Compacted from 12 records"}} +{"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..."],"score":10,"summary":"Compacted from 12 records"}} ``` Epoch records are treated as normal scored records by the scoring engine. The @@ -476,7 +478,7 @@ Body fields: **Example:** ```json -{"metabox":"1","type":"dependency","subject":"bin/server","author":"build-system","created_at":"2026-02-25T10:00:00Z","id":"1a2b3c4d...","body":{"depends_on":["lib/auth","lib/http","lib/db"]}} +{"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 MUST be a DAG. Implementations MUST detect and reject @@ -504,7 +506,7 @@ 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: ```json -{"metabox":"1","type":"https://example.com/qualifier/license/v1","subject":"src/parser.rs","author":"license-scanner","created_at":"...","id":"...","body":{"license":"MIT"}} +{"metabox":"1","type":"https://example.com/qualifier/license/v1","subject":"src/parser.rs","issuer":"https://license-scanner.example.com","created_at":"...","id":"...","body":{"license":"MIT"}} ``` Types defined in this spec use short aliases (`attestation`, `epoch`, @@ -608,8 +610,8 @@ predicates for use with DSSE signing and Sigstore distribution. "span": {"start": {"line": 42}, "end": {"line": 58}}, "summary": "Panics on malformed input", "tags": ["robustness"], - "author": "alice@example.com", - "author_type": "human", + "issuer": "mailto:alice@example.com", + "issuer_type": "human", "created_at": "2026-02-25T10:00:00Z", "ref": "git:3aba500", "supersedes": null @@ -624,7 +626,8 @@ predicates for use with DSSE signing and Sigstore distribution. | `subject` | `subject[0].name` | | `body.span` | `predicate.span` | | `id` | `predicate.qualifier_id` | -| `author` | `predicate.author` (also DSSE signer) | +| `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 @@ -653,8 +656,8 @@ SARIF v2.1.0 results can be converted to qualifier attestations: | `result.ruleId` | `body.kind` (as custom kind) | | `result.level` | `body.score` (see mapping below) | | `result.message.text` | `body.summary` | -| `run.tool.driver.name` | `author` | -| (constant) | `body.author_type: "tool"` | +| `run.tool.driver.name` | `issuer` | +| (constant) | `issuer_type: "tool"` (envelope) | **Level-to-score mapping:** @@ -698,7 +701,7 @@ qualifier attest src/parser.rs \ --suggested-fix "Use proper error propagation" \ --tag robustness \ --tag error-handling \ - --author "alice@example.com" \ + --issuer "mailto:alice@example.com" \ --span 42:58 ``` @@ -725,7 +728,7 @@ given kind (see section 2.7.1). `--file ` writes the attestation to a specific `.qual` file instead of using the default layout resolution. -When `--author` is omitted, defaults to the VCS user identity (see 8.4). +When `--issuer` is omitted, defaults to the VCS user identity (see 8.4). ### 6.3 `qualifier show` @@ -808,7 +811,7 @@ Qualifier uses layered configuration. Precedence (highest wins): | Key | CLI flag | Env var | Default | |-------------|----------------|----------------------|---------| | `graph` | `--graph` | `QUALIFIER_GRAPH` | `qualifier.graph.jsonl` | -| `author` | `--author` | `QUALIFIER_AUTHOR` | VCS identity (see 8.4) | +| `issuer` | `--issuer` | `QUALIFIER_ISSUER` | VCS identity (see 8.4) | | `format` | `--format` | `QUALIFIER_FORMAT` | `human` | | `min_score` | `--min-score` | `QUALIFIER_MIN_SCORE`| `0` | @@ -843,6 +846,7 @@ impl Record { pub fn score(&self) -> Option; // Attestation | Epoch pub fn supersedes(&self) -> Option<&str>; // Attestation only pub fn kind(&self) -> Option<&Kind>; // Attestation only + pub fn issuer_type(&self) -> Option<&IssuerType>; pub fn as_attestation(&self) -> Option<&Attestation>; pub fn as_epoch(&self) -> Option<&Epoch>; pub fn is_scored(&self) -> bool; // Attestation | Epoch @@ -852,14 +856,14 @@ pub struct Attestation { pub metabox: String, // always "1" pub record_type: String, // "attestation" pub subject: String, - pub author: String, + pub issuer: String, + pub issuer_type: Option, pub created_at: DateTime, pub id: String, pub body: AttestationBody, } pub struct AttestationBody { - pub author_type: Option, pub detail: Option, pub kind: Kind, pub r#ref: Option, @@ -875,14 +879,14 @@ pub struct Epoch { pub metabox: String, // always "1" pub record_type: String, // "epoch" pub subject: String, - pub author: String, + pub issuer: String, + pub issuer_type: Option, pub created_at: DateTime, pub id: String, pub body: EpochBody, } pub struct EpochBody { - pub author_type: Option, pub refs: Vec, pub score: i32, pub span: Option, @@ -893,7 +897,8 @@ pub struct DependencyRecord { pub metabox: String, // always "1" pub record_type: String, // "dependency" pub subject: String, - pub author: String, + pub issuer: String, + pub issuer_type: Option, pub created_at: DateTime, pub id: String, pub body: DependencyBody, @@ -914,7 +919,7 @@ pub struct Position { } pub enum Kind { Pass, Fail, Blocker, Concern, Praise, Suggestion, Waiver, Custom(String) } -pub enum AuthorType { Human, Ai, Tool, Unknown } +pub enum IssuerType { Human, Ai, Tool, Unknown } pub fn generate_id(attestation: &Attestation) -> String; pub fn generate_epoch_id(epoch: &Epoch) -> String; @@ -971,13 +976,13 @@ Delegates to the underlying VCS blame/annotate command: - Mercurial: `hg annotate` - Fallback: not available (prints guidance) -### 8.4 Author Defaults +### 8.4 Issuer Defaults -When `--author` is omitted: +When `--issuer` is omitted: - Git: `git config user.email` - Mercurial: `hg config ui.username` -- Fallback: `$USER@localhost` +- Fallback: `mailto:$USER@localhost` ## 9. Agent Integration @@ -1014,7 +1019,7 @@ qualifier/ ├── qualifier.graph.jsonl # Example / self-hosted graph └── src/ ├── lib.rs # Public library API - ├── attestation.rs # Record types, body structs, Kind, AuthorType, validation + ├── attestation.rs # Record types, body structs, Kind, IssuerType, validation ├── qual_file.rs # .qual file parsing, appending, discovery ├── graph.rs # Dependency graph loading, cycle detection ├── scoring.rs # Raw + effective score computation diff --git a/scripts/fmt.sh b/scripts/fmt.sh new file mode 100755 index 0000000..2683cc2 --- /dev/null +++ b/scripts/fmt.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +echo "fmt: cargo fmt" +cargo fmt --all --manifest-path "$ROOT/Cargo.toml" + +echo "fmt: prettier (site/)" +cd "$ROOT/site" +npx --yes prettier --write --no-color "**/*.{md,css,json,js}" diff --git a/scripts/quality_gates.sh b/scripts/quality_gates.sh new file mode 100755 index 0000000..77e0bdb --- /dev/null +++ b/scripts/quality_gates.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# +# Run quality gates for the repository. +# +# Usage: +# scripts/quality_gates.sh [--verbose] [[-]gate ...] +# +# Gates: format, clippy, test, doc, site +# No args runs all gates. Prefix with - to exclude a gate. +# +# Options: +# --verbose Stream all output (useful for CI) +# +# Examples: +# scripts/quality_gates.sh # all gates +# scripts/quality_gates.sh test # just tests +# scripts/quality_gates.sh -site # everything except site build +# scripts/quality_gates.sh --verbose # all gates, full output +# + +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +# ── Colors (when stdout is a terminal) ──────────────────────────────────────── + +if [[ -t 1 ]]; then + _grn=$'\033[32m' _red=$'\033[31m' _bld=$'\033[1m' _dim=$'\033[2m' _rst=$'\033[0m' +else + _grn='' _red='' _bld='' _dim='' _rst='' +fi + +# ── Temp dir for captured output ────────────────────────────────────────────── + +_tmpdir=$(mktemp -d) +trap 'rm -rf "$_tmpdir"' EXIT + +# ── Gate definitions ────────────────────────────────────────────────────────── + +_all_gates=(format clippy test doc site) + +gate_format() { + echo "--- cargo fmt ---" + cargo fmt --all --manifest-path "$ROOT/Cargo.toml" --check 2>&1 + echo "--- prettier ---" + cd "$ROOT/site" + npx --yes prettier --check --no-color "**/*.{md,css,json,js}" 2>&1 +} + +gate_clippy() { + cargo clippy --workspace -- -D warnings 2>&1 +} + +gate_test() { + cargo test --workspace 2>&1 +} + +gate_doc() { + cargo doc --workspace --no-deps 2>&1 +} + +gate_site() { + cd "$ROOT/site" && pnpm run build 2>&1 +} + +# ── Runner ──────────────────────────────────────────────────────────────────── + +run_gate() { + local name=$1 + + if [[ $_verbose -eq 1 ]]; then + echo "${_bld}── $name ──${_rst}" + local start=$SECONDS + if "gate_$name"; then + local elapsed=$(( SECONDS - start )) + echo "${_grn}PASS${_rst}: $name (${elapsed}s)" + echo "" + return 0 + else + local elapsed=$(( SECONDS - start )) + echo "${_red}FAIL${_rst}: $name (${elapsed}s)" + echo "" + return 1 + fi + else + local logfile="$_tmpdir/$name.log" + printf "%s: " "$name" + local start=$SECONDS + if "gate_$name" > "$logfile" 2>&1; then + local elapsed=$(( SECONDS - start )) + echo "${_grn}PASS${_rst} (${elapsed}s)" + return 0 + else + local elapsed=$(( SECONDS - start )) + echo "${_red}FAIL${_rst} (${elapsed}s)" + tail -50 "$logfile" | sed 's/^/ /' + return 1 + fi + fi +} + +# ── Parse args ──────────────────────────────────────────────────────────────── + +_valid_gate() { + local name=$1 + for g in "${_all_gates[@]}"; do [[ "$name" == "$g" ]] && return 0; done + return 1 +} + +_verbose=0 +gates=() +excludes=() +for arg in "$@"; do + if [[ "$arg" == "--verbose" ]]; then + _verbose=1 + continue + elif [[ "$arg" == -* ]]; then + name="${arg#-}" + if ! _valid_gate "$name"; then + echo "Unknown gate: $name" + echo "Valid gates: ${_all_gates[*]}" + exit 2 + fi + excludes+=("$name") + else + if ! _valid_gate "$arg"; then + echo "Unknown gate: $arg" + echo "Valid gates: ${_all_gates[*]}" + exit 2 + fi + gates+=("$arg") + fi +done + +# Default: all gates +if [[ ${#gates[@]} -eq 0 ]]; then + gates=("${_all_gates[@]}") +fi + +# Apply exclusions +if [[ ${#excludes[@]} -gt 0 ]]; then + filtered=() + for g in "${gates[@]}"; do + excluded=0 + for e in "${excludes[@]}"; do + [[ "$g" == "$e" ]] && excluded=1 && break + done + [[ $excluded -eq 0 ]] && filtered+=("$g") + done + gates=("${filtered[@]}") +fi + +# ── Run gates ───────────────────────────────────────────────────────────────── + +passed=0 +total=${#gates[@]} + +echo "${_bld}Running: ${gates[*]}${_rst} ${_dim}(skip with -gate, e.g. -site)${_rst}" +echo "" + +for gate in "${gates[@]}"; do + if run_gate "$gate"; then + ((passed++)) + fi +done + +echo "" + +if [[ $passed -eq $total ]]; then + echo "${_grn}${passed}/${total} passed${_rst}" + exit 0 +else + echo "${_red}${passed}/${total} passed${_rst}" + exit 1 +fi diff --git a/site/css/playground.css b/site/css/playground.css index 9bd814e..0f5579e 100644 --- a/site/css/playground.css +++ b/site/css/playground.css @@ -69,7 +69,9 @@ padding: 0.2rem 0.5rem; cursor: pointer; line-height: 1; - transition: color 0.15s, border-color 0.15s; + transition: + color 0.15s, + border-color 0.15s; } .playground-mode-toggle:hover { diff --git a/site/css/style.css b/site/css/style.css index fd4d0e3..9e2474b 100644 --- a/site/css/style.css +++ b/site/css/style.css @@ -42,7 +42,8 @@ /* Typography */ --display: "Instrument Sans", "Inter", "Helvetica Neue", sans-serif; - --body: "Atkinson Hyperlegible Next", "Atkinson Hyperlegible", system-ui, sans-serif; + --body: + "Atkinson Hyperlegible Next", "Atkinson Hyperlegible", system-ui, sans-serif; --mono: "JetBrains Mono", "SF Mono", "Consolas", monospace; /* Grid paper pattern */ diff --git a/site/examples/src-auth.rs.qual b/site/examples/src-auth.rs.qual index 2c83add..e2001dc 100644 --- a/site/examples/src-auth.rs.qual +++ b/site/examples/src-auth.rs.qual @@ -1,7 +1,7 @@ -{"metabox":"1","type":"attestation","subject":"src/auth.rs","author":"alice@example.com","created_at":"2026-02-24T09:00:00Z","id":"c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4a5b6c7d8e9f0a1b2","body":{"author_type":"human","kind":"blocker","ref":"git:3aba500","score":-50,"summary":"SQL injection vulnerability in login handler","tags":["security","blocker"]}} -{"metabox":"1","type":"attestation","subject":"src/auth.rs","author":"ci-bot@example.com","created_at":"2026-02-24T12:00:00Z","id":"g1h2i3j4k5l6m7n8o9p0q1r2s3t4u5v6w7x8y9z0a1b2c3d4e5f6g7h8i9j0k1l2","body":{"author_type":"tool","kind":"pass","score":20,"summary":"Input validation tests pass","tags":["testing"]}} -{"metabox":"1","type":"attestation","subject":"lib/crypto","author":"alice@example.com","created_at":"2026-02-24T09:30:00Z","id":"d1d2d3d4d5d6d7d8d9d0e1e2e3e4e5e6e7e8e9e0f1f2f3f4f5f6f7f8f9f0a1a2","body":{"author_type":"human","kind":"concern","score":-20,"suggested_fix":"Migrate from SHA-1 to SHA-256 or BLAKE3","summary":"Using deprecated hash algorithm","tags":["security","deprecation"]}} -{"metabox":"1","type":"attestation","subject":"lib/http","author":"bob@example.com","created_at":"2026-02-24T10:30:00Z","id":"b1b2b3b4b5b6b7b8b9b0c1c2c3c4c5c6c7c8c9c0d1d2d3d4d5d6d7d8d9d0e1e2","body":{"author_type":"human","kind":"praise","score":30,"summary":"Clean API design with comprehensive error types","tags":["design","api"]}} -{"metabox":"1","type":"attestation","subject":"lib/http","author":"ci-bot@example.com","created_at":"2026-02-24T12:30:00Z","id":"h1h2h3h4h5h6h7h8h9h0i1i2i3i4i5i6i7i8i9i0j1j2j3j4j5j6j7j8j9j0k1k2","body":{"author_type":"tool","kind":"pass","score":20,"summary":"All integration tests pass","tags":["testing"]}} -{"metabox":"1","type":"attestation","subject":"bin/server","author":"ci-bot@example.com","created_at":"2026-02-24T13:00:00Z","id":"a2a3a4a5a6a7a8a9a0b1b2b3b4b5b6b7b8b9b0c1c2c3c4c5c6c7c8c9c0d1d2d3","body":{"author_type":"tool","kind":"pass","score":20,"summary":"End-to-end smoke tests pass","tags":["testing","e2e"]}} -{"metabox":"1","type":"attestation","subject":"bin/server","author":"dave@example.com","created_at":"2026-02-24T13:30:00Z","id":"e2e3e4e5e6e7e8e9e0f1f2f3f4f5f6f7f8f9f0a1a2a3a4a5a6a7a8a9a0b1b2b3","body":{"author_type":"human","kind":"praise","score":30,"summary":"Excellent observability with structured logging","tags":["observability"]}} +{"metabox":"1","type":"attestation","subject":"src/auth.rs","issuer":"mailto:alice@example.com","issuer_type":"human","created_at":"2026-02-24T09:00:00Z","id":"c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4a5b6c7d8e9f0a1b2","body":{"kind":"blocker","ref":"git:3aba500","score":-50,"summary":"SQL injection vulnerability in login handler","tags":["security","blocker"]}} +{"metabox":"1","type":"attestation","subject":"src/auth.rs","issuer":"https://ci.example.com","issuer_type":"tool","created_at":"2026-02-24T12:00:00Z","id":"g1h2i3j4k5l6m7n8o9p0q1r2s3t4u5v6w7x8y9z0a1b2c3d4e5f6g7h8i9j0k1l2","body":{"kind":"pass","score":20,"summary":"Input validation tests pass","tags":["testing"]}} +{"metabox":"1","type":"attestation","subject":"lib/crypto","issuer":"mailto:alice@example.com","issuer_type":"human","created_at":"2026-02-24T09:30:00Z","id":"d1d2d3d4d5d6d7d8d9d0e1e2e3e4e5e6e7e8e9e0f1f2f3f4f5f6f7f8f9f0a1a2","body":{"kind":"concern","score":-20,"suggested_fix":"Migrate from SHA-1 to SHA-256 or BLAKE3","summary":"Using deprecated hash algorithm","tags":["security","deprecation"]}} +{"metabox":"1","type":"attestation","subject":"lib/http","issuer":"mailto:bob@example.com","issuer_type":"human","created_at":"2026-02-24T10:30:00Z","id":"b1b2b3b4b5b6b7b8b9b0c1c2c3c4c5c6c7c8c9c0d1d2d3d4d5d6d7d8d9d0e1e2","body":{"kind":"praise","score":30,"summary":"Clean API design with comprehensive error types","tags":["design","api"]}} +{"metabox":"1","type":"attestation","subject":"lib/http","issuer":"https://ci.example.com","issuer_type":"tool","created_at":"2026-02-24T12:30:00Z","id":"h1h2h3h4h5h6h7h8h9h0i1i2i3i4i5i6i7i8i9i0j1j2j3j4j5j6j7j8j9j0k1k2","body":{"kind":"pass","score":20,"summary":"All integration tests pass","tags":["testing"]}} +{"metabox":"1","type":"attestation","subject":"bin/server","issuer":"https://ci.example.com","issuer_type":"tool","created_at":"2026-02-24T13:00:00Z","id":"a2a3a4a5a6a7a8a9a0b1b2b3b4b5b6b7b8b9b0c1c2c3c4c5c6c7c8c9c0d1d2d3","body":{"kind":"pass","score":20,"summary":"End-to-end smoke tests pass","tags":["testing","e2e"]}} +{"metabox":"1","type":"attestation","subject":"bin/server","issuer":"mailto:dave@example.com","issuer_type":"human","created_at":"2026-02-24T13:30:00Z","id":"e2e3e4e5e6e7e8e9e0f1f2f3f4f5f6f7f8f9f0a1a2a3a4a5a6a7a8a9a0b1b2b3","body":{"kind":"praise","score":30,"summary":"Excellent observability with structured logging","tags":["observability"]}} diff --git a/site/examples/src-parser.rs.qual b/site/examples/src-parser.rs.qual index ecb04bf..272973a 100644 --- a/site/examples/src-parser.rs.qual +++ b/site/examples/src-parser.rs.qual @@ -1,3 +1,3 @@ -{"metabox":"1","type":"attestation","subject":"src/parser.rs","author":"alice@example.com","created_at":"2026-02-24T10:00:00Z","id":"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2","body":{"author_type":"human","kind":"concern","ref":"git:3aba500","score":-30,"span":{"start":{"line":42},"end":{"line":58}},"suggested_fix":"Replace .unwrap() on line 42 with proper error propagation","summary":"Panics on malformed UTF-8 input instead of returning an error","tags":["robustness","error-handling"]}} -{"metabox":"1","type":"attestation","subject":"src/parser.rs","author":"bob@example.com","created_at":"2026-02-24T11:00:00Z","id":"e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6","body":{"author_type":"human","kind":"praise","score":40,"summary":"Excellent property-based test coverage","tags":["testing"]}} -{"metabox":"1","type":"attestation","subject":"src/parser.rs","author":"carol@example.com","created_at":"2026-02-24T14:00:00Z","id":"f1f2f3f4f5f6f7f8f9f0a1a2a3a4a5a6a7a8a9a0b1b2b3b4b5b6b7b8b9b0c1c2","body":{"author_type":"human","kind":"suggestion","score":-5,"suggested_fix":"Add a fuzz/ directory with cargo-fuzz targets for the parse() entry point","summary":"Consider adding fuzzing targets","tags":["testing","robustness"]}} +{"metabox":"1","type":"attestation","subject":"src/parser.rs","issuer":"mailto:alice@example.com","issuer_type":"human","created_at":"2026-02-24T10:00:00Z","id":"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2","body":{"kind":"concern","ref":"git:3aba500","score":-30,"span":{"start":{"line":42},"end":{"line":58}},"suggested_fix":"Replace .unwrap() on line 42 with proper error propagation","summary":"Panics on malformed UTF-8 input instead of returning an error","tags":["robustness","error-handling"]}} +{"metabox":"1","type":"attestation","subject":"src/parser.rs","issuer":"mailto:bob@example.com","issuer_type":"human","created_at":"2026-02-24T11:00:00Z","id":"e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6","body":{"kind":"praise","score":40,"summary":"Excellent property-based test coverage","tags":["testing"]}} +{"metabox":"1","type":"attestation","subject":"src/parser.rs","issuer":"mailto:carol@example.com","issuer_type":"human","created_at":"2026-02-24T14:00:00Z","id":"f1f2f3f4f5f6f7f8f9f0a1a2a3a4a5a6a7a8a9a0b1b2b3b4b5b6b7b8b9b0c1c2","body":{"kind":"suggestion","score":-5,"suggested_fix":"Add a fuzz/ directory with cargo-fuzz targets for the parse() entry point","summary":"Consider adding fuzzing targets","tags":["testing","robustness"]}} diff --git a/site/index.md b/site/index.md index f25657e..1b43825 100644 --- a/site/index.md +++ b/site/index.md @@ -102,7 +102,8 @@ Qualifier gives you a structured, VCS-friendly way to **record what you know abo src/auth.rs: pass +20, concern -10 → raw +10, eff -20 (limited by crypto) src/api.rs: praise +30 → raw +30, eff +10 (limited by log) bin/server: pass +20, praise +30 → raw +50, eff -20 (limited by auth) - --> + +--> @@ -178,22 +179,22 @@ Qualifier gives you a structured, VCS-friendly way to **record what you know abo ## What Qualifier adds -| What | Without Qualifier | With Qualifier | -| ------------------------ | ----------------------------- | --------------------------------------------------- | -| Quality tracking | Spreadsheets, tickets, memory | Structured `.qual` files in your repo | -| Score propagation | Manual dependency analysis | Automatic through the dependency graph | -| CI gating | Custom scripts | `qualifier check --min-score 0` | -| Agent integration | None | JSON output, batch attestation, suggested fixes | -| Merge conflicts | Guaranteed with shared files | Structurally impossible (append-only JSONL) | -| History | Lost in ticket graveyards | VCS-native — blame, diff, bisect all work | +| What | Without Qualifier | With Qualifier | +| ----------------- | ----------------------------- | ----------------------------------------------- | +| Quality tracking | Spreadsheets, tickets, memory | Structured `.qual` files in your repo | +| Score propagation | Manual dependency analysis | Automatic through the dependency graph | +| CI gating | Custom scripts | `qualifier check --min-score 0` | +| Agent integration | None | JSON output, batch attestation, suggested fixes | +| Merge conflicts | Guaranteed with shared files | Structurally impossible (append-only JSONL) | +| History | Lost in ticket graveyards | VCS-native — blame, diff, bisect all work | ## Minimal example A `.qual` file is just JSONL — one attestation per line: ```jsonl -{"metabox":"1","type":"attestation","subject":"src/parser.rs","author":"alice@example.com","created_at":"2026-02-24T10:00:00Z","id":"a1b2c3d4...","body":{"kind":"concern","score":-30,"summary":"Panics on malformed UTF-8 input"}} -{"metabox":"1","type":"attestation","subject":"src/parser.rs","author":"bob@example.com","created_at":"2026-02-24T11:00:00Z","id":"e5f6a7b8...","body":{"kind":"praise","score":40,"summary":"Excellent property-based test coverage"}} +{"metabox":"1","type":"attestation","subject":"src/parser.rs","issuer":"mailto:alice@example.com","created_at":"2026-02-24T10:00:00Z","id":"a1b2c3d4...","body":{"kind":"concern","score":-30,"summary":"Panics on malformed UTF-8 input"}} +{"metabox":"1","type":"attestation","subject":"src/parser.rs","issuer":"mailto:bob@example.com","created_at":"2026-02-24T11:00:00Z","id":"e5f6a7b8...","body":{"kind":"praise","score":40,"summary":"Excellent property-based test coverage"}} ``` No parents, no headers, no schema declarations. Each line is self-contained. @@ -243,11 +244,11 @@ qualifier ls --below 0 Qualifier is a Rust crate with a library and a CLI: -| Component | What it does | -| ------------------ | ---------------------------------------------------------- | -| `.qual` files | VCS-friendly JSONL attestations — the primary interface | -| `qualifier` CLI | Human-friendly commands for attesting, scoring, gating | -| `qualifier` crate | Library API for tools, agents, and editor plugins | -| Dependency graph | `qualifier.graph.jsonl` — feeds the propagation engine | +| Component | What it does | +| ----------------- | ------------------------------------------------------- | +| `.qual` files | VCS-friendly JSONL attestations — the primary interface | +| `qualifier` CLI | Human-friendly commands for attesting, scoring, gating | +| `qualifier` crate | Library API for tools, agents, and editor plugins | +| Dependency graph | `qualifier.graph.jsonl` — feeds the propagation engine | See [Format](/format/) for the file spec or [CLI](/cli/) for command reference. diff --git a/site/js/playground.js b/site/js/playground.js index 2fd91d1..5ca29a2 100644 --- a/site/js/playground.js +++ b/site/js/playground.js @@ -187,11 +187,11 @@ return createQualifierModule({ noInitialRun: true, instantiateWasm: function (imports, callback) { - WebAssembly.instantiate(compiledWasm, imports).then(function ( - instance, - ) { - callback(instance); - }); + WebAssembly.instantiate(compiledWasm, imports).then( + function (instance) { + callback(instance); + }, + ); return {}; }, print: function (text) { @@ -251,20 +251,12 @@ lines.push(copperBold("QUALIFIER PLAYGROUND")); lines.push(""); lines.push(bold("Shell builtins:")); + lines.push(" " + copperBold(pad("ls", 38, true)) + "List files"); lines.push( - " " + copperBold(pad("ls", 38, true)) + "List files", - ); - lines.push( - " " + - copperBold(pad("cat ", 38, true)) + - "Display file contents", - ); - lines.push( - " " + copperBold(pad("clear", 38, true)) + "Clear terminal", - ); - lines.push( - " " + copperBold(pad("help", 38, true)) + "Show this message", + " " + copperBold(pad("cat ", 38, true)) + "Display file contents", ); + lines.push(" " + copperBold(pad("clear", 38, true)) + "Clear terminal"); + lines.push(" " + copperBold(pad("help", 38, true)) + "Show this message"); lines.push(""); lines.push(bold("CLI commands:")); lines.push( @@ -288,9 +280,7 @@ "List artifacts by score", ); lines.push( - " " + - copperBold(pad("qualifier --help", 38, true)) + - "Full CLI usage", + " " + copperBold(pad("qualifier --help", 38, true)) + "Full CLI usage", ); lines.push(""); lines.push(dim("Files: ") + fileList); @@ -346,8 +336,7 @@ // cargo install qualifier — special case for boot if (cmd === "cargo" && args[1] === "install" && args[2] === "qualifier") { return { - output: - dim(" qualifier is already installed."), + output: dim(" qualifier is already installed."), }; } @@ -625,8 +614,7 @@ // Show pinned header if scrolled past the original command if (viewport && viewport.scrollTop > 30) { this.pinnedEl.style.display = ""; - this.pinnedEl.querySelector(".pinned-text").textContent = - this.pinnedText; + this.pinnedEl.querySelector(".pinned-text").textContent = this.pinnedText; } else { this.pinnedEl.style.display = "none"; } @@ -1303,10 +1291,7 @@ this.buffer = this.buffer.substring(0, this.cursor) + this.buffer.substring(this.cursor + 1); - if ( - this.cursor >= this.buffer.length && - this.buffer.length > 0 - ) + if (this.cursor >= this.buffer.length && this.buffer.length > 0) this.cursor = this.buffer.length - 1; this.refreshLine(); } diff --git a/site/pages/cli.md b/site/pages/cli.md index 5f90da5..38a15c9 100644 --- a/site/pages/cli.md +++ b/site/pages/cli.md @@ -49,7 +49,7 @@ qualifier attest src/parser.rs --kind concern --score -30 \ --summary "Panics on malformed UTF-8 input" \ --suggested-fix "Replace .unwrap() on line 42 with error propagation" \ --tag robustness --tag error-handling \ - --author "alice@example.com" + --issuer "mailto:alice@example.com" ``` ### See scores for all artifacts @@ -157,11 +157,10 @@ cat attestations.jsonl | qualifier attest --stdin Qualifier uses layered configuration (highest wins): -| Priority | Source | Example | -| -------- | ------------------ | ------------------------------------------ | -| 1 | CLI flags | `--graph path/to/graph.jsonl` | -| 2 | Environment | `QUALIFIER_GRAPH`, `QUALIFIER_AUTHOR` | -| 3 | Project config | `.qualifier.toml` | -| 4 | User config | `~/.config/qualifier/config.toml` | -| 5 | Built-in defaults | | - +| Priority | Source | Example | +| -------- | ----------------- | ------------------------------------- | +| 1 | CLI flags | `--graph path/to/graph.jsonl` | +| 2 | Environment | `QUALIFIER_GRAPH`, `QUALIFIER_ISSUER` | +| 3 | Project config | `.qualifier.toml` | +| 4 | User config | `~/.config/qualifier/config.toml` | +| 5 | Built-in defaults | | diff --git a/site/pages/format.md b/site/pages/format.md index 657f3b2..fc656d0 100644 --- a/site/pages/format.md +++ b/site/pages/format.md @@ -14,19 +14,19 @@ Append-only JSONL. One record per line. VCS-native by design. 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). ```jsonl -{"metabox":"1","type":"attestation","subject":"src/parser.rs","author":"alice@example.com","created_at":"2026-02-24T10:00:00Z","id":"a1b2c3d4...","body":{"author_type":"human","kind":"concern","ref":"git:3aba500","score":-30,"summary":"Panics on malformed input"}} -{"metabox":"1","type":"attestation","subject":"src/parser.rs","author":"bob@example.com","created_at":"2026-02-24T11:00:00Z","id":"e5f6a7b8...","body":{"author_type":"human","kind":"praise","score":40,"summary":"Excellent test coverage"}} +{"metabox":"1","type":"attestation","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","score":-30,"summary":"Panics on malformed input"}} +{"metabox":"1","type":"attestation","subject":"src/parser.rs","issuer":"mailto:bob@example.com","issuer_type":"human","created_at":"2026-02-24T11:00:00Z","id":"e5f6a7b8...","body":{"kind":"praise","score":40,"summary":"Excellent test coverage"}} ``` ## Record types Every record has a `type` field that identifies its schema. Qualifier defines three record types: -| Type | Description | -| ------------- | ---------------------------------------- | -| `attestation` | A quality signal (the primary type) | -| `epoch` | A compaction snapshot | -| `dependency` | A dependency edge between subjects | +| Type | Description | +| ------------- | ----------------------------------- | +| `attestation` | A quality signal (the primary type) | +| `epoch` | A compaction snapshot | +| `dependency` | A dependency edge between subjects | When `type` is omitted, it defaults to `"attestation"`. Unknown types are preserved as opaque pass-through data. @@ -34,46 +34,46 @@ When `type` is omitted, it defaults to `"attestation"`. Unknown types are preser All record types share a common **Metabox envelope** — a fixed set of fields that answer "who said what about which subject, when", plus a type-specific `body` object. The record envelope is an instance of the [Metabox](/metabox/) envelope format. -| Field | Type | Required | Description | -| ------------ | ------- | -------- | ------------------------------------------------ | -| `metabox` | string | yes | Envelope version (always `"1"`) | -| `type` | string | yes* | Record type identifier. *Defaults to `"attestation"`. | -| `subject` | string | yes | Qualified name of the target artifact | -| `author` | string | yes | Who or what created this record | -| `created_at` | string | yes | RFC 3339 timestamp | -| `id` | string | yes | Content-addressed BLAKE3 hash | -| `body` | object | yes | Type-specific payload | +| Field | Type | Required | Description | +| ------------- | ------ | -------- | ------------------------------------------------------ | +| `metabox` | string | yes | Envelope version (always `"1"`) | +| `type` | string | yes\* | Record type identifier. \*Defaults to `"attestation"`. | +| `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 | +| `body` | object | yes | Type-specific payload | ## Attestation schema Attestations are the primary record type. Envelope fields plus body: -| Field | Type | Required | Description | -| --------------- | -------- | -------- | ------------------------------------------------ | -| `author_type` | enum | no | Author classification: human, ai, tool, unknown | -| `detail` | string | no | Extended description (markdown allowed) | -| `kind` | enum | yes | Type of attestation (see below) | -| `ref` | string | no | VCS ref pin (e.g. "git:3aba500"), opaque string | -| `score` | integer | yes | Signed quality delta, -100..100 | -| `span` | object | no | Sub-artifact range (line/col addressing) | -| `suggested_fix` | string | no | Actionable suggestion for improvement | -| `summary` | string | yes | Human-readable one-liner | -| `supersedes` | string | no | ID of a prior attestation this replaces | -| `tags` | string[] | no | Freeform classification tags | +| Field | Type | Required | Description | +| --------------- | -------- | -------- | ----------------------------------------------- | +| `detail` | string | no | Extended description (markdown allowed) | +| `kind` | enum | yes | Type of attestation (see below) | +| `ref` | string | no | VCS ref pin (e.g. "git:3aba500"), opaque string | +| `score` | integer | yes | Signed quality delta, -100..100 | +| `span` | object | no | Sub-artifact range (line/col addressing) | +| `suggested_fix` | string | no | Actionable suggestion for improvement | +| `summary` | string | yes | Human-readable one-liner | +| `supersedes` | string | no | ID of a prior attestation this replaces | +| `tags` | string[] | no | Freeform classification tags | Body fields are in alphabetical order (MCF canonical form). ## Attestation kinds -| Kind | Default Score | Meaning | -| ------------ | ------------- | ---------------------------------------------- | -| `pass` | +20 | Meets a stated quality bar | -| `fail` | -20 | Does NOT meet a stated quality bar | -| `blocker` | -50 | Blocking issue, must resolve before release | -| `concern` | -10 | Non-blocking issue worth tracking | -| `praise` | +30 | Positive recognition of quality | -| `suggestion` | -5 | Proposed improvement (often with suggested_fix)| -| `waiver` | +10 | Acknowledged issue, explicitly accepted | +| Kind | Default Score | Meaning | +| ------------ | ------------- | ----------------------------------------------- | +| `pass` | +20 | Meets a stated quality bar | +| `fail` | -20 | Does NOT meet a stated quality bar | +| `blocker` | -50 | Blocking issue, must resolve before release | +| `concern` | -10 | Non-blocking issue worth tracking | +| `praise` | +30 | Positive recognition of quality | +| `suggestion` | -5 | Proposed improvement (often with suggested_fix) | +| `waiver` | +10 | Acknowledged issue, explicitly accepted | When `--score` is omitted from `qualifier attest`, the CLI uses the default score for the given kind. @@ -81,30 +81,50 @@ When `--score` is omitted from `qualifier attest`, the CLI uses the default scor An **epoch** is a compaction snapshot — a synthetic record that replaces a set of attestations with a single scored record preserving the net score. Envelope fields plus body: -| Field | Type | Required | Description | -| ------------- | -------- | -------- | ------------------------------------------------- | -| `author_type` | enum | no | Always `"tool"` for epochs | -| `refs` | string[] | yes | IDs of the compacted records | -| `score` | integer | yes | Net score at compaction time | -| `span` | object | no | Sub-artifact range | -| `summary` | string | yes | `"Compacted from N records"` | +| Field | Type | Required | Description | +| --------- | -------- | -------- | ---------------------------- | +| `refs` | string[] | yes | IDs of the compacted records | +| `score` | integer | yes | Net score at compaction time | +| `span` | object | no | Sub-artifact range | +| `summary` | string | yes | `"Compacted from N records"` | -Epoch `author` is always `"qualifier/compact"`. +Epoch `issuer` is always `"urn:qualifier:compact"`. ```json -{"metabox":"1","type":"epoch","subject":"src/parser.rs","author":"qualifier/compact","created_at":"2026-02-25T12:00:00Z","id":"f9e8d7c6...","body":{"author_type":"tool","refs":["a1b2...","c3d4..."],"score":10,"summary":"Compacted from 12 records"}} +{ + "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..."], + "score": 10, + "summary": "Compacted from 12 records" + } +} ``` ## Dependency schema A **dependency** record declares directed edges from one subject to others. Envelope fields plus body: -| Field | Type | Required | Description | -| ------------ | -------- | -------- | -------------------------------------------------- | -| `depends_on` | string[] | yes | Subject names this subject depends on | +| Field | Type | Required | Description | +| ------------ | -------- | -------- | ------------------------------------- | +| `depends_on` | string[] | yes | Subject names this subject depends on | ```json -{"metabox":"1","type":"dependency","subject":"bin/server","author":"build-system","created_at":"2026-02-25T10:00:00Z","id":"1a2b3c4d...","body":{"depends_on":["lib/auth","lib/http"]}} +{ + "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"] } +} ``` Dependency records don't carry scores. They feed the propagation engine that computes effective scores. @@ -118,10 +138,22 @@ Attestations are immutable. To "update" a signal, write a new attestation with ` Record IDs are BLAKE3 hashes of the **Metabox Canonical Form (MCF)** — a deterministic JSON serialization with fixed envelope field order, alphabetical body field order, no whitespace, and `id` set to `""` during hashing. ```json -{"metabox":"1","type":"attestation","subject":"src/parser.rs","author":"alice@example.com","created_at":"2026-02-24T10:00:00Z","id":"","body":{"kind":"concern","score":-30,"summary":"Panics on malformed input"}} +{ + "metabox": "1", + "type": "attestation", + "subject": "src/parser.rs", + "issuer": "mailto:alice@example.com", + "created_at": "2026-02-24T10:00:00Z", + "id": "", + "body": { + "kind": "concern", + "score": -30, + "summary": "Panics on malformed input" + } +} ``` -Optional fields (`span`, `detail`, `suggested_fix`, `tags`, `author_type`, `ref`, `supersedes`) are omitted from the canonical form when absent — the hash changes only when a field is actually present. +Optional body fields (`span`, `detail`, `suggested_fix`, `tags`, `ref`, `supersedes`) and the optional envelope field `issuer_type` are omitted from the canonical form when absent — the hash changes only when a field is actually present. This ensures identical records always produce identical IDs, regardless of implementation language. @@ -139,11 +171,11 @@ Compaction MUST NOT change the raw score. If it does, the implementation has a b ## File placement -| Strategy | Example | Tradeoff | -| ---------------- | -------------------- | ---------------------------------- | -| Per-directory | `src/.qual` | Clean tree, good merge behavior | -| Per-file | `src/parser.rs.qual` | Maximum merge isolation | -| Per-project | `.qual` at root | Simplest setup, more contention | +| Strategy | Example | Tradeoff | +| ------------- | -------------------- | ------------------------------- | +| Per-directory | `src/.qual` | Clean tree, good merge behavior | +| Per-file | `src/parser.rs.qual` | Maximum merge isolation | +| Per-project | `.qual` at root | Simplest setup, more contention | The recommended layout is one `.qual` file per directory. `qualifier attest` defaults to this. diff --git a/src/attestation.rs b/src/attestation.rs index 3352689..796d7bf 100644 --- a/src/attestation.rs +++ b/src/attestation.rs @@ -155,39 +155,39 @@ impl Kind { } } -// ─── AuthorType enum ──────────────────────────────────────────────────────── +// ─── IssuerType enum ──────────────────────────────────────────────────────── -/// Author classification for attestations. +/// Issuer classification for attestations. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum AuthorType { +pub enum IssuerType { Human, Ai, Tool, Unknown, } -impl fmt::Display for AuthorType { +impl fmt::Display for IssuerType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - AuthorType::Human => write!(f, "human"), - AuthorType::Ai => write!(f, "ai"), - AuthorType::Tool => write!(f, "tool"), - AuthorType::Unknown => write!(f, "unknown"), + IssuerType::Human => write!(f, "human"), + IssuerType::Ai => write!(f, "ai"), + IssuerType::Tool => write!(f, "tool"), + IssuerType::Unknown => write!(f, "unknown"), } } } -impl std::str::FromStr for AuthorType { +impl std::str::FromStr for IssuerType { type Err = String; fn from_str(s: &str) -> std::result::Result { match s { - "human" => Ok(AuthorType::Human), - "ai" => Ok(AuthorType::Ai), - "tool" => Ok(AuthorType::Tool), - "unknown" => Ok(AuthorType::Unknown), - other => Err(format!("unknown author_type: '{other}'")), + "human" => Ok(IssuerType::Human), + "ai" => Ok(IssuerType::Ai), + "tool" => Ok(IssuerType::Tool), + "unknown" => Ok(IssuerType::Unknown), + other => Err(format!("unknown issuer_type: '{other}'")), } } } @@ -197,8 +197,6 @@ impl std::str::FromStr for AuthorType { /// Attestation body fields. Field order is alphabetical (MCF canonical form). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AttestationBody { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub author_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub detail: Option, pub kind: Kind, @@ -219,8 +217,6 @@ pub struct AttestationBody { /// Epoch body fields. Field order is alphabetical (MCF canonical form). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct EpochBody { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub author_type: Option, pub refs: Vec, pub score: i32, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -247,7 +243,7 @@ fn default_metabox() -> String { /// A quality attestation against a software artifact (Metabox envelope). /// /// **IMPORTANT:** Envelope field order is fixed: metabox, type, subject, -/// author, created_at, id, body. Body fields are alphabetical (MCF). +/// issuer, issuer_type, created_at, id, body. Body fields are alphabetical (MCF). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Attestation { /// Metabox envelope version. Always "1". @@ -261,8 +257,12 @@ pub struct Attestation { /// Qualified name of the subject (artifact). pub subject: String, - /// Who or what created this attestation. - pub author: String, + /// Who or what created this attestation (URI). + pub issuer: String, + + /// Issuer classification (human, ai, tool, unknown). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issuer_type: Option, /// When this attestation was created (RFC 3339). pub created_at: DateTime, @@ -285,7 +285,9 @@ pub struct Epoch { #[serde(rename = "type")] pub record_type: String, pub subject: String, - pub author: String, + pub issuer: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issuer_type: Option, pub created_at: DateTime, pub id: String, pub body: EpochBody, @@ -301,7 +303,9 @@ pub struct DependencyRecord { #[serde(rename = "type")] pub record_type: String, pub subject: String, - pub author: String, + pub issuer: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issuer_type: Option, pub created_at: DateTime, pub id: String, pub body: DependencyBody, @@ -420,6 +424,16 @@ impl Record { } } + /// Get the issuer type classification. + pub fn issuer_type(&self) -> Option<&IssuerType> { + match self { + Record::Attestation(a) => a.issuer_type.as_ref(), + Record::Epoch(e) => e.issuer_type.as_ref(), + Record::Dependency(d) => d.issuer_type.as_ref(), + Record::Unknown(_) => None, + } + } + /// Returns true if this is a scored record type (attestation or epoch). pub fn is_scored(&self) -> bool { matches!(self, Record::Attestation(_) | Record::Epoch(_)) @@ -435,7 +449,9 @@ struct AttestationCanonicalView<'a> { metabox: &'a str, r#type: &'a str, subject: &'a str, - author: &'a str, + issuer: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + issuer_type: Option<&'a IssuerType>, created_at: &'a DateTime, id: &'a str, body: &'a AttestationBody, @@ -447,7 +463,9 @@ struct EpochCanonicalView<'a> { metabox: &'a str, r#type: &'a str, subject: &'a str, - author: &'a str, + issuer: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + issuer_type: Option<&'a IssuerType>, created_at: &'a DateTime, id: &'a str, body: &'a EpochBody, @@ -459,7 +477,9 @@ struct DependencyCanonicalView<'a> { metabox: &'a str, r#type: &'a str, subject: &'a str, - author: &'a str, + issuer: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + issuer_type: Option<&'a IssuerType>, created_at: &'a DateTime, id: &'a str, body: &'a DependencyBody, @@ -474,7 +494,8 @@ pub fn generate_id(attestation: &Attestation) -> String { metabox: &attestation.metabox, r#type: "attestation", subject: &attestation.subject, - author: &attestation.author, + issuer: &attestation.issuer, + issuer_type: attestation.issuer_type.as_ref(), created_at: &attestation.created_at, id: "", body: &attestation.body, @@ -489,7 +510,8 @@ pub fn generate_epoch_id(epoch: &Epoch) -> String { metabox: &epoch.metabox, r#type: "epoch", subject: &epoch.subject, - author: &epoch.author, + issuer: &epoch.issuer, + issuer_type: epoch.issuer_type.as_ref(), created_at: &epoch.created_at, id: "", body: &epoch.body, @@ -504,7 +526,8 @@ pub fn generate_dependency_id(dep: &DependencyRecord) -> String { metabox: &dep.metabox, r#type: "dependency", subject: &dep.subject, - author: &dep.author, + issuer: &dep.issuer, + issuer_type: dep.issuer_type.as_ref(), created_at: &dep.created_at, id: "", body: &dep.body, @@ -541,8 +564,10 @@ pub fn validate(attestation: &Attestation) -> Vec { if attestation.body.summary.is_empty() { errors.push("summary must not be empty".into()); } - if attestation.author.is_empty() { - errors.push("author must not be empty".into()); + if attestation.issuer.is_empty() { + errors.push("issuer must not be empty".into()); + } else if !attestation.issuer.contains(':') { + errors.push("issuer must be a URI (e.g. mailto:user@example.com)".into()); } if attestation.body.score < -100 || attestation.body.score > 100 { errors.push(format!( @@ -769,13 +794,13 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "src/parser.rs".into(), - author: "alice@example.com".into(), + issuer: "mailto:alice@example.com".into(), + issuer_type: None, created_at: DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Concern, r#ref: None, @@ -823,11 +848,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: String::new(), - author: String::new(), + issuer: String::new(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -842,7 +867,7 @@ mod tests { let errors = validate(&att); assert!(errors.iter().any(|e| e.contains("subject"))); assert!(errors.iter().any(|e| e.contains("summary"))); - assert!(errors.iter().any(|e| e.contains("author"))); + assert!(errors.iter().any(|e| e.contains("issuer"))); assert!(errors.iter().any(|e| e.contains("id"))); } @@ -878,11 +903,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "test".into(), - author: "bot".into(), + issuer: "mailto:bot@localhost".into(), + issuer_type: None, created_at: Utc::now(), id: "will be replaced".into(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -906,11 +931,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "test.rs".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Concern, r#ref: None, @@ -949,11 +974,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "x.rs".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: now, id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Concern, r#ref: None, @@ -970,11 +995,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "x.rs".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: now, id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Concern, r#ref: None, @@ -1003,11 +1028,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "x".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: now, id: "aaa".into(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1023,11 +1048,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "x".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: now, id: "bbb".into(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1120,11 +1145,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "foo.rs".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1141,11 +1166,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "bar.rs".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1168,11 +1193,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "foo.rs".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Concern, r#ref: None, @@ -1189,11 +1214,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "foo.rs".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1224,11 +1249,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "test.rs".into(), - author: "test@test.com".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1254,11 +1279,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "x.rs".into(), - author: "test@test.com".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: now, id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1271,15 +1296,15 @@ mod tests { }, }); - let with_author_type = finalize(Attestation { + let with_issuer_type = finalize(Attestation { metabox: "1".into(), record_type: "attestation".into(), subject: "x.rs".into(), - author: "test@test.com".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: Some(IssuerType::Human), created_at: now, id: String::new(), body: AttestationBody { - author_type: Some(AuthorType::Human), detail: None, kind: Kind::Pass, r#ref: None, @@ -1296,11 +1321,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "x.rs".into(), - author: "test@test.com".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: now, id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: Some("git:abc123".into()), @@ -1314,9 +1339,9 @@ mod tests { }); assert_eq!(base.metabox, "1"); - assert_ne!(base.id, with_author_type.id, "author_type should affect ID"); + assert_ne!(base.id, with_issuer_type.id, "issuer_type should affect ID"); assert_ne!(base.id, with_ref.id, "ref should affect ID"); - assert_ne!(with_author_type.id, with_ref.id); + assert_ne!(with_issuer_type.id, with_ref.id); } #[test] @@ -1325,11 +1350,11 @@ mod tests { metabox: "99".into(), record_type: "attestation".into(), subject: "x.rs".into(), - author: "test@test.com".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1358,13 +1383,13 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "x.rs".into(), - author: "alice@example.com".into(), + issuer: "mailto:alice@example.com".into(), + issuer_type: Some(IssuerType::Human), created_at: DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: Some(AuthorType::Human), detail: None, kind: Kind::Praise, r#ref: Some("git:3aba500".into()), @@ -1381,7 +1406,7 @@ mod tests { assert!(json.contains("\"metabox\":\"1\"")); assert!(json.contains("\"type\":\"attestation\"")); assert!(json.contains("\"body\"")); - assert!(json.contains("\"author_type\":\"human\"")); + assert!(json.contains("\"issuer_type\":\"human\"")); assert!(json.contains("\"ref\":\"git:3aba500\"")); let parsed: Attestation = serde_json::from_str(&json).unwrap(); @@ -1394,13 +1419,13 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "x.rs".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1422,7 +1447,7 @@ mod tests { #[test] fn test_record_type_defaults_to_attestation() { // JSON without "type" field should parse as attestation - let json = r#"{"metabox":"1","subject":"x.rs","author":"test","created_at":"2026-02-24T10:00:00Z","id":"abc","body":{"kind":"pass","score":10,"summary":"ok"}}"#; + let json = r#"{"metabox":"1","subject":"x.rs","issuer":"mailto:test@test.com","created_at":"2026-02-24T10:00:00Z","id":"abc","body":{"kind":"pass","score":10,"summary":"ok"}}"#; let record: Record = serde_json::from_str(json).unwrap(); assert!(record.as_attestation().is_some()); } @@ -1433,13 +1458,13 @@ mod tests { metabox: "1".into(), record_type: "epoch".into(), subject: "x.rs".into(), - author: "qualifier/compact".into(), + issuer: "urn:qualifier:compact".into(), + issuer_type: Some(IssuerType::Tool), created_at: DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: EpochBody { - author_type: Some(AuthorType::Tool), refs: vec!["aaa".into(), "bbb".into()], score: 30, span: None, @@ -1458,7 +1483,7 @@ mod tests { #[test] fn test_unknown_record_type_preserved() { - let json = r#"{"metabox":"1","type":"custom-thing","subject":"x.rs","author":"test","created_at":"2026-02-24T10:00:00Z","id":"abc","body":{"foo":"bar"}}"#; + let json = r#"{"metabox":"1","type":"custom-thing","subject":"x.rs","issuer":"mailto:test@test.com","created_at":"2026-02-24T10:00:00Z","id":"abc","body":{"foo":"bar"}}"#; let record: Record = serde_json::from_str(json).unwrap(); match record { Record::Unknown(v) => { @@ -1520,16 +1545,16 @@ mod tests { } #[test] - fn test_author_type_roundtrip() { + fn test_issuer_type_roundtrip() { let types = vec![ - AuthorType::Human, - AuthorType::Ai, - AuthorType::Tool, - AuthorType::Unknown, + IssuerType::Human, + IssuerType::Ai, + IssuerType::Tool, + IssuerType::Unknown, ]; for at in &types { let s = at.to_string(); - let parsed: AuthorType = s.parse().unwrap(); + let parsed: IssuerType = s.parse().unwrap(); assert_eq!(&parsed, at); } } diff --git a/src/cli/commands/attest.rs b/src/cli/commands/attest.rs index 58f0d2f..fb29614 100644 --- a/src/cli/commands/attest.rs +++ b/src/cli/commands/attest.rs @@ -3,7 +3,7 @@ use clap::Args as ClapArgs; use std::io::{self, BufRead}; use std::path::Path; -use crate::attestation::{self, Attestation, AttestationBody, AuthorType, Kind, Record}; +use crate::attestation::{self, Attestation, AttestationBody, IssuerType, Kind, Record}; use crate::qual_file; #[derive(ClapArgs)] @@ -35,13 +35,13 @@ pub struct Args { #[arg(long = "tag")] pub tags: Vec, - /// Author identity (defaults to VCS user) + /// Issuer identity URI (defaults to VCS user email with mailto:) #[arg(long)] - pub author: Option, + pub issuer: Option, - /// Author type (human, ai, tool, unknown) + /// Issuer type (human, ai, tool, unknown) #[arg(long)] - pub author_type: Option, + pub issuer_type: Option, /// Sub-artifact span (e.g., "42", "42:58", "42.5:58.80") #[arg(long)] @@ -91,13 +91,14 @@ pub fn run(args: Args) -> crate::Result<()> { } }; - let author = args - .author - .or_else(detect_author) - .unwrap_or_else(|| "unknown".into()); + let issuer = normalize_issuer_uri( + args.issuer + .or_else(detect_issuer) + .unwrap_or_else(|| "mailto:unknown@localhost".into()), + ); - let author_type = match &args.author_type { - Some(s) => Some(s.parse::().map_err(crate::Error::Validation)?), + let issuer_type = match &args.issuer_type { + Some(s) => Some(s.parse::().map_err(crate::Error::Validation)?), None => None, }; @@ -112,11 +113,11 @@ pub fn run(args: Args) -> crate::Result<()> { metabox: "1".into(), record_type: "attestation".into(), subject, - author, + issuer, + issuer_type, created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type, detail: args.detail, kind, r#ref: args.r#ref, @@ -203,7 +204,7 @@ fn run_batch() -> crate::Result<()> { Ok(()) } -fn detect_author() -> Option { +fn detect_issuer() -> Option { // Try git first std::process::Command::new("git") .args(["config", "user.email"]) @@ -212,6 +213,7 @@ fn detect_author() -> Option { .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .filter(|s| !s.is_empty()) + .map(|email| format!("mailto:{email}")) .or_else(|| { // Try hg std::process::Command::new("hg") @@ -221,10 +223,21 @@ fn detect_author() -> Option { .filter(|o| o.status.success()) .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .filter(|s| !s.is_empty()) + .map(|email| format!("mailto:{email}")) }) .or_else(|| { // Fallback: $USER@localhost let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into()); - Some(format!("{user}@localhost")) + Some(format!("mailto:{user}@localhost")) }) } + +/// Normalize an issuer value to a URI. Bare emails get `mailto:` prefix; +/// values already containing `:` are assumed to be valid URIs. +fn normalize_issuer_uri(issuer: String) -> String { + if issuer.contains(':') { + issuer + } else { + format!("mailto:{issuer}") + } +} diff --git a/src/cli/commands/praise.rs b/src/cli/commands/praise.rs index 69cdb43..3342284 100644 --- a/src/cli/commands/praise.rs +++ b/src/cli/commands/praise.rs @@ -84,16 +84,16 @@ fn run_records(args: Args) -> crate::Result<()> { att.body.summary, ); - // Line 2: author + date + truncated ID + (author_type) - let author_type_suffix = match &att.body.author_type { - Some(at) if *at != crate::attestation::AuthorType::Human => { + // Line 2: issuer + date + truncated ID + (issuer_type) + let issuer_type_suffix = match &att.issuer_type { + Some(at) if *at != crate::attestation::IssuerType::Human => { format!(" ({})", at) } _ => String::new(), }; println!( " {} {} {}{}", - att.author, date, id_short, author_type_suffix, + att.issuer, date, id_short, issuer_type_suffix, ); // Line 3 (optional): suggested_fix, detail, or span @@ -128,15 +128,15 @@ fn run_records(args: Args) -> crate::Result<()> { "epoch", epoch.body.summary, ); - let author_type_suffix = match &epoch.body.author_type { - Some(at) if *at != crate::attestation::AuthorType::Human => { + let issuer_type_suffix = match &epoch.issuer_type { + Some(at) if *at != crate::attestation::IssuerType::Human => { format!(" ({})", at) } _ => String::new(), }; println!( " {} {} {}{}", - epoch.author, date, id_short, author_type_suffix, + epoch.issuer, date, id_short, issuer_type_suffix, ); println!(); } @@ -159,11 +159,11 @@ fn record_to_json(record: &crate::attestation::Record) -> Option Option crate::Result<()> { for record in &active { if let Some(att) = record.as_attestation() { let date = att.created_at.format("%Y-%m-%d"); - let author_short = att.author.split('@').next().unwrap_or(&att.author); + let issuer_short = att + .issuer + .strip_prefix("mailto:") + .and_then(|e| e.split('@').next()) + .unwrap_or(&att.issuer); println!( " {} {} {:?} {} {}", output::format_score(att.body.score), att.body.kind, att.body.summary, - author_short, + issuer_short, date, ); } else if let Some(epoch) = record.as_epoch() { @@ -89,7 +93,7 @@ pub fn run(args: Args) -> crate::Result<()> { " {} epoch {:?} {} {}", output::format_score(epoch.body.score), epoch.body.summary, - epoch.author, + epoch.issuer, date, ); } diff --git a/src/cli/config.rs b/src/cli/config.rs index c665f5b..f848252 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -17,9 +17,9 @@ pub struct Config { #[serde(default = "default_graph_path")] pub graph: PathBuf, - /// Default author for attestations. + /// Default issuer for attestations. #[serde(default)] - pub author: Option, + pub issuer: Option, /// Default output format ("human" or "json"). #[serde(default = "default_format")] @@ -42,7 +42,7 @@ impl Default for Config { fn default() -> Self { Config { graph: default_graph_path(), - author: None, + issuer: None, format: default_format(), min_score: 0, } @@ -68,7 +68,7 @@ pub fn load(project_root: Option<&Path>) -> Config { figment = figment.merge(Toml::file(project_config)); } - // Environment variables: QUALIFIER_GRAPH, QUALIFIER_AUTHOR, etc. + // Environment variables: QUALIFIER_GRAPH, QUALIFIER_ISSUER, etc. figment = figment.merge(Env::prefixed("QUALIFIER_")); figment.extract().unwrap_or_default() diff --git a/src/compact.rs b/src/compact.rs index cb35265..ca5efc1 100644 --- a/src/compact.rs +++ b/src/compact.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use chrono::Utc; -use crate::attestation::{self, AuthorType, Epoch, EpochBody, Record}; +use crate::attestation::{self, Epoch, EpochBody, IssuerType, Record}; use crate::qual_file::QualFile; use crate::scoring; @@ -83,11 +83,11 @@ pub fn snapshot(qual_file: &QualFile) -> (QualFile, CompactResult) { metabox: "1".into(), record_type: "epoch".into(), subject: subject.to_string(), - author: "qualifier/compact".into(), + issuer: "urn:qualifier:compact".into(), + issuer_type: Some(IssuerType::Tool), created_at: Utc::now(), id: String::new(), body: EpochBody { - author_type: Some(AuthorType::Tool), refs, score: raw, span: None, @@ -131,13 +131,13 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: subject.into(), - author: "test@test.com".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind, r#ref: None, @@ -160,13 +160,13 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: subject.into(), - author: "test@test.com".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T11:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -260,7 +260,7 @@ mod tests { let epoch = snapped.records[0].as_epoch().unwrap(); assert_eq!(epoch.body.score, 30); // 40 + -10 - assert_eq!(epoch.author, "qualifier/compact"); + assert_eq!(epoch.issuer, "urn:qualifier:compact"); assert_eq!(epoch.body.refs.len(), 2); } diff --git a/src/qual_file.rs b/src/qual_file.rs index e79c14d..6d0a1c1 100644 --- a/src/qual_file.rs +++ b/src/qual_file.rs @@ -296,13 +296,13 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: subject.into(), - author: "test@test.com".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind, r#ref: None, diff --git a/src/scoring.rs b/src/scoring.rs index 413ba81..803f5f2 100644 --- a/src/scoring.rs +++ b/src/scoring.rs @@ -242,13 +242,13 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: subject.into(), - author: "test@test.com".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind, r#ref: None, @@ -271,13 +271,13 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: subject.into(), - author: "test@test.com".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T11:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: None, diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 7f22c0d..0604faa 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -96,8 +96,8 @@ fn test_attest_and_show_roundtrip() { "40", "--summary", "Well structured code", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -148,8 +148,8 @@ fn test_score_json_output_structure() { "50", "--summary", "nice", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -213,8 +213,8 @@ fn test_check_passes_with_good_scores() { "50", "--summary", "excellent", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -237,8 +237,8 @@ fn test_check_fails_with_bad_scores() { "--score=-50", "--summary", "critical issue", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -273,8 +273,8 @@ fn test_attest_blocker_uses_default_score() { "blocker", "--summary", "security vulnerability", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -299,8 +299,8 @@ fn test_attest_pass_uses_default_score() { "pass", "--summary", "looks good", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -325,8 +325,8 @@ fn test_attest_concern_uses_default_score() { "concern", "--summary", "could be better", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -355,8 +355,8 @@ fn test_show_json_output() { "30", "--summary", "clean API", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -408,8 +408,8 @@ fn test_multiple_attestations_accumulate() { "30", "--summary", "good structure", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); run_qualifier( @@ -422,8 +422,8 @@ fn test_multiple_attestations_accumulate() { "--score=-10", "--summary", "needs docs", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -456,8 +456,8 @@ fn test_attest_writes_to_directory_qual_by_default() { "pass", "--summary", "looks good", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -487,8 +487,8 @@ fn test_attest_respects_existing_1to1_file() { "pass", "--summary", "looks good", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -522,8 +522,8 @@ fn test_attest_file_flag_override() { "praise", "--summary", "nice", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", "--file", "custom.qual", ], @@ -556,8 +556,8 @@ fn test_show_finds_attestation_in_directory_qual() { "30", "--summary", "clean code", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -586,8 +586,8 @@ fn test_score_accumulates_across_layouts() { "40", "--summary", "good", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -602,8 +602,8 @@ fn test_score_accumulates_across_layouts() { "--score=-10", "--summary", "needs work", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", "--file", "src/mixed.rs.qual", ], @@ -636,8 +636,8 @@ fn test_attest_creates_parent_dirs() { "pass", "--summary", "ok", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -662,8 +662,8 @@ fn test_ls_basic_listing() { "50", "--summary", "great", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); run_qualifier( @@ -676,8 +676,8 @@ fn test_ls_basic_listing() { "--score=-20", "--summary", "meh", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -702,8 +702,8 @@ fn test_ls_below_filter() { "50", "--summary", "nice", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); run_qualifier( @@ -716,8 +716,8 @@ fn test_ls_below_filter() { "--score=-50", "--summary", "broken", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -743,8 +743,8 @@ fn test_ls_kind_filter() { "blocker", "--summary", "bad", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); run_qualifier( @@ -758,8 +758,8 @@ fn test_ls_kind_filter() { "30", "--summary", "good", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -786,8 +786,8 @@ fn test_praise_shows_records() { "40", "--summary", "Well structured code", - "--author", - "alice@example.com", + "--issuer", + "mailto:alice@example.com", ], ); @@ -801,8 +801,8 @@ fn test_praise_shows_records() { "--score=-10", "--summary", "Missing error handling", - "--author", - "bob@example.com", + "--issuer", + "mailto:bob@example.com", ], ); @@ -826,11 +826,11 @@ fn test_praise_shows_records() { ); assert!( stdout.contains("alice@example.com"), - "should show author: {stdout}" + "should show issuer: {stdout}" ); assert!( stdout.contains("bob@example.com"), - "should show second author: {stdout}" + "should show second issuer: {stdout}" ); assert!( stdout.contains("Well structured code"), @@ -851,8 +851,8 @@ fn test_praise_blame_alias() { "pass", "--summary", "ok", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -881,8 +881,8 @@ fn test_praise_vcs_without_vcs() { "pass", "--summary", "ok", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -958,8 +958,8 @@ fn test_score_overflow_clamped() { "100", "--summary", &format!("praise {i}"), - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); } @@ -990,7 +990,7 @@ fn test_attest_batch_validates() { "score": 10, "summary": "" }, - "author": "test@test.com", + "issuer": "mailto:test@test.com", "created_at": "2026-01-01T00:00:00Z" }); @@ -1019,7 +1019,7 @@ fn test_attest_batch_validates() { // --- metabox format tests --- #[test] -fn test_attest_with_author_type() { +fn test_attest_with_issuer_type() { let dir = tempfile::tempdir().unwrap(); let (_, _, code) = run_qualifier( @@ -1033,21 +1033,21 @@ fn test_attest_with_author_type() { "30", "--summary", "Clean code", - "--author", - "test@test.com", - "--author-type", + "--issuer", + "mailto:test@test.com", + "--issuer-type", "human", ], ); - assert_eq!(code, 0, "attest with --author-type should succeed"); + assert_eq!(code, 0, "attest with --issuer-type should succeed"); - // Read the .qual file and verify author_type is present + // Read the .qual file and verify issuer_type is present let qual_path = dir.path().join(".qual"); let content = std::fs::read_to_string(&qual_path).unwrap(); assert!( - content.contains("\"author_type\":\"human\""), - "attestation should contain author_type: {content}" + content.contains("\"issuer_type\":\"human\""), + "attestation should contain issuer_type: {content}" ); } @@ -1066,8 +1066,8 @@ fn test_attest_with_ref() { "20", "--summary", "Looks good", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", "--ref", "git:3aba500", ], @@ -1098,8 +1098,8 @@ fn test_new_attestations_are_metabox() { "50", "--summary", "nice", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", ], ); @@ -1118,7 +1118,7 @@ fn test_new_attestations_are_metabox() { } #[test] -fn test_attest_invalid_author_type() { +fn test_attest_invalid_issuer_type() { let dir = tempfile::tempdir().unwrap(); let (_, stderr, code) = run_qualifier( @@ -1130,17 +1130,17 @@ fn test_attest_invalid_author_type() { "pass", "--summary", "ok", - "--author", - "test@test.com", - "--author-type", + "--issuer", + "mailto:test@test.com", + "--issuer-type", "banana", ], ); - assert_ne!(code, 0, "invalid author_type should fail"); + assert_ne!(code, 0, "invalid issuer_type should fail"); assert!( - stderr.contains("author_type") || stderr.contains("banana"), - "error should mention invalid author_type: {stderr}" + stderr.contains("issuer_type") || stderr.contains("banana"), + "error should mention invalid issuer_type: {stderr}" ); } @@ -1160,8 +1160,8 @@ fn test_attest_with_span() { "--score=-10", "--summary", "Problematic function", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", "--span", "42:58", ], @@ -1199,8 +1199,8 @@ fn test_attest_with_span_and_columns() { "--score=-10", "--summary", "Bad code", - "--author", - "test@test.com", + "--issuer", + "mailto:test@test.com", "--span", "10.5:20.80", ], diff --git a/tests/integration.rs b/tests/integration.rs index b9cd07b..bea980e 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -12,13 +12,13 @@ fn make_att(subject: &str, kind: Kind, score: i32, summary: &str) -> Attestation metabox: "1".into(), record_type: "attestation".into(), subject: subject.into(), - author: "test@test.com".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind, r#ref: None, @@ -44,13 +44,13 @@ fn test_golden_attestation_id() { metabox: "1".into(), record_type: "attestation".into(), subject: "src/parser.rs".into(), - author: "alice@example.com".into(), + issuer: "mailto:alice@example.com".into(), + issuer_type: None, created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Concern, r#ref: None, @@ -65,26 +65,26 @@ fn test_golden_attestation_id() { // If this assertion fails, the canonical form or hashing has changed — // all existing record IDs in the wild are now broken. assert_eq!( - att.id, "ea7ddda3cc31412ef7b0499956c2811a9108ce0455d21174c4967c53e54a8b15", + att.id, "47aecd917e3f1517158f9d084b00c79d45be849b21e1923da1c7706db94935a1", "Golden attestation ID changed! Canonical form or hashing is broken." ); } #[test] fn test_golden_epoch_id() { - use qualifier::attestation::{self, AuthorType, Epoch, EpochBody}; + use qualifier::attestation::{self, Epoch, EpochBody, IssuerType}; let epoch = attestation::finalize_epoch(Epoch { metabox: "1".into(), record_type: "epoch".into(), subject: "src/parser.rs".into(), - author: "qualifier/compact".into(), + issuer: "urn:qualifier:compact".into(), + issuer_type: Some(IssuerType::Tool), created_at: chrono::DateTime::parse_from_rfc3339("2026-02-25T12:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: EpochBody { - author_type: Some(AuthorType::Tool), refs: vec!["aaa".into(), "bbb".into(), "ccc".into()], score: 10, span: None, @@ -92,7 +92,7 @@ fn test_golden_epoch_id() { }, }); assert_eq!( - epoch.id, "1e9d1a1177aaf80745176ecb65be5fb8ac8f21fdb35763443e78d84ddfda2b37", + epoch.id, "2597b6594fdc1d8d1c1f7a4577637edccb865fa8024349c9caf87344b324bdb4", "Golden epoch ID changed! Canonical form or hashing is broken." ); } @@ -105,7 +105,8 @@ fn test_golden_dependency_id() { metabox: "1".into(), record_type: "dependency".into(), subject: "bin/server".into(), - author: "build-system".into(), + issuer: "https://build.example.com".into(), + issuer_type: None, created_at: chrono::DateTime::parse_from_rfc3339("2026-02-25T10:00:00Z") .unwrap() .with_timezone(&Utc), @@ -116,7 +117,7 @@ fn test_golden_dependency_id() { })); assert_eq!( dep.id(), - "9fd88c26fbb436740f9483e411279ebeeb1cfa84d06839ede0f4854587f7cf67", + "dc97e9f3fa9b8d1f0c70e1a8aae30ddf305dba6f6c1d9720ef7e9d5db57eacfe", "Golden dependency ID changed! Canonical form or hashing is broken." ); } @@ -248,13 +249,13 @@ fn test_compaction_roundtrip_preserves_scores() { metabox: "1".into(), record_type: "attestation".into(), subject: "mod.rs".into(), - author: "test@test.com".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T11:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -328,11 +329,11 @@ fn test_supersession_cycle_detected() { metabox: "1".into(), record_type: "attestation".into(), subject: "x".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: now, id: "aaa".into(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -348,11 +349,11 @@ fn test_supersession_cycle_detected() { metabox: "1".into(), record_type: "attestation".into(), subject: "x".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: now, id: "bbb".into(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -390,13 +391,13 @@ fn test_cross_artifact_supersession_rejected() { metabox: "1".into(), record_type: "attestation".into(), subject: "bar.rs".into(), - author: "test@test.com".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T11:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -422,11 +423,11 @@ fn test_kind_typo_detected_in_validation() { metabox: "1".into(), record_type: "attestation".into(), subject: "x.rs".into(), - author: "test@test.com".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, detail: None, kind: Kind::Custom("pss".into()), r#ref: None, @@ -458,7 +459,7 @@ fn test_parse_qual_file_only_comments() { #[test] fn test_metabox_roundtrip() { - use qualifier::attestation::AuthorType; + use qualifier::attestation::IssuerType; let dir = tempfile::tempdir().unwrap(); let qual_path = dir.path().join("test.rs.qual"); @@ -467,13 +468,13 @@ fn test_metabox_roundtrip() { metabox: "1".into(), record_type: "attestation".into(), subject: "test.rs".into(), - author: "alice@example.com".into(), + issuer: "mailto:alice@example.com".into(), + issuer_type: Some(IssuerType::Human), created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: Some(AuthorType::Human), detail: None, kind: Kind::Praise, r#ref: Some("git:3aba500".into()), @@ -493,14 +494,14 @@ fn test_metabox_roundtrip() { let parsed = qf.records[0].as_attestation().unwrap(); assert_eq!(parsed.metabox, "1"); - assert_eq!(parsed.body.author_type, Some(AuthorType::Human)); + assert_eq!(parsed.issuer_type, Some(IssuerType::Human)); assert_eq!(parsed.body.r#ref.as_deref(), Some("git:3aba500")); assert_eq!(parsed.id, att.id); } #[test] fn test_compact_snapshot_produces_epoch() { - use qualifier::attestation::AuthorType; + use qualifier::attestation::IssuerType; let records = vec![ make_record("src/a.rs", Kind::Praise, 40, "good"), @@ -517,7 +518,7 @@ fn test_compact_snapshot_produces_epoch() { let epoch = snapped.records[0].as_epoch().unwrap(); assert_eq!(epoch.metabox, "1"); - assert_eq!(epoch.body.author_type, Some(AuthorType::Tool)); + assert_eq!(epoch.issuer_type, Some(IssuerType::Tool)); assert_eq!(epoch.body.score, 30); // 40 + -10 } @@ -528,13 +529,13 @@ fn test_supersession_with_new_fields() { metabox: "1".into(), record_type: "attestation".into(), subject: "mod.rs".into(), - author: "test@test.com".into(), + issuer: "mailto:test@test.com".into(), + issuer_type: Some(qualifier::attestation::IssuerType::Human), created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T11:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: Some(qualifier::attestation::AuthorType::Human), detail: None, kind: Kind::Pass, r#ref: Some("git:abc123".into()),