This repository has been archived on 2026-09-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Praxis CI 813bd586d6 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---
2026-08-04 00:14:59 +00:00

34 KiB

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/expirationDatevalidFrom/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 primitivespynacl + 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). 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, 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)

[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)

# 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 (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:

"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 (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

"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) containing:

{
  "@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 credentialGET /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 proofOptionscanonicalDocument, canonicalProofConfig.
  4. HashhashData = 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 windowvalidFrom ≤ 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:

{
  "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.

{
  "@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 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