From 5891b49eb9a79ad9dd9b77b72650dce720fb3f0f Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Thu, 5 Mar 2026 22:59:45 -0500 Subject: [PATCH 1/4] feat: rename `author` to `issuer` with URI-based identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the opaque `author` string with `issuer`, a URI-based identity field (`mailto:`, `https:`, `urn:qualifier:`). Rename `author_type` to `issuer_type` and `AuthorType` to `IssuerType`. This is a breaking change to the Metabox envelope — all content-addressed IDs change due to the MCF field rename and body field reordering. --- METABOX.md | 24 ++--- SPEC.md | 68 ++++++------- site/examples/src-auth.rs.qual | 14 +-- site/examples/src-parser.rs.qual | 6 +- site/index.md | 4 +- site/pages/cli.md | 4 +- site/pages/format.md | 20 ++-- src/attestation.rs | 168 ++++++++++++++++--------------- src/cli/commands/attest.rs | 43 +++++--- src/cli/commands/praise.rs | 26 ++--- src/cli/commands/show.rs | 10 +- src/cli/config.rs | 8 +- src/compact.rs | 16 +-- src/qual_file.rs | 4 +- src/scoring.rs | 8 +- tests/cli_integration.rs | 164 +++++++++++++++--------------- tests/integration.rs | 58 +++++------ 17 files changed, 332 insertions(+), 313 deletions(-) diff --git a/METABOX.md b/METABOX.md index 279d180..cd06f52 100644 --- a/METABOX.md +++ b/METABOX.md @@ -27,7 +27,7 @@ this canonical order: | 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. | +| 4 | `issuer` | string | yes | Who or what created this record (URI). | | 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. | @@ -66,10 +66,10 @@ 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` @@ -126,7 +126,7 @@ Before serialization: ### 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`, `created_at`, `id`, `body`. 2. **Body fields** are sorted lexicographically by key. Sorting is recursive: nested objects also have their keys sorted lexicographically. @@ -192,22 +192,22 @@ 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","created_at":"2026-02-24T10:00:00Z","id":"a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8","body":{"issuer_type":"human","kind":"concern","ref":"git:3aba500","score":-30,"summary":"Panics on malformed input"}} ``` -Note that body fields are sorted lexicographically: `author_type`, `kind`, +Note that body fields are sorted lexicographically: `issuer_type`, `kind`, `ref`, `score`, `summary`. 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 +221,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 +232,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.*` +`issuer_type`, `ref`, `supersedes` → `body.*` **Epoch** (`type: "epoch"`): -`span`, `score`, `summary`, `refs`, `author_type` → `body.*` +`span`, `score`, `summary`, `refs`, `issuer_type` → `body.*` **Dependency** (`type: "dependency"`): diff --git a/SPEC.md b/SPEC.md index ec65c2c..63f12a1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -16,7 +16,7 @@ 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 +(`metabox`, `type`, `subject`, `issuer`, `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. @@ -73,7 +73,7 @@ Every record uses the [Metabox](METABOX.md) envelope format with these fields: | `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 | +| `issuer` | string | yes | Who or what created this record (URI) | | `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) | @@ -212,8 +212,8 @@ 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 | +| `issuer_type` | string | no | Issuer classification: `human`, `ai`, `tool`, `unknown` | | `kind` | string | yes | The type of attestation (see 2.7) | | `ref` | string | no | VCS reference pin (e.g., `"git:3aba500"`). Opaque to qualifier. | | `score` | integer | yes | Signed quality delta, -100..100 | @@ -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","created_at":"2026-02-25T10:00:00Z","id":"a1b2c3d4...","body":{"issuer_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"]}} ``` **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,7 @@ 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`, `created_at`, `id`, `body`. 3. **Body field order.** Body fields MUST appear in lexicographic (alphabetical) order. Nested objects (like `span`) also have their fields @@ -332,13 +332,13 @@ 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","created_at":"2026-02-24T10:00:00Z","id":"","body":{"issuer_type":"human","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, @@ -401,8 +401,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","created_at":"2026-02-24T10:00:00Z","id":"a1b2c3d4...","body":{"issuer_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","issuer":"mailto:bob@example.com","created_at":"2026-02-24T11:00:00Z","id":"e5f6a7b8...","body":{"issuer_type":"human","kind":"praise","score":40,"summary":"Excellent property-based test coverage","tags":["testing"]}} ``` ## 3. Record Type Specifications @@ -421,18 +421,18 @@ Body fields (alphabetical): | Field | Type | Required | Description | |---------------|----------|----------|-------------| -| `author_type` | string | no | Always `"tool"` for epochs | +| `issuer_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"`. **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","created_at":"2026-02-25T12:00:00Z","id":"f9e8d7c6...","body":{"issuer_type":"tool","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 +476,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 +504,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 +608,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 +624,7 @@ 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) | | All body fields | `predicate.*` | The in-toto `subject[0].digest` contains the content hash of the artifact @@ -653,8 +653,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) | `body.issuer_type: "tool"` | **Level-to-score mapping:** @@ -698,7 +698,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 +725,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 +808,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` | @@ -852,15 +852,15 @@ pub struct Attestation { pub metabox: String, // always "1" pub record_type: String, // "attestation" pub subject: String, - pub author: String, + pub issuer: String, pub created_at: DateTime, pub id: String, pub body: AttestationBody, } pub struct AttestationBody { - pub author_type: Option, pub detail: Option, + pub issuer_type: Option, pub kind: Kind, pub r#ref: Option, pub score: i32, @@ -875,14 +875,14 @@ pub struct Epoch { pub metabox: String, // always "1" pub record_type: String, // "epoch" pub subject: String, - pub author: String, + pub issuer: String, pub created_at: DateTime, pub id: String, pub body: EpochBody, } pub struct EpochBody { - pub author_type: Option, + pub issuer_type: Option, pub refs: Vec, pub score: i32, pub span: Option, @@ -893,7 +893,7 @@ pub struct DependencyRecord { pub metabox: String, // always "1" pub record_type: String, // "dependency" pub subject: String, - pub author: String, + pub issuer: String, pub created_at: DateTime, pub id: String, pub body: DependencyBody, @@ -914,7 +914,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 +971,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 +1014,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/site/examples/src-auth.rs.qual b/site/examples/src-auth.rs.qual index 2c83add..82ef8be 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","created_at":"2026-02-24T09:00:00Z","id":"c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4a5b6c7d8e9f0a1b2","body":{"issuer_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","issuer":"https://ci.example.com","created_at":"2026-02-24T12:00:00Z","id":"g1h2i3j4k5l6m7n8o9p0q1r2s3t4u5v6w7x8y9z0a1b2c3d4e5f6g7h8i9j0k1l2","body":{"issuer_type":"tool","kind":"pass","score":20,"summary":"Input validation tests pass","tags":["testing"]}} +{"metabox":"1","type":"attestation","subject":"lib/crypto","issuer":"mailto:alice@example.com","created_at":"2026-02-24T09:30:00Z","id":"d1d2d3d4d5d6d7d8d9d0e1e2e3e4e5e6e7e8e9e0f1f2f3f4f5f6f7f8f9f0a1a2","body":{"issuer_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","issuer":"mailto:bob@example.com","created_at":"2026-02-24T10:30:00Z","id":"b1b2b3b4b5b6b7b8b9b0c1c2c3c4c5c6c7c8c9c0d1d2d3d4d5d6d7d8d9d0e1e2","body":{"issuer_type":"human","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","created_at":"2026-02-24T12:30:00Z","id":"h1h2h3h4h5h6h7h8h9h0i1i2i3i4i5i6i7i8i9i0j1j2j3j4j5j6j7j8j9j0k1k2","body":{"issuer_type":"tool","kind":"pass","score":20,"summary":"All integration tests pass","tags":["testing"]}} +{"metabox":"1","type":"attestation","subject":"bin/server","issuer":"https://ci.example.com","created_at":"2026-02-24T13:00:00Z","id":"a2a3a4a5a6a7a8a9a0b1b2b3b4b5b6b7b8b9b0c1c2c3c4c5c6c7c8c9c0d1d2d3","body":{"issuer_type":"tool","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","created_at":"2026-02-24T13:30:00Z","id":"e2e3e4e5e6e7e8e9e0f1f2f3f4f5f6f7f8f9f0a1a2a3a4a5a6a7a8a9a0b1b2b3","body":{"issuer_type":"human","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..2521168 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","created_at":"2026-02-24T10:00:00Z","id":"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2","body":{"issuer_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","issuer":"mailto:bob@example.com","created_at":"2026-02-24T11:00:00Z","id":"e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6","body":{"issuer_type":"human","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","created_at":"2026-02-24T14:00:00Z","id":"f1f2f3f4f5f6f7f8f9f0a1a2a3a4a5a6a7a8a9a0b1b2b3b4b5b6b7b8b9b0c1c2","body":{"issuer_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"]}} diff --git a/site/index.md b/site/index.md index f25657e..941554b 100644 --- a/site/index.md +++ b/site/index.md @@ -192,8 +192,8 @@ Qualifier gives you a structured, VCS-friendly way to **record what you know abo 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. diff --git a/site/pages/cli.md b/site/pages/cli.md index 5f90da5..170c154 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 @@ -160,7 +160,7 @@ Qualifier uses layered configuration (highest wins): | Priority | Source | Example | | -------- | ------------------ | ------------------------------------------ | | 1 | CLI flags | `--graph path/to/graph.jsonl` | -| 2 | Environment | `QUALIFIER_GRAPH`, `QUALIFIER_AUTHOR` | +| 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..e0ff470 100644 --- a/site/pages/format.md +++ b/site/pages/format.md @@ -14,8 +14,8 @@ 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","created_at":"2026-02-24T10:00:00Z","id":"a1b2c3d4...","body":{"issuer_type":"human","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","created_at":"2026-02-24T11:00:00Z","id":"e5f6a7b8...","body":{"issuer_type":"human","kind":"praise","score":40,"summary":"Excellent test coverage"}} ``` ## Record types @@ -39,7 +39,7 @@ All record types share a common **Metabox envelope** — a fixed set of fields t | `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 | +| `issuer` | string | yes | Who or what created this record (URI) | | `created_at` | string | yes | RFC 3339 timestamp | | `id` | string | yes | Content-addressed BLAKE3 hash | | `body` | object | yes | Type-specific payload | @@ -50,8 +50,8 @@ 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) | +| `issuer_type` | enum | no | Issuer classification: human, ai, tool, unknown | | `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 | @@ -83,16 +83,16 @@ An **epoch** is a compaction snapshot — a synthetic record that replaces a set | Field | Type | Required | Description | | ------------- | -------- | -------- | ------------------------------------------------- | -| `author_type` | enum | no | Always `"tool"` for epochs | +| `issuer_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"` | -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","created_at":"2026-02-25T12:00:00Z","id":"f9e8d7c6...","body":{"issuer_type":"tool","refs":["a1b2...","c3d4..."],"score":10,"summary":"Compacted from 12 records"}} ``` ## Dependency schema @@ -104,7 +104,7 @@ A **dependency** record declares directed edges from one subject to others. Enve | `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 +118,10 @@ 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 fields (`span`, `detail`, `suggested_fix`, `tags`, `issuer_type`, `ref`, `supersedes`) 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. diff --git a/src/attestation.rs b/src/attestation.rs index 3352689..0c7e15e 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,10 +197,10 @@ 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issuer_type: Option, pub kind: Kind, #[serde(default, skip_serializing_if = "Option::is_none")] pub r#ref: Option, @@ -220,7 +220,7 @@ pub struct AttestationBody { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct EpochBody { #[serde(default, skip_serializing_if = "Option::is_none")] - pub author_type: Option, + pub issuer_type: Option, pub refs: Vec, pub score: i32, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -247,7 +247,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, 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 +261,8 @@ 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, /// When this attestation was created (RFC 3339). pub created_at: DateTime, @@ -285,7 +285,7 @@ pub struct Epoch { #[serde(rename = "type")] pub record_type: String, pub subject: String, - pub author: String, + pub issuer: String, pub created_at: DateTime, pub id: String, pub body: EpochBody, @@ -301,7 +301,7 @@ pub struct DependencyRecord { #[serde(rename = "type")] pub record_type: String, pub subject: String, - pub author: String, + pub issuer: String, pub created_at: DateTime, pub id: String, pub body: DependencyBody, @@ -435,7 +435,7 @@ struct AttestationCanonicalView<'a> { metabox: &'a str, r#type: &'a str, subject: &'a str, - author: &'a str, + issuer: &'a str, created_at: &'a DateTime, id: &'a str, body: &'a AttestationBody, @@ -447,7 +447,7 @@ struct EpochCanonicalView<'a> { metabox: &'a str, r#type: &'a str, subject: &'a str, - author: &'a str, + issuer: &'a str, created_at: &'a DateTime, id: &'a str, body: &'a EpochBody, @@ -459,7 +459,7 @@ struct DependencyCanonicalView<'a> { metabox: &'a str, r#type: &'a str, subject: &'a str, - author: &'a str, + issuer: &'a str, created_at: &'a DateTime, id: &'a str, body: &'a DependencyBody, @@ -474,7 +474,7 @@ pub fn generate_id(attestation: &Attestation) -> String { metabox: &attestation.metabox, r#type: "attestation", subject: &attestation.subject, - author: &attestation.author, + issuer: &attestation.issuer, created_at: &attestation.created_at, id: "", body: &attestation.body, @@ -489,7 +489,7 @@ pub fn generate_epoch_id(epoch: &Epoch) -> String { metabox: &epoch.metabox, r#type: "epoch", subject: &epoch.subject, - author: &epoch.author, + issuer: &epoch.issuer, created_at: &epoch.created_at, id: "", body: &epoch.body, @@ -504,7 +504,7 @@ pub fn generate_dependency_id(dep: &DependencyRecord) -> String { metabox: &dep.metabox, r#type: "dependency", subject: &dep.subject, - author: &dep.author, + issuer: &dep.issuer, created_at: &dep.created_at, id: "", body: &dep.body, @@ -541,8 +541,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 +771,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(), created_at: DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Concern, r#ref: None, @@ -823,11 +825,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: String::new(), - author: String::new(), + issuer: String::new(), created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -842,7 +844,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 +880,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "test".into(), - author: "bot".into(), + issuer: "mailto:bot@localhost".into(), created_at: Utc::now(), id: "will be replaced".into(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -906,11 +908,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "test.rs".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Concern, r#ref: None, @@ -949,11 +951,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "x.rs".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), created_at: now, id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Concern, r#ref: None, @@ -970,11 +972,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "x.rs".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), created_at: now, id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Concern, r#ref: None, @@ -1003,11 +1005,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "x".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), created_at: now, id: "aaa".into(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1023,11 +1025,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "x".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), created_at: now, id: "bbb".into(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1120,11 +1122,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "foo.rs".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1141,11 +1143,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "bar.rs".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1168,11 +1170,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "foo.rs".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Concern, r#ref: None, @@ -1189,11 +1191,11 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "foo.rs".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1224,11 +1226,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(), created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1254,11 +1256,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(), created_at: now, id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1271,15 +1273,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(), created_at: now, id: String::new(), body: AttestationBody { - author_type: Some(AuthorType::Human), + issuer_type: Some(IssuerType::Human), detail: None, kind: Kind::Pass, r#ref: None, @@ -1296,11 +1298,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(), created_at: now, id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Pass, r#ref: Some("git:abc123".into()), @@ -1314,9 +1316,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 +1327,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(), created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1358,13 +1360,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(), 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), + issuer_type: Some(IssuerType::Human), detail: None, kind: Kind::Praise, r#ref: Some("git:3aba500".into()), @@ -1381,7 +1383,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 +1396,13 @@ mod tests { metabox: "1".into(), record_type: "attestation".into(), subject: "x.rs".into(), - author: "test".into(), + issuer: "mailto:test@test.com".into(), created_at: DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1422,7 +1424,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 +1435,13 @@ mod tests { metabox: "1".into(), record_type: "epoch".into(), subject: "x.rs".into(), - author: "qualifier/compact".into(), + issuer: "urn:qualifier:compact".into(), 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), + issuer_type: Some(IssuerType::Tool), refs: vec!["aaa".into(), "bbb".into()], score: 30, span: None, @@ -1458,7 +1460,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 +1522,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..9b210e2 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, created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type, + issuer_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..34381a0 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.body.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.body.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..f1c4f05 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(), created_at: Utc::now(), id: String::new(), body: EpochBody { - author_type: Some(AuthorType::Tool), + issuer_type: Some(IssuerType::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(), created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, + issuer_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(), created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T11:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, + issuer_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..c8498d1 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(), created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind, r#ref: None, diff --git a/src/scoring.rs b/src/scoring.rs index 413ba81..acd9e36 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(), created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, + issuer_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(), created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T11:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, + issuer_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..8dcf554 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(), created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, + issuer_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(), created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T10:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, + issuer_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(), 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), + issuer_type: Some(IssuerType::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, "b0be0120b43116b0dbf0132bb6112babcd80ca25cf70caadfb63afeb1acc7993", "Golden epoch ID changed! Canonical form or hashing is broken." ); } @@ -105,7 +105,7 @@ 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(), created_at: chrono::DateTime::parse_from_rfc3339("2026-02-25T10:00:00Z") .unwrap() .with_timezone(&Utc), @@ -116,7 +116,7 @@ fn test_golden_dependency_id() { })); assert_eq!( dep.id(), - "9fd88c26fbb436740f9483e411279ebeeb1cfa84d06839ede0f4854587f7cf67", + "dc97e9f3fa9b8d1f0c70e1a8aae30ddf305dba6f6c1d9720ef7e9d5db57eacfe", "Golden dependency ID changed! Canonical form or hashing is broken." ); } @@ -248,13 +248,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(), created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T11:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -328,11 +328,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(), created_at: now, id: "aaa".into(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -348,11 +348,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(), created_at: now, id: "bbb".into(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -390,13 +390,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(), created_at: chrono::DateTime::parse_from_rfc3339("2026-02-24T11:00:00Z") .unwrap() .with_timezone(&Utc), id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -422,11 +422,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(), created_at: Utc::now(), id: String::new(), body: AttestationBody { - author_type: None, + issuer_type: None, detail: None, kind: Kind::Custom("pss".into()), r#ref: None, @@ -458,7 +458,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 +467,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(), 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), + issuer_type: Some(IssuerType::Human), detail: None, kind: Kind::Praise, r#ref: Some("git:3aba500".into()), @@ -493,14 +493,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.body.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 +517,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.body.issuer_type, Some(IssuerType::Tool)); assert_eq!(epoch.body.score, 30); // 40 + -10 } @@ -528,13 +528,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(), 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), + issuer_type: Some(qualifier::attestation::IssuerType::Human), detail: None, kind: Kind::Pass, r#ref: Some("git:abc123".into()), From 95980cac8408cd664a70ccfd3a6b904c7fc4b5da Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Thu, 5 Mar 2026 23:16:30 -0500 Subject: [PATCH 2/4] fix: deploy site preview on pull requests The deploy step was guarded by `github.event_name == 'push'`, so PR builds never deployed a preview. Deploy to a branch-specific Cloudflare Pages URL on PRs so the preview comment actually has a URL to post. --- .github/workflows/deploy-site.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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: | From 04d7ff85c8b976710169b4a78e2eeb3c9ec89518 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Fri, 6 Mar 2026 00:25:35 -0500 Subject: [PATCH 3/4] feat: promote `issuer_type` from body to envelope Move issuer_type (human/ai/tool/unknown) from the body of Attestation and Epoch records to the Metabox envelope, making it uniform across all record types and accessible without body parsing. DependencyRecord gains issuer_type for the first time. Envelope field order is now: metabox, type, subject, issuer, issuer_type, created_at, id, body. Golden epoch ID updated (canonical form changed); attestation and dependency IDs unchanged (issuer_type was None). --- METABOX.md | 62 +++++++------ SPEC.md | 57 ++++++------ site/examples/src-auth.rs.qual | 14 +-- site/examples/src-parser.rs.qual | 6 +- site/pages/format.md | 144 +++++++++++++++++++------------ src/attestation.rs | 73 ++++++++++------ src/cli/commands/attest.rs | 2 +- src/cli/commands/praise.rs | 8 +- src/compact.rs | 6 +- src/qual_file.rs | 2 +- src/scoring.rs | 4 +- tests/integration.rs | 27 +++--- 12 files changed, 240 insertions(+), 165 deletions(-) diff --git a/METABOX.md b/METABOX.md index cd06f52..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 | `issuer` | string | yes | Who or what created this record (URI). | -| 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` @@ -71,24 +73,33 @@ Examples: `"src/parser.rs"`, `"pkg:npm/lodash@4.17.21"`, `"service/health"`, 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`, `issuer`, `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,11 +205,12 @@ define: A Qualifier attestation in Metabox format: ```json -{"metabox":"1","type":"attestation","subject":"src/parser.rs","issuer":"mailto:alice@example.com","created_at":"2026-02-24T10:00:00Z","id":"a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8a1b2c3d4e5f6a7b8","body":{"issuer_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: `issuer_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: @@ -232,11 +246,11 @@ All non-frame fields move into the `body` object: **Attestation** (`type: "attestation"`): `span`, `kind`, `score`, `summary`, `detail`, `suggested_fix`, `tags`, -`issuer_type`, `ref`, `supersedes` → `body.*` +`ref`, `supersedes` → `body.*` **Epoch** (`type: "epoch"`): -`span`, `score`, `summary`, `refs`, `issuer_type` → `body.*` +`span`, `score`, `summary`, `refs` → `body.*` **Dependency** (`type: "dependency"`): diff --git a/SPEC.md b/SPEC.md index 63f12a1..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`, `issuer`, `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 | -| `issuer` | string | yes | Who or what created this record (URI) | -| `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. @@ -213,7 +214,6 @@ Metabox envelope fields (section 2.2) plus body fields: | Field | Type | Required | Description | |-----------------|----------|----------|-------------| | `detail` | string | no | Extended description, markdown allowed | -| `issuer_type` | string | no | Issuer classification: `human`, `ai`, `tool`, `unknown` | | `kind` | string | yes | The type of attestation (see 2.7) | | `ref` | string | no | VCS reference pin (e.g., `"git:3aba500"`). Opaque to qualifier. | | `score` | integer | yes | Signed quality delta, -100..100 | @@ -229,7 +229,7 @@ Canonical Form (MCF) serialization order. **Example:** ```json -{"metabox":"1","type":"attestation","subject":"src/parser.rs","issuer":"mailto:alice@example.com","created_at":"2026-02-25T10:00:00Z","id":"a1b2c3d4...","body":{"issuer_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 @@ -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`, `issuer`, `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 @@ -338,11 +339,12 @@ Given an attestation with no optional body fields, the MCF is: With a span and issuer_type: ```json -{"metabox":"1","type":"attestation","subject":"src/parser.rs","issuer":"mailto:alice@example.com","created_at":"2026-02-24T10:00:00Z","id":"","body":{"issuer_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","issuer":"mailto:alice@example.com","created_at":"2026-02-24T10:00:00Z","id":"a1b2c3d4...","body":{"issuer_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","issuer":"mailto:bob@example.com","created_at":"2026-02-24T11:00:00Z","id":"e5f6a7b8...","body":{"issuer_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 | |---------------|----------|----------|-------------| -| `issuer_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 `issuer` to `"urn: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","issuer":"urn:qualifier:compact","created_at":"2026-02-25T12:00:00Z","id":"f9e8d7c6...","body":{"issuer_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 @@ -625,6 +627,7 @@ predicates for use with DSSE signing and Sigstore distribution. | `body.span` | `predicate.span` | | `id` | `predicate.qualifier_id` | | `issuer` | `predicate.issuer` (also DSSE signer) | +| `issuer_type` | `predicate.issuer_type` | | All body fields | `predicate.*` | The in-toto `subject[0].digest` contains the content hash of the artifact @@ -654,7 +657,7 @@ SARIF v2.1.0 results can be converted to qualifier attestations: | `result.level` | `body.score` (see mapping below) | | `result.message.text` | `body.summary` | | `run.tool.driver.name` | `issuer` | -| (constant) | `body.issuer_type: "tool"` | +| (constant) | `issuer_type: "tool"` (envelope) | **Level-to-score mapping:** @@ -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 @@ -853,6 +857,7 @@ pub struct Attestation { pub record_type: String, // "attestation" pub subject: String, pub issuer: String, + pub issuer_type: Option, pub created_at: DateTime, pub id: String, pub body: AttestationBody, @@ -860,7 +865,6 @@ pub struct Attestation { pub struct AttestationBody { pub detail: Option, - pub issuer_type: Option, pub kind: Kind, pub r#ref: Option, pub score: i32, @@ -876,13 +880,13 @@ pub struct Epoch { pub record_type: String, // "epoch" pub subject: String, pub issuer: String, + pub issuer_type: Option, pub created_at: DateTime, pub id: String, pub body: EpochBody, } pub struct EpochBody { - pub issuer_type: Option, pub refs: Vec, pub score: i32, pub span: Option, @@ -894,6 +898,7 @@ pub struct DependencyRecord { pub record_type: String, // "dependency" pub subject: String, pub issuer: String, + pub issuer_type: Option, pub created_at: DateTime, pub id: String, pub body: DependencyBody, diff --git a/site/examples/src-auth.rs.qual b/site/examples/src-auth.rs.qual index 82ef8be..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","issuer":"mailto:alice@example.com","created_at":"2026-02-24T09:00:00Z","id":"c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4a5b6c7d8e9f0a1b2","body":{"issuer_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","issuer":"https://ci.example.com","created_at":"2026-02-24T12:00:00Z","id":"g1h2i3j4k5l6m7n8o9p0q1r2s3t4u5v6w7x8y9z0a1b2c3d4e5f6g7h8i9j0k1l2","body":{"issuer_type":"tool","kind":"pass","score":20,"summary":"Input validation tests pass","tags":["testing"]}} -{"metabox":"1","type":"attestation","subject":"lib/crypto","issuer":"mailto:alice@example.com","created_at":"2026-02-24T09:30:00Z","id":"d1d2d3d4d5d6d7d8d9d0e1e2e3e4e5e6e7e8e9e0f1f2f3f4f5f6f7f8f9f0a1a2","body":{"issuer_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","issuer":"mailto:bob@example.com","created_at":"2026-02-24T10:30:00Z","id":"b1b2b3b4b5b6b7b8b9b0c1c2c3c4c5c6c7c8c9c0d1d2d3d4d5d6d7d8d9d0e1e2","body":{"issuer_type":"human","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","created_at":"2026-02-24T12:30:00Z","id":"h1h2h3h4h5h6h7h8h9h0i1i2i3i4i5i6i7i8i9i0j1j2j3j4j5j6j7j8j9j0k1k2","body":{"issuer_type":"tool","kind":"pass","score":20,"summary":"All integration tests pass","tags":["testing"]}} -{"metabox":"1","type":"attestation","subject":"bin/server","issuer":"https://ci.example.com","created_at":"2026-02-24T13:00:00Z","id":"a2a3a4a5a6a7a8a9a0b1b2b3b4b5b6b7b8b9b0c1c2c3c4c5c6c7c8c9c0d1d2d3","body":{"issuer_type":"tool","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","created_at":"2026-02-24T13:30:00Z","id":"e2e3e4e5e6e7e8e9e0f1f2f3f4f5f6f7f8f9f0a1a2a3a4a5a6a7a8a9a0b1b2b3","body":{"issuer_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 2521168..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","issuer":"mailto:alice@example.com","created_at":"2026-02-24T10:00:00Z","id":"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2","body":{"issuer_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","issuer":"mailto:bob@example.com","created_at":"2026-02-24T11:00:00Z","id":"e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6","body":{"issuer_type":"human","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","created_at":"2026-02-24T14:00:00Z","id":"f1f2f3f4f5f6f7f8f9f0a1a2a3a4a5a6a7a8a9a0b1b2b3b4b5b6b7b8b9b0c1c2","body":{"issuer_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/pages/format.md b/site/pages/format.md index e0ff470..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","issuer":"mailto:alice@example.com","created_at":"2026-02-24T10:00:00Z","id":"a1b2c3d4...","body":{"issuer_type":"human","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","created_at":"2026-02-24T11:00:00Z","id":"e5f6a7b8...","body":{"issuer_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 | -| `issuer` | string | yes | Who or what created this record (URI) | -| `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 | -| --------------- | -------- | -------- | ------------------------------------------------ | -| `detail` | string | no | Extended description (markdown allowed) | -| `issuer_type` | enum | no | Issuer classification: human, ai, tool, unknown | -| `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 | -| ------------- | -------- | -------- | ------------------------------------------------- | -| `issuer_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 `issuer` is always `"urn:qualifier:compact"`. ```json -{"metabox":"1","type":"epoch","subject":"src/parser.rs","issuer":"urn:qualifier:compact","created_at":"2026-02-25T12:00:00Z","id":"f9e8d7c6...","body":{"issuer_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","issuer":"https://build.example.com","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","issuer":"mailto: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`, `issuer_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 0c7e15e..796d7bf 100644 --- a/src/attestation.rs +++ b/src/attestation.rs @@ -199,8 +199,6 @@ impl std::str::FromStr for IssuerType { pub struct AttestationBody { #[serde(default, skip_serializing_if = "Option::is_none")] pub detail: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub issuer_type: Option, pub kind: Kind, #[serde(default, skip_serializing_if = "Option::is_none")] pub r#ref: Option, @@ -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 issuer_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, -/// issuer, 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". @@ -264,6 +260,10 @@ pub struct Attestation { /// 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, @@ -286,6 +286,8 @@ pub struct Epoch { pub record_type: String, pub subject: 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, @@ -302,6 +304,8 @@ pub struct DependencyRecord { pub record_type: String, pub subject: 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(_)) @@ -436,6 +450,8 @@ struct AttestationCanonicalView<'a> { r#type: &'a str, subject: &'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, @@ -448,6 +464,8 @@ struct EpochCanonicalView<'a> { r#type: &'a str, subject: &'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, @@ -460,6 +478,8 @@ struct DependencyCanonicalView<'a> { r#type: &'a str, subject: &'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, @@ -475,6 +495,7 @@ pub fn generate_id(attestation: &Attestation) -> String { r#type: "attestation", subject: &attestation.subject, issuer: &attestation.issuer, + issuer_type: attestation.issuer_type.as_ref(), created_at: &attestation.created_at, id: "", body: &attestation.body, @@ -490,6 +511,7 @@ pub fn generate_epoch_id(epoch: &Epoch) -> String { r#type: "epoch", subject: &epoch.subject, issuer: &epoch.issuer, + issuer_type: epoch.issuer_type.as_ref(), created_at: &epoch.created_at, id: "", body: &epoch.body, @@ -505,6 +527,7 @@ pub fn generate_dependency_id(dep: &DependencyRecord) -> String { r#type: "dependency", subject: &dep.subject, issuer: &dep.issuer, + issuer_type: dep.issuer_type.as_ref(), created_at: &dep.created_at, id: "", body: &dep.body, @@ -772,12 +795,12 @@ mod tests { record_type: "attestation".into(), subject: "src/parser.rs".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 { - issuer_type: None, detail: None, kind: Kind::Concern, r#ref: None, @@ -826,10 +849,10 @@ mod tests { record_type: "attestation".into(), subject: String::new(), issuer: String::new(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -881,10 +904,10 @@ mod tests { record_type: "attestation".into(), subject: "test".into(), issuer: "mailto:bot@localhost".into(), + issuer_type: None, created_at: Utc::now(), id: "will be replaced".into(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -909,10 +932,10 @@ mod tests { record_type: "attestation".into(), subject: "test.rs".into(), issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Concern, r#ref: None, @@ -952,10 +975,10 @@ mod tests { record_type: "attestation".into(), subject: "x.rs".into(), issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: now, id: String::new(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Concern, r#ref: None, @@ -973,10 +996,10 @@ mod tests { record_type: "attestation".into(), subject: "x.rs".into(), issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: now, id: String::new(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Concern, r#ref: None, @@ -1006,10 +1029,10 @@ mod tests { record_type: "attestation".into(), subject: "x".into(), issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: now, id: "aaa".into(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1026,10 +1049,10 @@ mod tests { record_type: "attestation".into(), subject: "x".into(), issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: now, id: "bbb".into(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1123,10 +1146,10 @@ mod tests { record_type: "attestation".into(), subject: "foo.rs".into(), issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1144,10 +1167,10 @@ mod tests { record_type: "attestation".into(), subject: "bar.rs".into(), issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1171,10 +1194,10 @@ mod tests { record_type: "attestation".into(), subject: "foo.rs".into(), issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Concern, r#ref: None, @@ -1192,10 +1215,10 @@ mod tests { record_type: "attestation".into(), subject: "foo.rs".into(), issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1227,10 +1250,10 @@ mod tests { record_type: "attestation".into(), subject: "test.rs".into(), issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1257,10 +1280,10 @@ mod tests { record_type: "attestation".into(), subject: "x.rs".into(), issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: now, id: String::new(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1278,10 +1301,10 @@ mod tests { record_type: "attestation".into(), subject: "x.rs".into(), issuer: "mailto:test@test.com".into(), + issuer_type: Some(IssuerType::Human), created_at: now, id: String::new(), body: AttestationBody { - issuer_type: Some(IssuerType::Human), detail: None, kind: Kind::Pass, r#ref: None, @@ -1299,10 +1322,10 @@ mod tests { record_type: "attestation".into(), subject: "x.rs".into(), issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: now, id: String::new(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: Some("git:abc123".into()), @@ -1328,10 +1351,10 @@ mod tests { record_type: "attestation".into(), subject: "x.rs".into(), issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1361,12 +1384,12 @@ mod tests { record_type: "attestation".into(), subject: "x.rs".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 { - issuer_type: Some(IssuerType::Human), detail: None, kind: Kind::Praise, r#ref: Some("git:3aba500".into()), @@ -1397,12 +1420,12 @@ mod tests { record_type: "attestation".into(), subject: "x.rs".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 { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -1436,12 +1459,12 @@ mod tests { record_type: "epoch".into(), subject: "x.rs".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 { - issuer_type: Some(IssuerType::Tool), refs: vec!["aaa".into(), "bbb".into()], score: 30, span: None, diff --git a/src/cli/commands/attest.rs b/src/cli/commands/attest.rs index 9b210e2..fb29614 100644 --- a/src/cli/commands/attest.rs +++ b/src/cli/commands/attest.rs @@ -114,10 +114,10 @@ pub fn run(args: Args) -> crate::Result<()> { record_type: "attestation".into(), subject, issuer, + issuer_type, created_at: Utc::now(), id: String::new(), body: AttestationBody { - issuer_type, detail: args.detail, kind, r#ref: args.r#ref, diff --git a/src/cli/commands/praise.rs b/src/cli/commands/praise.rs index 34381a0..3342284 100644 --- a/src/cli/commands/praise.rs +++ b/src/cli/commands/praise.rs @@ -85,7 +85,7 @@ fn run_records(args: Args) -> crate::Result<()> { ); // Line 2: issuer + date + truncated ID + (issuer_type) - let issuer_type_suffix = match &att.body.issuer_type { + let issuer_type_suffix = match &att.issuer_type { Some(at) if *at != crate::attestation::IssuerType::Human => { format!(" ({})", at) } @@ -128,7 +128,7 @@ fn run_records(args: Args) -> crate::Result<()> { "epoch", epoch.body.summary, ); - let issuer_type_suffix = match &epoch.body.issuer_type { + let issuer_type_suffix = match &epoch.issuer_type { Some(at) if *at != crate::attestation::IssuerType::Human => { format!(" ({})", at) } @@ -162,7 +162,7 @@ fn record_to_json(record: &crate::attestation::Record) -> Option Option (QualFile, CompactResult) { record_type: "epoch".into(), subject: subject.to_string(), issuer: "urn:qualifier:compact".into(), + issuer_type: Some(IssuerType::Tool), created_at: Utc::now(), id: String::new(), body: EpochBody { - issuer_type: Some(IssuerType::Tool), refs, score: raw, span: None, @@ -132,12 +132,12 @@ mod tests { record_type: "attestation".into(), subject: subject.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 { - issuer_type: None, detail: None, kind, r#ref: None, @@ -161,12 +161,12 @@ mod tests { record_type: "attestation".into(), subject: subject.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 { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, diff --git a/src/qual_file.rs b/src/qual_file.rs index c8498d1..6d0a1c1 100644 --- a/src/qual_file.rs +++ b/src/qual_file.rs @@ -297,12 +297,12 @@ mod tests { record_type: "attestation".into(), subject: subject.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 { - issuer_type: None, detail: None, kind, r#ref: None, diff --git a/src/scoring.rs b/src/scoring.rs index acd9e36..803f5f2 100644 --- a/src/scoring.rs +++ b/src/scoring.rs @@ -243,12 +243,12 @@ mod tests { record_type: "attestation".into(), subject: subject.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 { - issuer_type: None, detail: None, kind, r#ref: None, @@ -272,12 +272,12 @@ mod tests { record_type: "attestation".into(), subject: subject.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 { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, diff --git a/tests/integration.rs b/tests/integration.rs index 8dcf554..bea980e 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -13,12 +13,12 @@ fn make_att(subject: &str, kind: Kind, score: i32, summary: &str) -> Attestation record_type: "attestation".into(), subject: subject.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 { - issuer_type: None, detail: None, kind, r#ref: None, @@ -45,12 +45,12 @@ fn test_golden_attestation_id() { record_type: "attestation".into(), subject: "src/parser.rs".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 { - issuer_type: None, detail: None, kind: Kind::Concern, r#ref: None, @@ -79,12 +79,12 @@ fn test_golden_epoch_id() { record_type: "epoch".into(), subject: "src/parser.rs".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 { - issuer_type: Some(IssuerType::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, "b0be0120b43116b0dbf0132bb6112babcd80ca25cf70caadfb63afeb1acc7993", + epoch.id, "2597b6594fdc1d8d1c1f7a4577637edccb865fa8024349c9caf87344b324bdb4", "Golden epoch ID changed! Canonical form or hashing is broken." ); } @@ -106,6 +106,7 @@ fn test_golden_dependency_id() { record_type: "dependency".into(), subject: "bin/server".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), @@ -249,12 +250,12 @@ fn test_compaction_roundtrip_preserves_scores() { record_type: "attestation".into(), subject: "mod.rs".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 { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -329,10 +330,10 @@ fn test_supersession_cycle_detected() { record_type: "attestation".into(), subject: "x".into(), issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: now, id: "aaa".into(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -349,10 +350,10 @@ fn test_supersession_cycle_detected() { record_type: "attestation".into(), subject: "x".into(), issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: now, id: "bbb".into(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -391,12 +392,12 @@ fn test_cross_artifact_supersession_rejected() { record_type: "attestation".into(), subject: "bar.rs".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 { - issuer_type: None, detail: None, kind: Kind::Pass, r#ref: None, @@ -423,10 +424,10 @@ fn test_kind_typo_detected_in_validation() { record_type: "attestation".into(), subject: "x.rs".into(), issuer: "mailto:test@test.com".into(), + issuer_type: None, created_at: Utc::now(), id: String::new(), body: AttestationBody { - issuer_type: None, detail: None, kind: Kind::Custom("pss".into()), r#ref: None, @@ -468,12 +469,12 @@ fn test_metabox_roundtrip() { record_type: "attestation".into(), subject: "test.rs".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 { - issuer_type: Some(IssuerType::Human), detail: None, kind: Kind::Praise, r#ref: Some("git:3aba500".into()), @@ -493,7 +494,7 @@ fn test_metabox_roundtrip() { let parsed = qf.records[0].as_attestation().unwrap(); assert_eq!(parsed.metabox, "1"); - assert_eq!(parsed.body.issuer_type, Some(IssuerType::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); } @@ -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.issuer_type, Some(IssuerType::Tool)); + assert_eq!(epoch.issuer_type, Some(IssuerType::Tool)); assert_eq!(epoch.body.score, 30); // 40 + -10 } @@ -529,12 +530,12 @@ fn test_supersession_with_new_fields() { record_type: "attestation".into(), subject: "mod.rs".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 { - issuer_type: Some(qualifier::attestation::IssuerType::Human), detail: None, kind: Kind::Pass, r#ref: Some("git:abc123".into()), From fce0da87dd412d06c423e10973c7ca368a422a92 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Fri, 6 Mar 2026 00:25:54 -0500 Subject: [PATCH 4/4] chore: add fmt and quality_gates scripts, reformat site assets Add scripts/fmt.sh (cargo fmt + prettier) and scripts/quality_gates.sh (format, clippy, test, doc, site gates), adapted from toolpath. Prettier-reformat site CSS, JS, and markdown. --- scripts/fmt.sh | 11 +++ scripts/quality_gates.sh | 175 +++++++++++++++++++++++++++++++++++++++ site/css/playground.css | 4 +- site/css/style.css | 3 +- site/index.md | 31 +++---- site/js/playground.js | 41 +++------ site/pages/cli.md | 15 ++-- 7 files changed, 227 insertions(+), 53 deletions(-) create mode 100755 scripts/fmt.sh create mode 100755 scripts/quality_gates.sh 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/index.md b/site/index.md index 941554b..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,14 +179,14 @@ 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 @@ -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 170c154..38a15c9 100644 --- a/site/pages/cli.md +++ b/site/pages/cli.md @@ -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_ISSUER` | -| 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 | |