feat(daemon): explicit classify/abstain signal on /v1/infer (#131)#36
Conversation
/v1/infer now emits a `classification` field ("classified" | "abstain")
alongside attackClass, so a consumer can distinguish a confident benign
from "the model could not produce a usable verdict". Until now both
carried attackClass:"" and were indistinguishable, so FGA Step 5 failed
silently open.
- Abstain when inference throws or the predicted-class confidence is below
ABSTAIN_CONFIDENCE_FLOOR (0.5, a conservative heuristic pending
selective-risk calibration). On abstain, attackClass is forced to "" so a
low-confidence guess is never actionable; the raw guess is kept in evidence.
- The HTTP 500 error body also carries classification:"abstain".
- Additive and backward/forward compatible: older consumers ignore the
field; an older daemon lets the consumer fall back to its legacy
attackClass==""?abstain:classified heuristic. No lockstep deploy required.
- 20/20 daemon tests pass (+2). Bump @nanomind/daemon 0.3.0 -> 0.4.0.
There was a problem hiding this comment.
Claude Code Review
Security Review: PR #36 — Explicit classify/abstain signal on /v1/infer
VERDICT: APPROVE
SUMMARY:
This PR introduces an explicit classification field ("classified" | "abstain") to the /v1/infer response contract, addressing issue #131 where confident benign verdicts were indistinguishable from "model could not answer" cases (both produced attackClass: ""). The change is additive and backward-compatible: older consumers continue to work with the existing attackClass behavior, while new consumers can read the explicit signal. The implementation includes a conservative ABSTAIN_CONFIDENCE_FLOOR = 0.5 threshold, below which verdicts are marked as abstain with attackClass forced to "" to prevent low-confidence guesses from being actionable. Three new tests verify the abstain logic (no-confidence engine, sub-floor confidence, at-floor boundary). The change is contract/fallback only with no model retrain required. All mitigations are properly implemented: the confidence floor check includes NaN handling, the HTTP 500 error path carries the abstain signal, and the raw low-confidence guess is preserved in evidence for audit. No security, correctness, or publish-surface regressions detected.
FINDINGS:
None. All potential concerns were verified and found to have adequate mitigations in place:
- ✅ The
hasConfidencecheck correctly handles both missing and NaN values (line 306-307 in server.ts) - ✅ The confidence floor comparison is inclusive (
>=) as intended, with a boundary test confirming the behavior - ✅ The HTTP 500 error path includes
classification: "abstain"(line 343 in server.ts) - ✅ Low-confidence verdicts preserve the raw guess in
evidencefor audit (line 324 in server.ts) - ✅ The change is additive; no breaking changes to the wire contract
Reviewed 7 files changed (28985 bytes)
#36) (#37) /v1/infer now emits a `classification` field ("classified" | "abstain") alongside attackClass, so a consumer can distinguish a confident benign from "the model could not produce a usable verdict". Until now both carried attackClass:"" and were indistinguishable, so FGA Step 5 failed silently open. - Abstain when inference throws or the predicted-class confidence is below ABSTAIN_CONFIDENCE_FLOOR (0.5, a conservative heuristic pending selective-risk calibration). On abstain, attackClass is forced to "" so a low-confidence guess is never actionable; the raw guess is kept in evidence. - The HTTP 500 error body also carries classification:"abstain". - Additive and backward/forward compatible: older consumers ignore the field; an older daemon lets the consumer fall back to its legacy attackClass==""?abstain:classified heuristic. No lockstep deploy required. - 20/20 daemon tests pass (+2). Bump @nanomind/daemon 0.3.0 -> 0.4.0.
Summary
/v1/infernow emits an explicitclassificationfield ("classified" | "abstain") alongsideattackClass, so a consumer can distinguish a confident benign from "the model could not produce a usable verdict". Until now both carriedattackClass: ""and were indistinguishable, so the downstream FGA Step 5 intent check failed silently open (issue #131).What changed
InferResponse.classification: "classified" | "abstain"."classified": the model produced a usable verdict (benign OR an attack class) at or aboveABSTAIN_CONFIDENCE_FLOOR.attackClassis authoritative (""is a confident benign)."abstain": the model could not produce a usable verdict — inference threw, the engine reported no usable confidence (missing/NaN), or the predicted-class confidence was below the floor.attackClassis forced to""so a low-confidence guess is never actionable; the raw guess is preserved inevidence. A non-classifying engine abstains rather than masquerading as a confident benign.ABSTAIN_CONFIDENCE_FLOOR = 0.5— a conservative heuristic, not a calibrated value. The v0.5.0 classifier saturates confidence near 1.0, so this rarely trips today; the dominant fix is the explicit field. Calibration is a separate follow-up.classification: "abstain"for consumers that read the body without checking the status code.Compatibility
Additive and forward/backward compatible. Older consumers ignore the field and still get a deterministic
attackClass(""on abstain); an older daemon that omits the field lets the consumer fall back to its legacyattackClass==""?abstain:classifiedheuristic. No lockstep deploy required. No model retrain.Tests
21/21 daemon tests pass (was 18; +3): a no-confidence engine result abstains; sub-floor confidence abstains and blanks
attackClass; a verdict exactly at the inclusive floor classifies through. Build clean; HMA static scan 98/100 (one pre-existing LOW: missing.gitignorein the package subdir). Bumps@nanomind/daemon0.3.0 → 0.4.0.The matching consumer change lands in
agent-identity-management(FGA Step 5 readsclassification+ checks the HTTP status code).