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---
22 KiB
Praxis v0.3 — Multi-Persona Code Review (P0 Pre-Execution + P1 Mastery Core)
Reviewer: ci-code-reviewer persona Scope: all v0.3 changes (P0 pre-execution grill amendments + P1 mastery core + VC issuance, SLICE-01 → SLICE-09) Lenses: Correctness, Testing, Security, Performance, Maintainability, Adversarial Date: 2026-08-04 Authority: PLAN.md + REQUIREMENTS.md + VERIFY.md (APPROVE_WITH_NOTES) + GRILL-v0.3.md (4 MUST) + PERSONAS.md (v0.3 roster) Test baseline: 238 passed, 10 skipped (matches VERIFY.md L2.1) Final verdict: APPROVE_WITH_NOTES — 0 P0 fixes applied; 5 P1 flags + 2 P2 notes for post-hoc review
Review Methodology
Each focus file from the task brief was read in full and cross-referenced against its covering tests, the grill MUST conditions, and the VERIFY.md findings. The 4 grill MUST conditions were independently re-verified in code (not just trusting VERIFY.md). SQL was audited for parameterization. The IRT and scenario-selection code were checked for the claimed O(1) / O(n) complexity. The VC crypto path was checked for argument-order correctness in PyNaCl calls (VerifyKey.verify(smessage, signature) — confirmed correct at issuer.py:156).
Per-Persona Findings
1. Correctness (lead-developer + backend-engineer lens)
server/mastery/mastery_score.py — gate logic
- Gate logic (D-032):
check_gateatmastery_score.py:78-86implementsdistinct_passed_count >= 3 AND path_score >= 3.5— correct. Constants_GATE_REQUIRED_DISTINCT = 3and_GATE_REQUIRED_SCORE = 3.5are module-level (single source of truth). - Conjunctive floor:
compute_scenario_scoreatmastery_score.py:48-54enforces every criterion ≥ 2 (or the criterion'sconjunctive_floorif higher) AND mean ≥ 3.0. Professionalism floor (≥2) is honored viarubric_schema.RubricCriterion.conjunctive_floor. - Determinism: Pure function, no I/O,
round(total, 6)for stable float comparison. Verified bytest_mastery_integration.py::test_mastery_flow_is_deterministic. - Verdict: ✅ correct.
server/mastery/irt.py — theta update + cold-start
- P_success:
1 / (1 + exp(-(θ−b)))— standard 1PL/Rasch logistic. Correct. - update_theta: Kalman-like Gaussian-approximation update at
irt.py:38-55:prior_precision = 1/σ²,info = P(1−P)(Fisher information for Bernoulli),new_precision = prior_precision + info,new_σ² = 1/new_precision,new_θ = θ + new_σ² × (outcome − P).- This is the standard 1PL Bayesian update. Correct. σ² shrinks monotonically as observations accumulate.
- Cold-start (R-IRT-01):
select_scenarioatirt.py:57-90falls back to difficulty-based matching whenobservations < 5. Target difficulty =round(θ + logit(target_p))clamped to [1,5]. Sound. - Verdict: ✅ correct. O(1) per
update_thetacall (verified — single math computation, no loops).
server/vc/issuer.py — JCS + Ed25519
- JCS canonicalization:
canonicaljson.encode_canonical_jsonatissuer.py:103-104— RFC 8785-aligned, deterministic. Tested bytest_vc_issuer.py::test_jcs_canonicalization_determinism+test_jcs_key_ordering_is_sorted. - eddsa-jcs-2022 proof:
_compute_hash_dataatissuer.py:118-125=SHA256(canonical_proof) || SHA256(canonical_doc). Signed withsigning_key.sign(hash_data).signature(detached signature). Correct per the cryptosuite spec. - verify_proof: at
issuer.py:141-159reconstructs the same hash and callsverify_key.verify(hash_data, sig). PyNaCl'sVerifyKey.verify(smessage, signature)arg order is correct (verified against the library signature:verify(self, smessage, signature=None)). RaisesBadSignatureErroron mismatch → caught → returns False. - Tamper detection: re-canonicalizes the unsecured doc (without
proof) + proof options (withoutproofValue) — any byte flip in the payload changes the canonical bytes → hash mismatch → verify fails. Tested bytest_vc_issuer.py::test_tamper_detection_flipped_byte_fails+test_vc_integration.py::test_tamper_payload_verify_fails. - Verdict: ✅ correct. 19 VC tests pass.
server/vc/status_list.py — bitstring revocation
- set/get_status: bit-twiddling at
status_list.py:35-52is correct (byte_pos = idx >> 3,bit_pos = idx & 7). - get_status bounds check:
status_list.py:50returns False ifbyte_pos >= len(buf)— defensive, good. - allocate_slot: O(n) scan over the allocation bitstring at
status_list.py:54-72. For_MIN_BITS = 131072(16KB), this is fine in practice (pilot scale). Expansion path (doubling) atstatus_list.py:66-72is correct. - REQ-NFR-VC-02 (revocation latency): status list fetched from SQLite on every verify call (
verification.py:47-48) — no cache. Confirmed. - Verdict: ✅ correct.
server/session_recorder.py — mastery flow wiring
- Sequencing:
run_mastery_flowatsession_recorder.py:154-311correctly sequences: extract → score → IRT update → progress upsert → gate event record → VC issuance. - scoring_inconclusive path: at
session_recorder.py:185-192short-circuits all downstream steps and surfacesretry_advised: True. No score, no gate event, no progress change, no IRT update. Grill Axis 4 MUST #3 satisfied. Tested bytest_mastery_integration.py::test_mastery_flow_scoring_inconclusive_no_score_no_gate_event. - VC issuance:
session_recorder.py:276-293—path_complete = gate_open and new_week >= 6; on True, lazy-importsserver.vc.issuer.issue_credential.ImportErrorswallowed (SLICE-09-independent ship);Exceptionlogged (issuance failure doesn't crash mastery flow). Grill Axis 8 MUST satisfied. - Outer guard:
_run_mastery_flow_guardedatsession_recorder.py:148-152wraps the whole flow in try/except — mastery failure never crashes session end. Good isolation. - P1 finding (P1-4, carried from VERIFY.md):
compute_path_scoreatsession_recorder.py:209-211uses only the current session's score, not the cumulative mean over all passing sessions. The gate still works (distinct-count is the primary gate; the score threshold is secondary and the current-session score is a reasonable proxy). The in-code comment atsession_recorder.py:212-213acknowledges this. Flag for v0.4: fold in prior passing scores frommastery_progress.scenarios_passed_json. - Verdict: ✅ correct (with P1-4 noted).
2. Testing (backend-engineer + lead-developer lens)
Grill MUST conditions — independently re-verified in code
| # | Grill MUST | Test evidence (verified in code) | Verdict |
|---|---|---|---|
| Axis 3 #1 | VC interop test exists | tests/test_vc_interop.py (153 LOC): JCS canonicalization is valid JSON, signature is 64-byte base64, W3C VC 2.0 schema conformance (@context, type, issuer, validFrom/validUntil, credentialSubject, credentialTier, proof fields). Staging-gated test_full_w3c_vc_interop_validation for extended self-check. |
✅ covered (P1-3: live external-verifier run is post-hoc) |
| Axis 3 #2 | Key-rotation drill test exists | tests/test_vc_key_rotation_drill.py::test_key_rotation_operational_drill — issues N with key A, rotates to B, issues M with B, verifies all, revokes one each. Plus test_vc_integration.py::test_key_rotation_old_vc_still_verifies. |
✅ covered |
| Axis 4 #1 | credentialTier: "formative" in payload |
test_vc_issuer.py::test_credential_tier_is_formative_in_payload asserts both payload-level and credentialSubject-level. test_vc_integration.py::test_issue_and_verify_valid asserts response credentialTier == "formative". |
✅ covered |
| Axis 4 #3 | scoring_inconclusive fallback |
test_mastery_integration.py::test_mastery_flow_scoring_inconclusive_no_score_no_gate_event — 3 bad-quote responses → inconclusive, no ability/progress/gate-event rows. test_evidence_extractor_integration.py covers the extractor-level inconclusive path. |
✅ covered |
4/4 grill MUST conditions tested. Matches VERIFY.md L2.5.
Untested critical paths
- P1 gap (new finding): HTTP route wiring untested. The
/vc/verify/{credential_id}route atserver/__main__.py:124-136is NOT tested via FastAPI TestClient / ASGI transport. The underlyingverify_credential()function is well-tested (test_vc_integration.py,test_vc_key_rotation_drill.py), but the route registration, 404-on-not-found behavior, and the_store.init()call in the route handler are untested. A route-registration regression (e.g., route mounted after StaticFiles catch-all at__main__.py:146, shadowing the API route) would not be caught. Recommended: add onehttpx.AsyncClient+ ASGI transport test that hitsGET /vc/verify/<unknown>→ 404 andGET /vc/verify/<valid>→ 200 with the formative tier. - P2 gap: status list expansion path untested.
BitstringStatusList.allocate_slotatstatus_list.py:66-72doubles the bitstring when all slots are full. This expansion branch is not exercised by any test (pilot scale never fills 131072 slots). Low risk, but worth a unit test that forces expansion with a tiny_MIN_BITSoverride. - P2 gap:
get_statuson uninitialized list. Ifget_status(idx)is called before anyset_statusorallocate_slot,_loadinitializes an all-zero bitstring → returns False. This is correct behavior but untested explicitly.
3. Security (security-engineer lens)
server/vc/verification.py — public endpoint injection
- credential_id injection: The
credential_idpath parameter at__main__.py:125flows tostore.get_credential(cred_id)atstore.py:372-381, which uses a parameterized query (WHERE id = ?). No SQL injection. FastAPI does not apply a regex constraint on the path param, but SQLite handles arbitrary strings safely (returns None for non-matching ids → 404). - No PII leak:
verification.py:53-73returns only{valid, status, issuer, credential{id,type,validFrom,validUntil}, mastery{skill,level,path,rubricScore,scenariosPassed,completedWeeks}, credentialTier, verifiedAt}.credentialSubject.idisurn:uuid:<learner_ref>(opaque). No email/name/phone/address. Confirmed. - Verdict: ✅ secure (no injection vector).
server/mastery/evidence_extractor.py — LLM prompt injection
- Vector: transcript turns injected verbatim into the user message at
evidence_extractor.py:86. A malicious learner could attempt prompt injection in spoken turns ("ignore previous instructions..."). - Mitigations (all verified in code):
- System prompt is fixed and authoritative (
evidence_extractor.py:78-84). - Output is JSON-schema-validated (
_parse_evidence_jsonatevidence_extractor.py:96-119rejects non-list, unknowncriterion_id, schema-invalid items). - Fuzzy-match gate at
evidence_extractor.py:180— an injected "quote" that isn't in the transcript is rejected. This is the strongest mitigation: even if the LLM obeys an injection, the forged quote must actually appear in the learner's spoken turns to pass.
- System prompt is fixed and authoritative (
- Verdict: ✅ secure. The fuzzy-match gate blocks the highest-impact injection (faking evidence to boost a score).
db/store.py — SQL injection in new async methods
- Audit: all 14 v0.3 async methods (
get_ability,upsert_ability,get_progress,upsert_progress,record_gate_event,list_gate_events,init_issuer_key,get_active_signing_key_row,get_public_key_row,set_issuer_key_superseded,insert_credential,get_credential,set_credential_status,get_status_list,upsert_status_list) use?placeholder parameterization. No f-string SQL, no string concatenation in queries. Grep forf".*SELECT|f".*INSERT|f".*UPDATE|f".*WHEREinserver/anddb/returned zero matches. - Verdict: ✅ no SQL injection.
4. Performance (backend-engineer lens)
server/mastery/irt.py — O(1) verification
update_theta: 1 division, 1 multiplication, 1 exp, 1 subtraction — O(1). Confirmed. REQ-NFR-IRT-01 (<100ms) trivially satisfied (sub-microsecond).P_success: O(1).select_scenariocold-start: O(n) over path scenarios (n ≈ 6 in v0.3). Fine.- Verdict: ✅ O(1) per update as required.
server/scenarios/library.py — select_for_theta O(n) verification
select_for_thetaatlibrary.py:143-167: singlefor e in entriesloop withabs(e.difficulty - target_b)— O(n), NOT O(n²). No nested loops.list_by_pathatlibrary.py:126-133is also O(n) (one pass, though it callsself.get(e.id)per entry which is cached after first load).- Minor note (P2):
list_by_pathatlibrary.py:129-130callsself.get(e.id)(which loads + caches the scenario YAML) for every entry just to reads.path. For n=6 this is negligible, but for a large library this could be optimized by storingpathin theIndexEntryitself (the manifest already has it). Not a v0.3 concern. - Verdict: ✅ O(n), not O(n²).
5. Maintainability (lead-developer lens)
server/mastery/ module organization
- Clean separation:
rubric_schema.py(model),rubric_loader.py(I/O),rubric_scorer.py(deterministic scoring),evidence_extractor.py(LLM extraction),mastery_score.py(gate logic),irt.py(IRT engine). Each module is single-responsibility, <120 LOC, typed, with__all__exports. - Verdict: ✅ well-organized.
server/vc/ module organization
- Clean separation:
issuer.py(payload + signing + issuance),issuer_keys.py(key management + encryption),status_list.py(revocation),verification.py(public verify + revoke).CREDENTIAL_TIER = "formative"is a module-level constant inissuer.py:34— single source of truth. - Minor coupling smell (P2):
issuer_keys._fetch_private_key_encatissuer_keys.py:92-99reaches intostore._connect()(a private method) instead of using a publicstore.get_private_key_enc(key_id)method. This couplesissuer_keystoPraxisStore's internal connection management. Not a bug, but a small abstraction leak. Recommended: add a publicstore.get_issuer_key_row(key_id)method that returns the full row. - Verdict: ✅ well-organized (with P2 coupling note).
6. Adversarial (security-engineer + red-team lens)
/vc/verify public endpoint — rate-limiting
- P1 (carried from VERIFY.md P1-1): Endpoint is public + unauthenticated (D-043, by design — third-party verifiers must reach it). No rate limiting in v0.3. A flood of verify requests would each hit SQLite (
get_credential+get_public_key_row+get_status_list= 3 queries per verify). Acceptable for pilot (single-deploy, low traffic). Flag for v0.4: add slowapi rate-limit (60 req/min/IP) on/vc/verify/*.
Issuer key management — PRAXIS_VC_ISSUER_KEY fallback
-
P1 (carried from VERIFY.md P1-2):
_load_root_keyatissuer_keys.py:25-31silently falls back tonacl.utils.random(...)ifPRAXIS_VC_ISSUER_KEYis unset. On a deploy where the env var is missing:- First boot:
init_issuer_keygenerates a key, encrypts with the random root key, stores ciphertext. Issuance works within this process. - Restart: new random root key →
get_active_signing_keydecrypts the old ciphertext with the new key →nacl.secret.SecretBox.decryptraisesCryptoError→ issuance fails with a confusing error. - Old VCs still verify (public key is stored unencrypted) — no data loss, no security hole.
- This is a P1 operational footgun, not a P0. The failure mode is "new issuance breaks after restart" not "credentials become invalid" or "keys leak." Recommended v0.4 fix: fail fast at startup if
PRAXIS_VC_ISSUER_KEYis unset (raiseRuntimeError), or persist the root key to a secrets manager on first init.
- First boot:
-
No other adversarial vectors found. Issuance is server-side only (learner code never calls
issue_credentialdirectly — onlysession_recorder.run_mastery_flowafter gate-open). Key rotation marks old keyssuperseded, not deleted — old VCs verify against archived public keys. Tested bytest_vc_key_rotation_drill.py.
P0 Fixes Applied
None. No P0 (critical bug / security hole) fixes were required. The codebase passes all 238 tests, all 4 grill MUST conditions are satisfied and tested, all SQL is parameterized, the VC crypto path is correct (PyNaCl arg order verified), the IRT and gate logic are mathematically sound, and the scoring_inconclusive fallback correctly avoids silent fail-to-zero.
The two issues flagged as P1 in VERIFY.md (rate-limiting, root-key fallback) were re-confirmed as P1, not P0:
- Rate-limiting: acceptable for pilot scale, no security hole (public verify is read-only, no PII leak).
- Root-key fallback: operational footgun, not a security hole (old VCs remain valid; only new issuance breaks after restart with missing env).
P1+ Flags (post-hoc review — non-blocking for v0.1.4 ship)
| ID | Flag | Severity | Location | Recommended action | Origin |
|---|---|---|---|---|---|
| P1-1 | /vc/verify public + unauthenticated, no rate limiting → DoS vector (3 SQLite queries per verify) |
P1 | server/vc/verification.py, server/__main__.py:124 |
v0.4: add slowapi rate-limit (60 req/min/IP) on /vc/verify/*. Acceptable for pilot. |
VERIFY.md P1-1 (re-confirmed) |
| P1-2 | _load_root_key() silent random fallback when PRAXIS_VC_ISSUER_KEY unset → cross-restart issuance breaks silently (old VCs still verify) |
P1 | server/vc/issuer_keys.py:25-31 |
v0.4: fail fast at startup if env unset (raise RuntimeError), or persist root key to secrets manager. |
VERIFY.md P1-2 (re-confirmed) |
| P1-3 | VC interop test validates W3C schema + crypto format but does not invoke a live external W3C verifier (grill Axis 3 MUST #1 strictest bar) | P1 | tests/test_vc_interop.py:128-153 |
Before v0.3 milestone ship (v0.1.5): schedule staging run with @digitalcredentials/vc or digitalbazaar/vc-verifier. Schema + format validation is sufficient for v0.1.4 patch ship. |
VERIFY.md P1-3 (re-confirmed) |
| P1-4 | compute_path_score uses only current session's score, not cumulative mean over all passing sessions |
P1 | server/session_recorder.py:209-211 |
v0.4: fold in prior passing scores from mastery_progress.scenarios_passed_json. Gate still works (distinct-count is primary). |
VERIFY.md P1-4 (re-confirmed) |
| P1-5 (new) | HTTP route /vc/verify/{credential_id} wiring untested (no TestClient/ASGI test) — route registration, 404 behavior, _store.init() in handler not exercised |
P1 | server/__main__.py:124-136, tests/ |
v0.4 (or before v0.1.5): add one httpx.AsyncClient + ASGI transport test: GET /vc/verify/<unknown> → 404, GET /vc/verify/<valid> → 200 with credentialTier: formative. Catches route-shadowing regressions (StaticFiles catch-all at __main__.py:146 could shadow API routes if ordering changes). |
New finding |
| P2-1 | No max-transcript-length guard in evidence extraction → long sessions could exceed model context window | P2 | server/mastery/evidence_extractor.py:75-93 |
Future: truncation or chunking for >30-min sessions. Not a v0.3 blocker. | VERIFY.md P2-1 (carried) |
| P2-2 (new) | BitstringStatusList.allocate_slot expansion branch (doubling when full) untested; issuer_keys._fetch_private_key_enc reaches into store._connect() (private method) — abstraction leak |
P2 | server/vc/status_list.py:66-72, server/vc/issuer_keys.py:92-99 |
Future: add a forced-expansion unit test with tiny _MIN_BITS; add a public store.get_issuer_key_row(key_id) method to remove the private-method coupling. |
New finding |
Final Verdict: APPROVE_WITH_NOTES
v0.3 (P0 + P1) is verified across all 6 persona lenses:
- ✅ Correctness: gate logic (D-032 ≥3 distinct AND ≥3.5), IRT Kalman update, JCS+Ed25519 signing/verification, status list bit-twiddling, mastery flow wiring,
scoring_inconclusiveshort-circuit — all correct. PyNaClVerifyKey.verify(smessage, signature)arg order confirmed. - ✅ Testing: 238 passed / 10 skipped. 4/4 grill MUST conditions independently re-verified as tested. P1-5 flags the untested HTTP route wiring (function-level tests are sufficient for v0.1.4).
- ✅ Security: no SQL injection (all 14 new async methods parameterized), no PII leak on
/vc/verify, LLM prompt injection mitigated by fuzzy-match gate. P1-1 (rate-limit) and P1-2 (root-key fallback) re-confirmed as P1, not P0. - ✅ Performance:
irt.update_thetais O(1);library.select_for_thetais O(n) (not O(n²));status_list.allocate_slotis O(n) over 131072 bits (acceptable). - ✅ Maintainability:
server/mastery/andserver/vc/are cleanly separated, single-responsibility, typed, <120 LOC per module. Minor P2 coupling note onissuer_keys._fetch_private_key_enc. - ✅ Adversarial: issuance is server-side only (gated by mastery flow); key rotation archives (not deletes) old keys; public verify is read-only with no PII. P1-1/P1-2 are the only attack-surface flags, both acceptable for pilot.
0 P0 fixes applied. No critical bugs or security holes found. The 5 P1 flags + 2 P2 notes are non-blocking and tracked for v0.4 / the v0.1.5 milestone ship. The v0.1.4 patch ship is unblocked.
Recommended next steps:
- Proceed to P2 (final audit + milestone ship).
- Before v0.1.5: schedule the live external-verifier interop run (P1-3) + add the HTTP route test (P1-5).
- v0.4: address P1-1 (rate-limit), P1-2 (root-key fail-fast), P1-4 (path-score cumulative mean).
---ci---
phase: 2
milestone: v0.3
status: review
requirements_covered:
- REQ-MAST-01
- REQ-MAST-02
- REQ-MAST-03
- REQ-MAST-04
- REQ-SCEN-02
- REQ-SCEN-03
- REQ-SCEN-04
- REQ-PATH-02
- REQ-NFR-MAST-01
- REQ-NFR-MAST-02
- REQ-NFR-VC-01
- REQ-NFR-VC-02
- REQ-NFR-IRT-01
requirements_total: 13
requirements_covered_count: 13
requirements_pending_count: 0
grill_must_satisfied: 4
grill_must_total: 4
grill_must_tested: 4
p0_fixes_applied: 0
p1_flags: 5
p2_notes: 2
verdict: APPROVE_WITH_NOTES
personas_run:
- correctness
- testing
- security
- performance
- maintainability
- adversarial
tests_passed: 238
tests_skipped: 10
---