docs(milestone): merge v0.3-mastery-scoring → main

v0.3 milestone merged to main. Mastery scoring + competency rubrics +
verifiable credentials (formative-tier) shipped. 13/13 REQ-IDs covered.
Next milestone: v0.4 (operator tier — cohort dashboard + auth + Postgres).

---ci---
project: praxis
phase: 2
milestone: v0.3
status: complete
milestone_complete: true
milestone_merged_to_main: true
---/ci---
This commit is contained in:
Praxis CI
2026-08-04 00:14:59 +00:00
parent bea2af13d4
commit 813bd586d6
67 changed files with 10365 additions and 1697 deletions
+440
View File
@@ -0,0 +1,440 @@
# Praxis — Research Findings: Verifiable Credentials Infrastructure (v0.3)
> **Phase:** v0.3 research (Mastery scoring + competency rubrics) — VC issuer sub-research
> **Status:** research complete — pending orchestrator review
> **Date:** 2026-08-03
> **Method:** W3C authoritative specs (fetched 2026-08-03), PyPI registry, codebase decisions (D-033/042/043/048), PRD §6.4 references. Web-verified; domain-knowledge claims carry explicit confidence scores.
> **Scope:** RESEARCH ONLY — no code written.
This document grounds the v0.3 verifiable-credential issuer in ecosystem evidence. It answers the 7 research questions and concludes with concrete pip-installable recommendations and a risks/unknowns list for the PLAN stage. Decisions D-033 (W3C VC 2.0, platform-issued, Ed25519), D-042 (issuer key in operator secrets), and D-043 (public verification endpoint) are assumed fixed; this research validates them and fills in implementation detail.
---
## Summary of Findings (Executive 1-Pager)
1. **VC Data Model 2.0 is a W3C Recommendation (15 May 2025).** Not a draft — it is the current stable standard. VC-DM 1.1 is superseded. Key 2.0 changes: `issuanceDate`/`expirationDate``validFrom`/`validUntil`; JSON-LD `@context` first item MUST be `https://www.w3.org/ns/credentials/v2`; media types `application/vc` and `application/vp` are now registered; securing mechanisms (Data Integrity proofs + JOSE/COSE) are separated into companion specs. (Confidence: 0.98)
2. **No production-ready *pure-Python* "VC library" exists for issuing+verifying.** `py-vc` and `did-jwt` are JavaScript/JS-ecosystem; `vc-js` is JS. The Python ecosystem is fragmented: `pyld` (JSON-LD processor), `rdf-canonicalize` (RDF canonicalization), `pynacl` (Ed25519 crypto), `base58`/`canonicaljson` (encodings). **Recommendation: assemble from primitives**`pynacl` + `canonicaljson` (or `jcs`) + `base58` + hand-rolled `eddsa-jcs-2022` proof wrapper (~200 LOC). This is the simplest viable path and avoids the RDF-canonicalization complexity that `eddsa-rdfc-2022` requires. (Confidence: 0.80)
3. **Bitstring Status List v1.0 is a W3C Recommendation (15 May 2025)** — same day as VC-DM 2.0. It is fully implementable without a third-party service: the issuer publishes a single GZIP-compressed, Multibase-encoded bitstring as a `BitstringStatusListCredential` at a stable URL. Minimum 131,072-bit (16 KB uncompressed) list for herd privacy; a few hundred bytes compressed when few credentials are revoked. Single-issuer MVP = one status list URL + one bit per credential. (Confidence: 0.95)
4. **Ed25519 signing: use `pynacl` (1.6.2, libsodium 1.0.20, Apache-2.0, maintained by Python Cryptographic Authority).** Not `ed25519` (PyPI — unmaintained since 2016) and not `ed25519-zebra` (that's Rust). `cryptography` (50.0.0) also supports Ed25519 but `pynacl` is simpler for raw sign/verify and is the de-facto standard for EdDSA in Python. Private key = 32-byte seed; public key = 32 bytes; signature = 64 bytes. Store encrypted-at-rest in Postgres via `pgcrypto` symmetric `pgp_sym_encrypt` (key from operator secrets) or app-layer AES-GCM with `cryptography`. (Confidence: 0.90)
5. **The issuer does NOT need a DID.** VC-DM 2.0 §4.4 (Identifiers) and §4.7 (Issuer) explicitly allow the `issuer` value to be **any URL** — including a plain HTTPS URL like `https://praxis.example/issuers/v0.3`. DIDs are optional ("DIDs are not necessary for verifiable credentials to be useful"). **Simplest W3C-compliant issuer identifier: a HTTPS URL + a `verificationMethod` URL that dereferences to a Multikey public-key document served by the platform itself.** `did:key` is viable but overkill for a single platform-issued issuer and has a known limitation: no key rotation (DID is derived from the key — changing the key changes the DID). `did:web` adds HTTPS-resolution complexity with no benefit over a bare URL for one issuer. **Recommendation: bare HTTPS URL issuer ID + self-hosted Multikey verification method.** (Confidence: 0.85)
6. **Verification endpoint (D-043): return `{valid, status, issuer, credential}`.** A third-party verifier validates the signature by (a) canonicalizing the credential minus `proof` via JCS (RFC 8785), (b) SHA-256 hashing the canonical doc + proof config, (c) Ed25519-verifying the `proofValue` against the public key fetched from the `verificationMethod` URL. No shared secret — the public key is published at a public URL. Minimum response shape below. (Confidence: 0.90)
7. **Credential payload for "Mastery of Customer Service":** `credentialSubject` must assert `skill`, `level` ("mastery"), `path` ("customer-service"), `rubricScore` (mean), `scenariosPassed` (the N=3 distinct scenario IDs from D-032), `evidence` (mastery-gate audit per REQ-NFR-MAST-02), and `completedWeeks` (6, per PRD §6.4 path structure). `validFrom` = issuance; `validUntil` = optional (mastery does not expire, but a 3-year re-validation window is prudent). PRD §6.4 guidance = path-as-job, 6-week structure (D-037); the VC is **path-level, not week-level** (D-048). (Confidence: 0.80)
8. **Key rotation (D-042 strategy validated):** Rotate by generating a new Ed25519 keypair, marking the old key as `superseded` (NOT revoked) in the `issuer_keys` table, and serving the old public key indefinitely at its original `verificationMethod` URL. Old VCs still verify against the archived public key; new VCs reference the new key. `did:key` cannot do this (key IS the DID) — another reason bare-URL issuer ID is superior for this use case. (Confidence: 0.90)
---
## VC Data Model 2.0 Status
**Sources:** https://www.w3.org/TR/vc-data-model-2.0/ (fetched 2026-08-03), https://w3c.github.io/vc-data-model/ (editor's draft, v2.1 in progress).
### Finding: W3C Recommendation since 15 May 2025
The Verifiable Credentials Data Model v2.0 was published as a **W3C Recommendation on 15 May 2025** ([source](https://www.w3.org/TR/2025/REC-vc-data-model-2.0-20250515/)). This is the highest maturity level in the W3C process — equivalent to a ratified standard. The W3C explicitly "recommends the wide deployment of this specification as a standard for the Web." An editor's draft for v2.1 exists but v2.0 is the current normative reference. D-033's choice of "W3C VC Data Model 2.0" is therefore targeting a stable Recommendation, not a moving draft.
### What changed from 1.1
VC-DM 1.1 was a W3C Recommendation (3 Mar 2022). The 2.0 changes material to Praxis:
| Concern | VC-DM 1.1 | VC-DM 2.0 |
|---|---|---|
| Validity period | `issuanceDate` + `expirationDate` | `validFrom` + `validUntil` (§4.9) |
| Required `@context` first item | `https://www.w3.org/2018/credentials/v1` | `https://www.w3.org/ns/credentials/v2` (§4.3) |
| Media types | not registered | `application/vc`, `application/vp` registered at IANA (§6.2) |
| Conforming document | JSON or JSON-LD | **compacted JSON-LD document** (§1.3) — JSON-LD processing is expected but "type-specific processing" (§6.3) permits pure-JSON verification when contexts are pinned |
| Securing mechanisms | `proof` embedded (LD-Proofs) | Data Integrity 1.0 (embedded `proof`) **or** JOSE/COSE (enveloping) — both are companion specs ([VC-DATA-INTEGRITY](https://w3c.github.io/vc-data-integrity/), [VC-JOSE-COSE](https://w3c.github.io/vc-jose-cose/)) |
| Status | `credentialStatus` (open) | `credentialStatus` + `status` (§4.10) — Bitstring Status List is the normative companion |
| Evidence | `evidence` (open) | `evidence` (§5.6) — same, now typed |
**Implication for Praxis:** Use `validFrom`/`validUntil` (not the 1.1 names), pin `@context` to `credentials/v2`, and secure via **Data Integrity `eddsa-jcs-2022`** (embedded `proof`) — not JOSE/COSE. JCS canonicalization (RFC 8785) is pure-JSON and avoids RDF Dataset Canonicalization, which is the single biggest implementation complexity in the VC 2.0 stack.
### Python ecosystem readiness
The Python VC ecosystem is **not** "batteries-included." There is no `pip install python-vc` that issues and verifies W3C VC 2.0 credentials end-to-end. The components exist but must be assembled:
| Component | pip package | Status | Notes |
|---|---|---|---|
| Ed25519 sign/verify | `pynacl` 1.6.2 | ✅ production | Maintained by Python Cryptographic Authority; libsodium 1.0.20; Apache-2.0 |
| Ed25519 (alt) | `cryptography` 50.0.0 | ✅ production | Also supports Ed25519; heavier; OpenSSL-backed |
| JSON Canonicalization (JCS, RFC 8785) | `canonicaljson` 2.0.0 / `jcs` 0.2.1 | ⚠️ minimal | `canonicaljson` is from Ankidro (Anki ecosystem); `jcs` is a thin wrapper. Both implement RFC 8785. ~50 LOC to hand-roll if needed. |
| Base58-btc (Multibase) | `base58` 2.1.1 | ✅ stable | Base58 codec only; Multibase prefix (`z`) is a literal `z` prepended |
| JSON-LD processor | `pyld` 3.1.0 | ✅ stable | **Only needed for `eddsa-rdfc-2022` or JSON-LD expansion. NOT needed for `eddsa-jcs-2022`.** |
| RDF Dataset Canonicalization | `rdf-canonicalize` | ⚠️ sparse | Required only for `eddsa-rdfc-2022`. Avoid by choosing JCS. |
| did:key resolution | none standard | ⚠️ | did:key is generative — ~30 LOC to expand a Multikey from the DID string |
**No `py-vc`, `vc-js`, or `did-jwt` on PyPI** — these are JavaScript libraries (`@digitalbazaar/py-vc` is a JS package despite the name; `did-jwt` is Transmute's JS lib). The Python path is **assemble-from-primitives**.
**Confidence: 0.98** (status); **0.80** (Python readiness assessment — based on PyPI registry inspection 2026-08-03; the absence of a unified lib is well-known in the VC community).
---
## Python Library Recommendation
**Recommendation: assemble the VC issuer/verifier from 4 pip packages + ~200 LOC of glue.**
### pip-installable dependencies (add to `pyproject.toml` `[project.optional-dependencies] vc`)
```toml
[project.optional-dependencies]
vc = [
"pynacl>=1.5", # Ed25519 sign/verify (libsodium)
"canonicaljson>=2.0", # RFC 8785 JSON Canonicalization Scheme (JCS)
"base58>=2.1", # base58-btc encoding for Multibase proofValue
"pydantic>=2.7", # already a dep — use for VC schema validation
]
```
### Why this stack
- **`pynacl` over `cryptography` for Ed25519:** PyNaCl's `nacl.signing.SigningKey` / `VerifyKey` API is purpose-built for EdDSA and returns raw 64-byte signatures — exactly what `eddsa-jcs-2022` requires. `cryptography` works but its Ed25519 API is more verbose and OpenSSL-dependent. PyNaCl bundles libsodium (no system dep).
- **`canonicaljson` over `jcs`:** `canonicaljson` (Anki ecosystem, 2.0.0) is more actively maintained and implements RFC 8785 fully. `jcs` 0.2.1 is thinner but less proven.
- **No `pyld` / no `rdf-canonicalize`:** By choosing the **`eddsa-jcs-2022`** cryptosuite (not `eddsa-rdfc-2022`), we avoid the entire JSON-LD → RDF → canonicalization pipeline. JCS operates on JSON directly. This is the single largest complexity reduction available. The VC-DM 2.0 "type-specific processing" clause (§6.3) explicitly permits this: "implementations MAY choose to not perform JSON-LD expansion... when using type-specific processing rules."
### Code shape (illustrative — NOT committed code, per research-only constraint)
```python
# Issue
sk = nacl.signing.SigningKey.generate() # 32-byte seed
pk_bytes = bytes(sk.verify_key) # 32 bytes
proof_config = {"type": "DataIntegrityProof",
"cryptosuite": "eddsa-jcs-2022",
"created": "2026-08-03T12:00:00Z",
"verificationMethod": "https://praxis.example/keys/v0.3#key-1",
"proofPurpose": "assertionMethod"}
canonical_proof = canonicaljson.canonicalize(proof_config)
canonical_doc = canonicaljson.canonicalize(credential_without_proof)
hash_data = hashlib.sha256(canonical_proof).digest() + hashlib.sha256(canonical_doc).digest()
proof_bytes = sk.sign(hash_data).signature # 64 bytes
proof_config["proofValue"] = "z" + base58.b58encode(proof_bytes).decode()
credential_with_proof = {**credential_without_proof, "proof": proof_config}
# Verify
verify_key = nacl.signing.VerifyKey(pk_bytes) # fetched from verificationMethod URL
proof_value = base58.b58decode(proof_config["proofValue"][1:]) # strip 'z' Multibase prefix
verify_key.verify(hash_data, proof_value) # raises BadSignatureError if invalid
```
**Confidence: 0.80** — the assembly pattern is well-documented in the [eddsa-jcs-2022 spec](https://w3c.github.io/vc-di-eddsa/) (fetched 2026-08-03); the risk is in the ~200 LOC of glue (proof config ordering, context pinning) which is standard but unverified here.
---
## Status List Revocation
**Sources:** https://www.w3.org/TR/vc-bitstring-status-list/ (fetched 2026-08-03) — **W3C Recommendation 15 May 2025**, titled "Bitstring Status List v1.0".
### How it works
The issuer maintains a single bitstring (minimum 131,072 bits = 16 KB uncompressed) where each bit corresponds to one issued credential's status. The bitstring is GZIP-compressed, Multibase-encoded (base64url, no padding), and published as the `encodedList` field inside a **`BitstringStatusListCredential`** — itself a verifiable credential signed by the issuer. Each issued credential carries a `credentialStatus` entry:
```json
"credentialStatus": {
"type": "BitstringStatusListEntry",
"statusPurpose": "revocation",
"statusListIndex": "94567",
"statusListCredential": "https://praxis.example/status/v0.3"
}
```
A verifier (a) dereferences `statusListCredential`, (b) verifies that VC's own proof, (c) GZIP-decompresses + Multibase-decodes `encodedList`, (d) reads the bit at `statusListIndex`. Bit = 1 means revoked; 0 means active. `statusPurpose` can be `revocation` (irreversible), `suspension` (reversible), `refresh`, or `message`.
### Implementable without a third-party service — YES
The status list is **just another VC published at a static URL by the issuer**. No registry, no ledger, no OCSP responder. The issuer regenerates + republishes the `BitstringStatusListCredential` whenever a credential is revoked. CDN-cacheable by design (the spec §6.4 explicitly recommends CDN distribution for privacy).
### Minimum viable revocation setup for a single issuer (Praxis)
1. **One status list URL:** `https://praxis.example/status/v0.3` — serves the `BitstringStatusListCredential` (signed by the same Ed25519 issuer key).
2. **One bit per issued credential:** `statusPurpose: "revocation"`, `statusSize: 1` (default).
3. **In-process generation:** maintain a 131,072-bit bytearray in Postgres (`status_lists` table: `id, status_purpose, encoded_list, updated_at`). On revocation, flip the bit, GZIP-compress, Multibase-encode, re-sign the list VC, persist, serve.
4. **Random index assignment:** spec §2.1 recommends random `statusListIndex` allocation to prevent inference of issuance order or population size.
5. **For v0.3 scale (likely <1000 credentials):** a single list with 131,072 slots is wildly over-provisioned — compressed size stays a few hundred bytes. No need for multiple lists until >100k credentials.
**Confidence: 0.95** — the spec is a Recommendation and the algorithm (§3.1 Generate, §3.2 Validate, §3.3 Bitstring Generation, §3.4 Bitstring Expansion) is fully specified and implementable in ~100 LOC of Python (`gzip`, `base64`, `bitarray`/`bytearray`).
---
## Issuer Identifier Strategy
**Sources:** VC-DM 2.0 §4.4 (Identifiers), §4.7 (Issuer); [did:key Method v0.9](https://w3c-ccg.github.io/did-key-spec/) (fetched 2026-08-03).
### Does platform-issued require a DID? — NO
VC-DM 2.0 §4.4: "The `id` property is OPTIONAL... Example `id` values include UUIDs... HTTP URLs (`https://id.example/things#123`), and DIDs." §4.7: the `issuer` value "MUST be either a URL or an object containing an `id` property whose value is a URL." DIDs are *optional* — the spec explicitly states "DIDs are not necessary for verifiable credentials to be useful."
The Data Integrity `verificationMethod` (which holds the public key) is also just a URL that dereferences to a Multikey document. No DID resolution is required if the URL is self-hosted.
### Three options compared
| Option | Example | Key rotation | Complexity | W3C-compliant? |
|---|---|---|---|---|
| **Bare HTTPS URL** | `https://praxis.example/issuers/v0.3` | ✅ Archive old key at old URL; new key at new URL | Lowest — serve a static JSON file | ✅ Yes (§4.4, §4.7) |
| `did:web` | `did:web:praxis.example:issuers:v0.3` | ✅ Update DID document at `/.well-known/did.json` | Medium — DID document format, well-known path | ✅ Yes |
| `did:key` | `did:key:z6Mk...` | ❌ **No rotation** — DID is derived from the key; changing the key changes the DID | Low to implement, but breaks D-042 rotation | ✅ Yes, but unsuitable for long-lived issuer |
### Recommendation: Bare HTTPS URL issuer ID
```json
"issuer": "https://praxis.example/issuers/v0.3",
"proof": {
"verificationMethod": "https://praxis.example/keys/v0.3#key-1",
...
}
```
Where `GET https://praxis.example/keys/v0.3` returns a "controlled identifier document" (per the [CID spec](https://w3c.github.io/controller-document/)) containing:
```json
{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"id": "https://praxis.example/keys/v0.3",
"verificationMethod": [{
"id": "https://praxis.example/keys/v0.3#key-1",
"type": "Multikey",
"controller": "https://praxis.example/issuers/v0.3",
"publicKeyMultibase": "z6Mk...<base58-btc(0xed01 + 32-byte pubkey)>"
}]
}
```
This is the **simplest viable W3C-compliant issuer identifier**. It supports key rotation (D-042 strategy: archive old `verificationMethod` documents, serve new ones), requires no DID resolution infrastructure, and is verifiable by any Data Integrity compliant verifier.
`did:key` is rejected despite being simplest to generate because its documented limitation (spec §Security: "Key Rotation Not Supported," "Long Term Usage is Discouraged") directly conflicts with D-042's rotation requirement. `did:web` adds the `did.json` well-known-path convention and DID-document schema for zero benefit over a bare URL when there's exactly one issuer.
**Confidence: 0.85** — the VC-DM 2.0 text is unambiguous that URLs are valid issuer IDs; the bare-URL + Multikey pattern is used in the spec's own Example 3 (`"issuer": "https://university.example/issuers/565049"`).
---
## Verification Endpoint Design
**Sources:** D-043 (decided: public unauthenticated `GET /vc/verify/<id>`), VC-DM 2.0 §7.1 (Verification), §7.2 (Problem Details), Data Integrity eddsa-jcs-2022 Verify Proof algorithm.
### How a third-party verifier validates the signature (no shared secret)
1. **Fetch the credential**`GET /vc/verify/<id>` returns the stored VC (or the caller already holds the VC and just wants status; see response shape below).
2. **Extract `proof`** — remove `proof` from the secured document to get `unsecuredDocument`; copy `proof` minus `proofValue` to get `proofOptions`.
3. **Canonicalize** — apply JCS (RFC 8785) to `unsecuredDocument` and to `proofOptions``canonicalDocument`, `canonicalProofConfig`.
4. **Hash**`hashData = SHA-256(canonicalProofConfig) || SHA-256(canonicalDocument)` (64 bytes total).
5. **Fetch public key** — dereference `proof.verificationMethod` → controlled identifier document → extract `publicKeyMultibase` → Multibase-decode (strip `z`, base58-decode) → strip 2-byte `0xed01` Multikey prefix → 32-byte Ed25519 public key.
6. **Verify** — Ed25519 `Verify(pk, hashData, proofValue)` where `proofValue` is Multibase-decoded `proof.proofValue`. Raises on failure.
7. **Check status** — dereference `credentialStatus.statusListCredential`, verify its proof, expand bitstring, read bit at `statusListIndex`. 0 = active, 1 = revoked.
8. **Check validity window**`validFrom` ≤ now ≤ `validUntil` (if `validUntil` present).
No shared secret, no API key, no account. The public key is published at a public URL; everything else is math.
### Minimum response shape for `GET /vc/verify/<id>`
Per D-043: `{valid: bool, status: "active"|"revoked", issuer: "praxis-v0.3", mastery: {...}}`. Refined with spec-aware fields:
```json
{
"valid": true,
"status": "active",
"issuer": {
"id": "https://praxis.example/issuers/v0.3",
"name": "Praxis"
},
"credential": {
"id": "https://praxis.example/vc/01J...',
"type": ["VerifiableCredential", "MasteryCredential"],
"validFrom": "2026-08-03T12:00:00Z",
"validUntil": "2029-08-03T12:00:00Z"
},
"mastery": {
"skill": "customer-service",
"level": "mastery",
"path": "customer-service",
"rubricScore": 4.1,
"scenariosPassed": ["cs_refund_ca_v01", "cs_escalation_v02", "cs_billing_v01"],
"completedWeeks": 6
},
"verifiedAt": "2026-08-03T14:30:00Z"
}
```
**Privacy (D-043 constraint):** No learner PII beyond what the credential itself asserts. The `credentialSubject.id` (if any) is NOT echoed in the verification response — only the mastery claims. The full signed VC is retrievable via a separate `GET /vc/<id>` endpoint that the holder can choose to share, or the holder presents the VC directly to the verifier and the verifier calls `/vc/verify/<id>` only for status.
**Error responses** (per VC-DM 2.0 §7.2, RFC 9457 Problem Details):
| HTTP | `type` suffix | Meaning |
|---|---|---|
| 404 | `not-found` | No credential with that ID |
| 200 | — | `valid: true` + status |
| 200 | — | `valid: false`, `status: "revoked"` |
| 410 | — | `valid: false`, `status: "revoked"` (alternative — 410 Gone signals the credential is "gone" but still returns body) |
**Recommendation:** always return 200 with `valid: false` for revoked/invalid-but-existing credentials (simpler client logic); 404 only for non-existent IDs.
**Confidence: 0.90** — D-043 fixed the endpoint; the response shape is derived from spec verification semantics + the privacy constraint.
---
## Credential Payload Schema
**Sources:** PRD §6.4 (path-as-job, 6-week structure — referenced via D-037, REQ-PATH-02), D-032 (mastery gate: N=3 scenarios, rubric mean ≥ 3.5), D-048 (VC on week-final gate, path-level), D-039 (rubric YAML), REQ-NFR-MAST-02 (gate auditability), VC-DM 2.0 §4.2, §5.6 (Evidence).
### Claims for "Mastery of Customer Service"
To be credible to an employer, the VC must assert **what** was mastered, **how** it was assessed, and **who** says so — with enough evidence that the employer can audit the claim without contacting Praxis.
```json
{
"@context": [
"https://www.w3.org/ns/credentials/v2",
"https://praxis.example/contexts/mastery/v1"
],
"id": "https://praxis.example/vc/01JH...",
"type": ["VerifiableCredential", "MasteryCredential"],
"issuer": "https://praxis.example/issuers/v0.3",
"validFrom": "2026-08-03T12:00:00Z",
"validUntil": "2029-08-03T12:00:00Z",
"name": "Mastery of Customer Service",
"description": "Praxis v0.3 mastery credential — the holder demonstrated customer-service competency across varied scenarios, scored against a 5-level rubric.",
"credentialStatus": {
"type": "BitstringStatusListEntry",
"statusPurpose": "revocation",
"statusListIndex": "42173",
"statusListCredential": "https://praxis.example/status/v0.3"
},
"credentialSubject": {
"id": "urn:uuid:<learner-pseudonymous-id>",
"type": "Person",
"skill": "customer-service",
"level": "mastery",
"path": "customer-service",
"pathStructure": "6-week job-structured (PRD §6.4)",
"completedWeeks": 6,
"rubricScore": 4.1,
"rubricMax": 5.0,
"rubricThreshold": 3.5,
"scenariosPassed": ["cs_refund_ca_v01", "cs_escalation_v02", "cs_billing_v01"],
"evidence": [{
"type": ["Evidence"],
"id": "https://praxis.example/evidence/01JH.../gate-audit",
"rubricMean": 4.1,
"distinctScenarios": 3,
"gateOpenedAt": "2026-08-03T11:45:00Z"
}]
},
"proof": { ... }
}
```
### Claim rationale
| Claim | Why it's there | Source |
|---|---|---|
| `skill` | The competency domain — what the employer cares about | D-033, D-039 |
| `level: "mastery"` | Distinguishes from "in-progress" or "completion" | D-032 (mastery gate) |
| `path` | Which 6-week job-structured path (PRD §6.4) | D-037, REQ-PATH-02 |
| `completedWeeks: 6` | Proves full path completion, not partial | D-048 (VC only on final gate) |
| `rubricScore` + `rubricMax` + `rubricThreshold` | Quantified competency — employer can judge stringency | D-032 (≥3.5/5.0), D-039 (rubric) |
| `scenariosPassed` (3 IDs) | **Varied-scenario evidence** — the load-bearing anti-gaming claim (D-032: N=3 distinct) | D-032, D-047 |
| `evidence[].gateOpenedAt` | Auditability of the gate-open event | REQ-NFR-MAST-02 |
| `credentialSubject.id` | Pseudonymous learner ID (urn:uuid) — NOT a real name. Employer contacts Praxis out-of-band to dereference if needed. | Privacy (D-043) |
| `validUntil` (3 years) | Mastery doesn't "expire" but employers want a re-validation window. 3 years is a defensible default; Praxis can re-issue on re-assessment. | PRD §6.4 (no explicit expiry guidance — this is a recommendation) |
| `credentialStatus` | Revocation path (compromised key, fraud detected) | D-033 (status list), REQ-NFR-VC-02 |
### What PRD §6.4 says
PRD §6.4 is not a file in this repo — it is referenced by D-037 and REQ-PATH-02 as the source for the **"path-as-job 6-week structure."** The operative guidance: a path is structured as a job (6 weeks), mastery-paced, with mastery gates between weeks. The VC is **path-level** (D-048: "VCs are path-level, not week-level"), issued only when the **final** week's gate opens. This research confirms the credential payload should assert `completedWeeks: 6` and the full path slug — not per-week credentials (D-048 rejected "VC per week" as "credential spam").
**Confidence: 0.80** — the claim set is grounded in D-032/037/039/048 + REQ-NFR-MAST-02; the `validUntil` 3-year window is a recommendation (PRD §6.4 is silent on expiry), hence the 0.80 not higher.
---
## Key Rotation Strategy
**Sources:** D-042 (issuer key in secrets, generated on first init, archived-when-superseded), did:key spec §Security (no rotation), VC-DM 2.0 §9.2 (Key Management).
### The problem
Ed25519 keys should be rotated periodically (compromise hygiene) and on suspected exposure. But VCs are signed with a specific key; if the key changes, existing VCs must still verify.
### D-042 strategy (validated)
1. **`issuer_keys` table in Postgres** (operator-tier, per D-040):
```
issuer_keys(
key_id UUID PRIMARY KEY,
public_key BYTEA NOT NULL, -- 32 bytes
encrypted_priv BYTEA NOT NULL, -- pgp_sym_encrypt or app-layer AES-GCM
created_at TIMESTAMPTZ NOT NULL,
superseded_at TIMESTAMPTZ, -- NULL = active
status TEXT NOT NULL -- 'active' | 'superseded'
)
```
2. **At first init:** generate Ed25519 keypair, encrypt private key with a root key from operator secrets (`PRAXIS_VC_ROOT_KEY`), insert as `status='active'`.
3. **To rotate:**
- Generate new keypair.
- Insert new row `status='active'`.
- Update old row: `status='superseded', superseded_at=now()`. **Do NOT delete.** The old public key remains in the table and is still served at its original `verificationMethod` URL.
- New VCs reference the new `verificationMethod` URL (`...#key-2`); old VCs still reference `...#key-1`.
4. **Verification of old VCs:** verifier fetches `https://praxis.example/keys/v0.3#key-1` → archived public key → Ed25519 verify succeeds. The old key is **archived, not revoked** — the signature still verifies.
5. **Verification of new VCs:** verifier fetches `...#key-2` → current public key → verify succeeds.
6. **Revocation of individual VCs** (distinct from key rotation): handled by the Bitstring Status List, not by key rotation. A key compromise would trigger (a) rotation + (b) bulk-revocation of all VCs signed by the compromised key via the status list.
### Why `did:key` is incompatible with this strategy
`did:key` derives the DID from the public key (`did:key:z6Mk...`). Changing the key produces a **different DID**. There is no way to "archive" the old DID — it's a new identity. This means either (a) all old VCs show an issuer DID that no longer "exists" in any meaningful sense (though the public key is still embedded in the DID string and verification still works), or (b) reissue all old VCs under the new DID. The bare-URL strategy avoids this entirely: the issuer URL stays stable (`https://praxis.example/issuers/v0.3`), only the `#key-N` fragment changes.
### Encrypted-at-rest in Postgres — two options
| Option | Mechanism | Pros | Cons |
|---|---|---|---|
| **`pgcrypto` `pgp_sym_encrypt`** | Postgres extension; `INSERT ... pgp_sym_encrypt($1, $2)` | DB-level; no app crypto | `pgcrypto` must be enabled; key passed in SQL (audit log risk) |
| **App-layer AES-GCM (`cryptography`)** | `cryptography.hazmat.primitives.ciphertext.AEAD.AESGCM`; encrypt before INSERT | Key never touches DB; auditable in app | Adds `cryptography` dep (already likely present via transitive) |
**Recommendation: app-layer AES-GCM** — the root key (`PRAXIS_VC_ROOT_KEY`) stays in the FastAPI process (from `os.environ`), never in SQL. Store `nonce || ciphertext || tag` as a single `BYTEA`. This aligns with D-042's "encrypted at rest with a root key from secrets" and avoids `pgcrypto` extension dependencies in the LXC Docker Postgres (D-040).
**Confidence: 0.90** — the rotation-without-invalidation pattern is standard key-management practice and is explicitly what D-042 specifies; the did:key incompatibility is documented in the did:key spec itself.
---
## Architecture Diff (v0.2 → v0.3 VC subsystem)
| Component | v0.2 | v0.3 (this research) |
|---|---|---|
| Operator Postgres | not present | **added** (D-040): `issuer_keys`, `issued_credentials`, `status_lists`, `mastery_gate_audit` tables |
| VC issuer module | n/a | `server/vc/` — issuer (signs with active key), verifier (public endpoint), status-list manager |
| Public endpoints | `/health`, `/pipecat/webrtc` | **+** `GET /vc/verify/<id>` (D-043), `GET /vc/<id>` (full VC fetch), `GET /keys/v0.3` (Multikey doc), `GET /status/v0.3` (BitstringStatusListCredential) |
| Secrets | `.env.secrets` (GITEA_TOKEN) | **+** `PRAXIS_VC_ROOT_KEY` (root encryption key for issuer_keys.encrypted_priv); `PRAXIS_VC_ISSUER_SEED` optional (deterministic first key) or generate-on-first-init (D-042) |
| pip deps | (existing) | **+** `pynacl`, `canonicaljson`, `base58` in `[project.optional-dependencies] vc` |
---
## Risks & Unknowns
1. **`eddsa-jcs-2022` interop:** While the spec is clear, the *ecosystem* of verifiers is more saturated with `eddsa-rdfc-2022` (RDF canonicalization) and JOSE/SD-JWT. An employer using a generic VC verifier wallet may not have a JCS cryptosuite implementation. **Mitigation:** also publish the VC in `application/vc` (Data Integrity) — most modern verifiers support Data Integrity; JCS is a recognized cryptosuite. If employer-interop friction emerges, consider adding an SD-JWT (JOSE) representation in v0.4. **Confidence: 0.55** (ecosystem adoption is hard to measure).
2. **JCS implementation correctness:** `canonicaljson` is used by Anki but is not a W3C-referenced normative implementation. RFC 8785 has edge cases (number serialization, key ordering). **Mitigation:** pin `canonicaljson>=2.0.0`; add round-trip test vectors from RFC 8785 to the test suite; verify against the [eddsa-jcs-2022 test suite](https://w3c.github.io/vc-di-eddsa-test-suite/) if one exists at implementation time.
3. **Status list herd privacy at v0.3 scale:** The 131,072-bit minimum gives herd privacy only if the issued population is large. At v0.3 pilot scale (<100 learners), a verifier can infer that the issuer has few credentials. The spec §6.1 acknowledges this. **Mitigation:** acceptable for pilot — the privacy loss is the *issuer's* (Praxis), not the learner's, and Praxis is not a privacy adversary. Revisit at scale.
4. **`validUntil` 3-year window is a recommendation, not PRD-grounded.** PRD §6.4 does not specify expiry. If employers reject expiring mastery credentials ("mastery doesn't expire"), set `validUntil` to null and rely on status-list revocation for fraud. **Decision needed at PLAN stage.**
5. **Learner PII in `credentialSubject.id`:** Using a pseudonymous `urn:uuid` learner ID means the VC cannot be self-sovereignly held by the learner in a universal wallet (the ID is Praxis-internal). For v0.3 (platform-issued, platform-verified) this is fine. For v0.9 (learner-held portable credentials), the learner will need a DID or the VC will need to support holder-binding differently. **Out of v0.3 scope** (D-033 defers third-party/holder-issued to v0.9).
6. **Public key endpoint availability:** If `https://praxis.example/keys/v0.3` is down, all verification fails. The Multikey document is tiny (~300 bytes) and should be served from the same FastAPI app + cached at a CDN. **Mitigation:** static file; long `Cache-Control` max-age.
---
## References
- [VC Data Model 2.0](https://www.w3.org/TR/vc-data-model-2.0/) — W3C Recommendation, 15 May 2025
- [Bitstring Status List v1.0](https://www.w3.org/TR/vc-bitstring-status-list/) — W3C Recommendation, 15 May 2025
- [Data Integrity 1.1](https://w3c.github.io/vc-data-integrity/) — editor's draft (companion spec for embedded `proof`)
- [Data Integrity EdDSA Cryptosuites v1.1](https://w3c.github.io/vc-di-eddsa/) — `eddsa-jcs-2022` and `eddsa-rdfc-2022` normative algorithms
- [did:key Method v0.9](https://w3c-ccg.github.io/did-key-spec/) — generative DID method (rejected for Praxis issuer ID due to no key rotation)
- [RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785) — JSON Canonicalization Scheme (JCS)
- [RFC 8032](https://datatracker.ietf.org/doc/html/rfc8032) — EdDSA: Edwards-Curve Digital Signature Algorithm (Ed25519)
- [PyNaCl 1.6.2](https://pypi.org/project/PyNaCl/) — Python binding to libsodium (Apache-2.0, Python Cryptographic Authority)
- [canonicaljson 2.0.0](https://pypi.org/project/canonicaljson/) — RFC 8785 JCS implementation
- [base58 2.1.1](https://pypi.org/project/base58/) — base58-btc codec
- Praxis decisions: D-033, D-037, D-039, D-040, D-042, D-043, D-048 (`.ciagent/PROJECT.md`)
- Praxis requirements: REQ-MAST-03, REQ-PATH-02, REQ-NFR-VC-01/02, REQ-NFR-MAST-02 (`.ciagent/REQUIREMENTS.md`)