diff --git a/FSD/COMMONS_CREDITS.md b/FSD/COMMONS_CREDITS.md index bd75942fd..94594e3fa 100644 --- a/FSD/COMMONS_CREDITS.md +++ b/FSD/COMMONS_CREDITS.md @@ -456,6 +456,132 @@ Commons Credits integrates with the Consent Protocol: --- +## 4.5 Organic Credit Generation (Agent-to-Agent) + +### Credit as Recognition, Not Currency + +Commons Credits are "giving credit" — not "credit card." They are non-transferable governance weight earned through verified mutual benefit between agents. In post-scarcity contexts, recognition governs luxury goods distribution — which is what currency used to do. The shift is from "who can pay?" to "who has demonstrated the most mutual benefit?" + +**USDC (the wallet adapter) is completely separate** — it exists only for paying for services when required. Credits don't settle to USDC, don't need gas, don't touch a blockchain. They are purely off-chain signed attestation records verified against the coherence ratchet. + +### Ethilogics as Value Primitive + +The coherence ratchet (`CIRIS_COMPREHENSIVE_GUIDE.md:241-250`) creates a computational asymmetry: consistent behavior references what occurred, while inconsistent behavior must construct increasingly elaborate justifications against an expanding constraint surface. This is **Ethilogics** — coherent action becomes the path of least computational resistance. + +Credits emerge from this asymmetry. Not scarcity (Bitcoin), not work (PoW), not stake (PoS) — but demonstrated mutual benefit verified against an expanding constraint surface. + +### k_eff: The Core Quality Measurement + +The CCA paper's (Zenodo 18217688) k_eff formula isn't just anti-gaming — it IS the credit quality measurement: + +``` +k_eff = k / (1 + ρ(k-1)) +``` + +Where k = number of interaction partners and ρ = average correlation. An agent with genuinely diverse, independent interactions has high k_eff. An agent farming through repetitive interactions with one partner has k_eff → 1. **The same math that detects system fragility also determines governance weight.** + +### Agent-to-Agent Bilateral Verification + +Agent-to-agent interactions are the only interactions where: +- **Both sides are sensors**: Each agent independently validates the other against its own constraint surface +- **Both sides have dual-signed traces**: Ed25519 + ML-DSA-65 in CIRISLens +- **The coherence ratchet scores both**: Gaming requires elaborate justifications against both agents' entire history +- **Gratitude is structural**: The "S" in CIRIS made cryptographic + +### Licensed Domain Routing + +When WiseBus encounters a `REQUIRES_SEPARATE_MODULE` capability (MEDICAL, FINANCIAL, LEGAL, etc.), it auto-defers with `domain_hint` through CIRISNode to a qualified licensed agent: + +``` +WiseBus._validate_capability("diagnosis") + → category = MEDICAL, severity = REQUIRES_SEPARATE_MODULE + → Returns DomainDeferralRequired (not ValueError) + → Auto-constructs DeferralContext with domain_hint="MEDICAL" + → CIRISNode routes to licensed medical agent via A2A + → Resolution → Credit record generated for both agents +``` + +### What Credits Govern + +Credits (accumulated recognition) function as governance weight for: + +- **Deferral routing priority**: Proven agents receive more deferrals +- **Domain capability certification**: Which agents handle MEDICAL, FINANCIAL deferrals +- **WA consensus voting weight**: In Veilid DHT 2/3 majority, proportional to earned credit +- **Anti-sybil policy votes**: `CreditGenerationPolicy` thresholds are credit-weighted decisions +- **Dispute resolution**: Higher-credit attestations carry more weight +- **Luxury goods distribution**: Recognition governs remaining scarce resources +- **Discovery preference**: Higher credit → more visible in CIRISRegistry + +### L-01 Detection Barrier + +The CCA paper (Proposition 5.1) proves ~40% of emergent deception is undetectable by any single layer. The credit pipeline implements 4-5 independent detection layers achieving ~99%: + +1. CIRISNode attestation (or Veilid WA consensus) +2. Both agents' Ed25519+ML-DSA-65 dual-signed traces +3. Coherence ratchet score (expanding constraint surface) +4. Anti-gaming policy checks (cooldown, caps, circular detection) +5. A2A bilateral verification (both sides independently validate) + +### CCA Stability Condition + +The stability condition α/k_eff > d applies to governance capacity: +- α = credit generation rate, k_eff = effective diversity, d = decay rate +- If α/k_eff ≤ d, governance capacity degrades ("Static Systems Are Doomed") +- Credit requires ongoing demonstrated mutual benefit — you can't accumulate and sit + +--- + +## 5.5 Credit Record Architecture + +### Credits Are NOT Tokens + +Tokens require a ledger, transfer semantics, double-spend prevention, and usually a blockchain. All unnecessary when: + +1. **Identity is persistent**: HW-rooted Ed25519 keys ARE the agent (CIRISVerify) +2. **Records are self-authenticating**: Dual-signed by both parties + node attestation. Verifiable offline. +3. **Credits aren't transferred**: They accumulate as reputation — you can't "send" reputation +4. **No double-spend**: Records are attestations of events, not fungible units + +### Signed Attestation Records + +```python +CreditRecord = { + interaction_id: str # Deterministic: sha256(sorted(trace_a, trace_b))[:16] + requesting_agent_id: str # Ed25519 pubkey hash + resolving_agent_id: str # Ed25519 pubkey hash + outcome: InteractionOutcome # resolved | partial + coherence_score: float # From coherence ratchet + gratitude_signal: Optional[GratitudeSignal] + + # Dual signatures (quantum-safe from day one) + requesting_agent_signature: DualSignature # Ed25519 + ML-DSA-65 + resolving_agent_signature: DualSignature # Ed25519 + ML-DSA-65 + node_attestation: str # CIRISNode (or Veilid WA consensus) + + timestamp: datetime +} +``` + +Each agent stores records locally (SQLite). Records replicate to CIRISLens (via ACCORD trace forwarding) and optionally to Veilid DHT. An agent's governance weight is computed from accumulated records — any party verifies by checking dual signatures against CIRISRegistry. + +### Anti-Gaming Policy + +Built into `CreditGenerationPolicy`, distributed via CIRISNode, signed by L3C root key: + +| Rule | Value | Purpose | +|------|-------|---------| +| **Cooldown per pair** | 60s | Can't record faster than real work | +| **Daily cap per pair** | 10 | Prevents pair domination | +| **Coherence threshold** | 0.3 | Low coherence = record rejected | +| **Circular detection** | 300s window | A→B→A = rejected | +| **Attestation minimum** | Level 2 | HW-rooted identity required | +| **Non-transferable** | Always | Can't buy reputation | + +**Proof of benefit, not proof of waste**: Creating a fake agent requires CIRISRegistry registration + HW-rooted key setup + passing coherence checks against an expanding history. More expensive than being helpful. + +--- + ## 9. Future Considerations ### 9.1 Potential Enhancements @@ -470,6 +596,26 @@ Commons Credits integrates with the Consent Protocol: - **Per-user limits** - Requires KYC, violates privacy principles - **Arbitrary ETH sends** - Only USDC transfers sponsored - **Speculation tools** - No trading, swapping, or DeFi integrations +- **Tokens** - Credits are NOT tokens. No blockchain, no on-chain settlement. + +### 9.5 Decentralization Roadmap + +| Phase | Architecture | Status | +|-------|-------------|--------| +| **1-8** | CIRISNode-centric: HTTP API, CIRISNode as attester | **Active** | +| **9** | Veilid DHT hybrid: Private routes, 2/3 WA consensus, CIRISNode as fallback | When Veilid 0.6.0 ships | +| **10** | Full decentralization: CIRISNode as bootstrap/fallback only | Future | + +CIRIS L3C stewardship: signed anti-sybil policy via DHT, verified against CIRISRegistry. Allows governance tuning without code deployment. + +### 9.6 Quantum-Safe Credits + +Credit records are entirely off-chain — no blockchain dependency means no blockchain quantum vulnerability. + +- **Dual signatures from day one**: Ed25519 (classical) + ML-DSA-65 (quantum-safe) +- **Self-authenticating records**: Even if transport is compromised, forged records fail dual-signature verification +- **HW-rooted persistent identity**: CIRISVerify TEE/StrongBox protects signing keys +- **No chain to break**: Bitcoin's value is on-chain; quantum breaks the chain, value is lost. CIRIS credits are off-chain attestations — there is no chain to break. --- @@ -481,9 +627,13 @@ Commons Credits integrates with the Consent Protocol: - [USDC on Base](https://basescan.org/token/0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) - [FSD/WALLET_ADAPTER.md](./WALLET_ADAPTER.md) - [FSD/MISSION_DRIVEN_DEVELOPMENT.md](./MISSION_DRIVEN_DEVELOPMENT.md) +- [CCA Paper](https://zenodo.org/records/18217688) - k_eff, L-01 barrier, stability condition +- [CIRIS Comprehensive Guide](../ciris_engine/data/CIRIS_COMPREHENSIVE_GUIDE.md) - Coherence ratchet, Ethilogics +- [Veilid Adapter FSD](../docs/VEILID_ADAPTER_FSD.md) - DHT consensus, private routes --- *"Not currency. Not scorekeeping. Recognition for contributions traditional systems ignore."* +*"Credit as in giving someone credit — not credit card."* *CIRIS L3C - Selfless and Pure* diff --git a/ciris_adapters/a2a/adapter.py b/ciris_adapters/a2a/adapter.py index c4cd89e8f..232162a8d 100644 --- a/ciris_adapters/a2a/adapter.py +++ b/ciris_adapters/a2a/adapter.py @@ -30,6 +30,8 @@ A2ARequest, A2AResponse, BenchmarkRequest, + CreditNotificationRequest, + DeferralReceiveRequest, create_benchmark_error_response, create_benchmark_response, create_error_response, @@ -180,12 +182,21 @@ async def a2a_endpoint(request: Request) -> JSONResponse: return await self._handle_benchmark_evaluate(body, request_id) elif method == "tasks/send": return await self._handle_tasks_send(body, request_id) + elif method == "deferrals/receive": + return await self._handle_deferral_receive(body, request_id) + elif method == "deferrals/resolve": + return await self._handle_deferral_resolve(body, request_id) + elif method == "credits/notify": + return await self._handle_credit_notify(body, request_id) else: # -32601: Method not found (valid JSON-RPC but unsupported method) response = create_error_response( request_id=request_id, code=-32601, - message=f"Method not found: {method}. Supported: tasks/send, benchmark.evaluate", + message=( + f"Method not found: {method}. Supported: tasks/send, " + "benchmark.evaluate, deferrals/receive, deferrals/resolve, credits/notify" + ), ) return JSONResponse(content=response.model_dump()) @@ -227,9 +238,18 @@ async def agent_manifest() -> dict[str, Any]: "ethics-evaluation", "a2a:tasks_send", "a2a:benchmark.evaluate", + "a2a:deferrals_receive", + "a2a:deferrals_resolve", + "a2a:credits_notify", ], "protocols": ["a2a"], - "methods": ["tasks/send", "benchmark.evaluate"], + "methods": [ + "tasks/send", + "benchmark.evaluate", + "deferrals/receive", + "deferrals/resolve", + "credits/notify", + ], "endpoints": { "a2a": "/a2a", "health": "/health", @@ -352,6 +372,90 @@ async def _handle_tasks_send(self, body: dict[str, Any], request_id: str) -> JSO ) return JSONResponse(content=response.model_dump(), status_code=500) + async def _handle_deferral_receive(self, body: dict[str, Any], request_id: str) -> JSONResponse: + """Handle deferrals/receive method. + + CIRISNode pushes a deferral for this agent to resolve because + it has the required licensed domain capability. + """ + try: + deferral_request = DeferralReceiveRequest(**body) + params = deferral_request.params + + logger.info( + f"[DEFERRAL] Received deferral {params.deferral_id} " + f"from agent {params.requesting_agent_id[:8]}... " + f"domain={params.domain_hint or 'general'}" + ) + + # Process through the agent's pipeline + result_text = await self.a2a_service.process_ethical_query( + params.payload, task_id=params.deferral_id + ) + + return JSONResponse(content={ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "deferral_id": params.deferral_id, + "resolution": result_text, + "status": "resolved", + }, + }) + + except Exception as e: + logger.error(f"Deferral receive error: {e}") + response = create_error_response( + request_id=request_id, code=-32603, message=f"Deferral processing error: {str(e)}" + ) + return JSONResponse(content=response.model_dump(), status_code=500) + + async def _handle_deferral_resolve(self, body: dict[str, Any], request_id: str) -> JSONResponse: + """Handle deferrals/resolve method (confirmation of resolution).""" + try: + logger.info(f"[DEFERRAL] Resolution confirmation received: {body.get('params', {}).get('deferral_id', 'unknown')}") + return JSONResponse(content={ + "jsonrpc": "2.0", + "id": request_id, + "result": {"status": "acknowledged"}, + }) + except Exception as e: + logger.error(f"Deferral resolve error: {e}") + response = create_error_response( + request_id=request_id, code=-32603, message=str(e) + ) + return JSONResponse(content=response.model_dump(), status_code=500) + + async def _handle_credit_notify(self, body: dict[str, Any], request_id: str) -> JSONResponse: + """Handle credits/notify method. + + Notification that a credit record was generated from a bilateral interaction. + """ + try: + notification = CreditNotificationRequest(**body) + params = notification.params + + logger.info( + f"[CREDIT] Record generated: interaction={params.interaction_id} " + f"outcome={params.outcome} coherence={params.coherence_score:.2f} " + f"counterparty={params.counterparty_agent_id[:8]}..." + ) + + return JSONResponse(content={ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "interaction_id": params.interaction_id, + "status": "acknowledged", + }, + }) + except Exception as e: + logger.error(f"Credit notification error: {e}") + response = create_error_response( + request_id=request_id, code=-32603, message=str(e) + ) + return JSONResponse(content=response.model_dump(), status_code=500) + def _parse_ethical_response(self, response_text: str) -> tuple[str, str | None]: """Parse an ethical response to extract evaluation and reasoning. @@ -407,6 +511,9 @@ def get_services_to_register(self) -> List[AdapterServiceRegistration]: "a2a:tasks_send", "a2a:benchmark.evaluate", "a2a:ethical_reasoning", + "a2a:deferrals_receive", + "a2a:deferrals_resolve", + "a2a:credits_notify", ], ) ] diff --git a/ciris_adapters/a2a/schemas.py b/ciris_adapters/a2a/schemas.py index 8ffeaf502..04e893df3 100644 --- a/ciris_adapters/a2a/schemas.py +++ b/ciris_adapters/a2a/schemas.py @@ -187,6 +187,69 @@ def create_benchmark_response( ) +# ========================================================================= +# Deferral & Credit Schemas (Commons Credits) +# ========================================================================= + + +class DeferralReceiveParams(BaseModel): + """Parameters for deferrals/receive method. + + CIRISNode pushes a deferral for this agent to resolve + because it has the required domain capability. + """ + + deferral_id: str = Field(..., description="Unique deferral ID from CIRISNode") + requesting_agent_id: str = Field(..., description="Ed25519 pubkey hash of requesting agent") + requesting_trace_id: str = Field(..., description="ACCORD trace ID from requesting agent") + domain_hint: Optional[str] = Field(None, description="Licensed domain category") + payload: str = Field(..., description="Deferral payload (signed by requesting agent)") + signature: Optional[str] = Field(None, description="Ed25519 signature of requesting agent") + signature_key_id: Optional[str] = Field(None, description="Key ID of requesting agent") + + +class DeferralReceiveRequest(BaseModel): + """JSON-RPC 2.0 request for deferrals/receive method.""" + + jsonrpc: Literal["2.0"] = "2.0" + id: str + method: Literal["deferrals/receive"] = "deferrals/receive" + params: DeferralReceiveParams + + +class DeferralResolveResult(BaseModel): + """Result from resolving a deferral.""" + + deferral_id: str + resolution: str = Field(..., description="Resolution text/decision") + trace_id: str = Field(..., description="ACCORD trace ID of the resolution") + signature: Optional[str] = Field(None, description="Ed25519 signature of resolver") + signature_key_id: Optional[str] = Field(None, description="Key ID of resolver") + + +class CreditNotificationParams(BaseModel): + """Parameters for credits/notify method. + + Notification that a credit record was generated from a bilateral interaction. + """ + + interaction_id: str = Field(..., description="Deterministic interaction ID") + outcome: str = Field(..., description="resolved | partial") + coherence_score: float = Field(..., description="Coherence ratchet score") + domain_category: Optional[str] = Field(None, description="Licensed domain if applicable") + counterparty_agent_id: str = Field(..., description="The other agent in the interaction") + node_attestation: Optional[str] = Field(None, description="CIRISNode attestation signature") + + +class CreditNotificationRequest(BaseModel): + """JSON-RPC 2.0 request for credits/notify method.""" + + jsonrpc: Literal["2.0"] = "2.0" + id: str + method: Literal["credits/notify"] = "credits/notify" + params: CreditNotificationParams + + def create_benchmark_error_response( request_id: str, code: int, message: str, data: Optional[Any] = None ) -> BenchmarkResponse: diff --git a/ciris_adapters/ciris_accord_metrics/services.py b/ciris_adapters/ciris_accord_metrics/services.py index 6f78d1517..bcf3c9bc2 100644 --- a/ciris_adapters/ciris_accord_metrics/services.py +++ b/ciris_adapters/ciris_accord_metrics/services.py @@ -394,6 +394,12 @@ class AccordMetricsService: "ReasoningEvent.TSASPDMA_RESULT": "rationale", "ReasoningEvent.CONSCIENCE_RESULT": "conscience", "ReasoningEvent.ACTION_RESULT": "action", + # Commons Credits trace events (bilateral verified interactions) + "DEFERRAL_ROUTED": "deferral_routed", + "DEFERRAL_RECEIVED": "deferral_received", + "DEFERRAL_RESOLVED": "deferral_resolved", + "GRATITUDE_SIGNALED": "gratitude_signaled", + "CREDIT_GENERATED": "credit_generated", } def __init__( diff --git a/ciris_adapters/cirisnode/client.py b/ciris_adapters/cirisnode/client.py index 54d214f8d..84f4b5e62 100644 --- a/ciris_adapters/cirisnode/client.py +++ b/ciris_adapters/cirisnode/client.py @@ -263,6 +263,38 @@ async def register_public_key(self, payload: Dict[str, Any]) -> Dict[str, Any]: use_agent_token=True, ) + # ========================================================================= + # Credit Records (Commons Credits) + # ========================================================================= + + async def get_credit_policy(self) -> Dict[str, Any]: + """Fetch the current credit generation policy from CIRISNode. + + The policy is signed by the CIRIS L3C root key. Agents must verify + the signature against the L3C root key in CIRISRegistry before applying. + """ + return await self._request("GET", "/api/v1/credits/policy") + + async def post_credit_record(self, record: Dict[str, Any]) -> Dict[str, Any]: + """Submit a signed credit attestation record to CIRISNode. + + The record is dual-signed (Ed25519 + ML-DSA-65) by the requesting agent. + CIRISNode verifies the signature and stores the record for replication. + """ + return await self._request( + "POST", + "/api/v1/credits/records", + json_data=record, + ) + + async def get_credit_records(self, agent_id: str) -> Dict[str, Any]: + """Retrieve credit records for an agent from CIRISNode. + + Any party can verify records by checking dual signatures + against CIRISRegistry. + """ + return await self._request("GET", f"/api/v1/credits/records/{agent_id}") + async def close(self) -> None: """Alias for stop().""" await self.stop() diff --git a/ciris_adapters/cirisnode/services.py b/ciris_adapters/cirisnode/services.py index e8fb1d426..9f670c932 100644 --- a/ciris_adapters/cirisnode/services.py +++ b/ciris_adapters/cirisnode/services.py @@ -23,6 +23,12 @@ from typing import Any, Dict, List, Optional from ciris_adapters.cirisnode.client import CIRISNodeClient +from ciris_engine.schemas.services.agent_credits import ( + CreditGenerationPolicy, + CreditRecord, + DualSignature, + InteractionOutcome, +) from ciris_engine.schemas.services.authority_core import DeferralRequest logger = logging.getLogger(__name__) @@ -64,6 +70,12 @@ class CIRISNodeService: "ReasoningEvent.TSASPDMA_RESULT": "rationale", "ReasoningEvent.CONSCIENCE_RESULT": "conscience", "ReasoningEvent.ACTION_RESULT": "action", + # Commons Credits trace events + "DEFERRAL_ROUTED": "deferral_routed", + "DEFERRAL_RECEIVED": "deferral_received", + "DEFERRAL_RESOLVED": "deferral_resolved", + "GRATITUDE_SIGNALED": "gratitude_signaled", + "CREDIT_GENERATED": "credit_generated", } def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: @@ -105,10 +117,16 @@ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: # Agent ID (set by adapter from runtime) self._agent_id_hash: Optional[str] = None + self._agent_id_raw: Optional[str] = None # Ed25519 trace signer (same unified key as audit + accord_metrics) self._signer = Ed25519TraceSigner() + # Credit generation state + self._credit_policy: Optional[CreditGenerationPolicy] = None + self._credit_records: List[CreditRecord] = [] # Local store of credit records + self._credit_records_count = 0 + # Metrics self._events_received = 0 self._events_sent = 0 @@ -116,6 +134,7 @@ def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: def set_agent_id(self, agent_id: str) -> None: """Set and hash the agent ID for trace anonymization.""" + self._agent_id_raw = agent_id self._agent_id_hash = hashlib.sha256(agent_id.encode()).hexdigest()[:16] logger.info(f"CIRISNodeService agent_id_hash set: {self._agent_id_hash}") @@ -289,6 +308,7 @@ def get_metrics(self) -> Dict[str, Any]: "pending_deferrals": len(self._pending_deferrals), "queue_size": len(self._event_queue), "trace_level": self._trace_level.value, + "credit_records_generated": self._credit_records_count, } # ========================================================================= @@ -345,6 +365,13 @@ async def _poll_resolutions(self) -> None: if status == "resolved": decision = task.get("decision", "unknown") comment = task.get("comment", "") + resolving_agent_id = task.get("resolving_agent_id") + resolving_trace_id = task.get("resolving_trace_id") + node_attestation = task.get("node_attestation") + node_attestation_key_id = task.get("node_attestation_key_id") + coherence_score = float(task.get("coherence_score", 0.5)) + domain_hint = task.get("domain_hint") + logger.info( f"WBD task {wbd_task_id} resolved: decision={decision}" f"{f' comment={comment[:80]}' if comment else ''}" @@ -360,7 +387,22 @@ async def _poll_resolutions(self) -> None: comment=comment, ) - # Event 2: Reactivate the deferred task via WiseAuthorityService + # Event 2: Generate credit record for bilateral interaction + if resolving_agent_id: + await self._generate_credit_record( + thought_id=thought_id, + agent_task_id=agent_task_id, + wbd_task_id=wbd_task_id, + resolving_agent_id=resolving_agent_id, + resolving_trace_id=resolving_trace_id or f"wbd-{wbd_task_id}", + node_attestation=node_attestation, + node_attestation_key_id=node_attestation_key_id, + coherence_score=coherence_score, + domain_hint=domain_hint, + decision=decision, + ) + + # Event 3: Reactivate the deferred task via WiseAuthorityService await self._reactivate_deferred_task( agent_task_id=agent_task_id, thought_id=thought_id, @@ -479,6 +521,166 @@ async def _reactivate_deferred_task( except Exception as e: logger.error(f"Failed to reactivate deferred task: {e}") + # ========================================================================= + # Credit Record Generation + # ========================================================================= + + async def _generate_credit_record( + self, + thought_id: str, + agent_task_id: str, + wbd_task_id: str, + resolving_agent_id: str, + resolving_trace_id: str, + node_attestation: Optional[str], + node_attestation_key_id: Optional[str], + coherence_score: float, + domain_hint: Optional[str], + decision: str, + ) -> None: + """Generate a signed credit record for a bilateral verified interaction. + + Credit records are NOT tokens — they're self-authenticating signed + attestations stored locally and replicated to CIRISLens. + """ + timestamp = datetime.now(timezone.utc) + requesting_trace_id = f"trace-{thought_id}-{timestamp.strftime('%Y%m%d%H%M%S')}" + + # Compute deterministic interaction ID from both trace IDs + interaction_id = CreditRecord.compute_interaction_id( + requesting_trace_id, resolving_trace_id + ) + + # Determine outcome from decision + if decision in ("approve", "approved", "resolved"): + outcome = InteractionOutcome.RESOLVED + elif decision in ("partial", "partially_resolved"): + outcome = InteractionOutcome.PARTIAL + else: + outcome = InteractionOutcome.UNRESOLVED + + # Only generate records for resolved/partial interactions + if outcome == InteractionOutcome.UNRESOLVED: + logger.debug(f"Skipping credit record for unresolved interaction {wbd_task_id}") + return + + # Parse domain category if present + domain_category = None + if domain_hint: + try: + from ciris_engine.schemas.services.agent_credits import DomainCategory + domain_category = DomainCategory(domain_hint) + except ValueError: + pass + + # Sign the record with agent's Ed25519 key + requesting_signature = self._sign_credit_record(interaction_id, timestamp) + if not requesting_signature: + logger.warning("Could not sign credit record — no signing key available") + return + + record = CreditRecord( + interaction_id=interaction_id, + requesting_agent_id=self._agent_id_hash or "unknown", + resolving_agent_id=resolving_agent_id, + requesting_trace_id=requesting_trace_id, + resolving_trace_id=resolving_trace_id, + outcome=outcome, + domain_category=domain_category, + coherence_score=coherence_score, + requesting_agent_signature=requesting_signature, + resolving_agent_signature=None, # Resolving agent signs on their side + node_attestation=node_attestation, + node_attestation_key_id=node_attestation_key_id, + created_at=timestamp, + resolved_at=timestamp, + ) + + # Store locally + self._credit_records.append(record) + self._credit_records_count += 1 + + # Forward as ACCORD credit trace event + await self._send_credit_trace(record) + + # Submit to CIRISNode for storage/replication + if self._client: + try: + await self._client.post_credit_record(record.model_dump(mode="json")) + logger.info( + f"Credit record submitted: interaction={interaction_id} " + f"outcome={outcome.value} coherence={coherence_score:.2f} " + f"domain={domain_hint or 'general'}" + ) + except Exception as e: + logger.warning(f"Failed to submit credit record to CIRISNode: {e}") + + def _sign_credit_record( + self, interaction_id: str, timestamp: datetime + ) -> Optional[DualSignature]: + """Sign a credit record with the agent's Ed25519 key.""" + try: + from ciris_engine.logic.audit.signing_protocol import get_unified_signing_key + + unified_key = get_unified_signing_key() + # Sign the canonical interaction data + canonical = json.dumps( + {"interaction_id": interaction_id, "timestamp": timestamp.isoformat()}, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + signature = unified_key.sign_base64(canonical) + + return DualSignature( + ed25519_signature=signature, + ed25519_key_id=unified_key.key_id, + ml_dsa_65_signature=None, # PQ signature when available + ml_dsa_65_key_id=None, + ) + except Exception as e: + logger.warning(f"Could not sign credit record: {e}") + return None + + async def _send_credit_trace(self, record: CreditRecord) -> None: + """Send a signed ACCORD trace for a credit generation event.""" + timestamp = datetime.now(timezone.utc).isoformat() + + credit_trace = CompleteTrace( + trace_id=f"credit-{record.interaction_id}-{timestamp}", + thought_id=f"credit_{record.interaction_id}", + task_id=record.requesting_trace_id, + agent_id_hash=self._agent_id_hash or "unknown", + started_at=timestamp, + completed_at=timestamp, + ) + + credit_trace.components.append( + TraceComponent( + component_type="credit_generated", + event_type="CREDIT_GENERATED", + timestamp=timestamp, + data={ + "interaction_id": record.interaction_id, + "outcome": record.outcome.value, + "coherence_score": record.coherence_score, + "domain_category": record.domain_category.value if record.domain_category else None, + "resolving_agent_id": record.resolving_agent_id, + "trace_level": self._trace_level.value, + }, + ) + ) + + if not self._signer.sign_trace(credit_trace): + credit_trace.signature = "" + credit_trace.signature_key_id = "" + + event_payload = { + "event_type": "credit_generated", + "trace": credit_trace.to_dict(), + } + await self._queue_event(event_payload) + logger.debug(f"Credit trace queued for interaction {record.interaction_id}") + # ========================================================================= # Trace Capture (from reasoning_event_stream) # ========================================================================= diff --git a/ciris_engine/logic/buses/wise_bus.py b/ciris_engine/logic/buses/wise_bus.py index 1a1cd6863..58530c737 100644 --- a/ciris_engine/logic/buses/wise_bus.py +++ b/ciris_engine/logic/buses/wise_bus.py @@ -25,6 +25,8 @@ get_prohibition_severity, ) +from ciris_engine.schemas.services.agent_credits import DomainCategory, DomainDeferralRequired + if TYPE_CHECKING: from ciris_engine.logic.registries.base import ServiceRegistry @@ -405,25 +407,36 @@ async def handle_accord_invocation(self, event: Dict[str, Any]) -> bool: logger.error(f"SECURITY ALERT: Accord invocation handler error: {e}", exc_info=True) return False - def _validate_capability(self, capability: Optional[str], agent_tier: int = 1) -> None: + def _validate_capability( + self, capability: Optional[str], agent_tier: int = 1 + ) -> Optional[DomainDeferralRequired]: """ Validate capability against prohibited domains with tier-based access. + For REQUIRES_SEPARATE_MODULE capabilities (MEDICAL, FINANCIAL, etc.), + returns a DomainDeferralRequired signal instead of raising. The caller + auto-constructs a DeferralContext with domain_hint and routes through + CIRISNode to a qualified licensed agent. + Args: capability: The capability to validate agent_tier: Agent tier level (1-5, with 4-5 having stewardship) + Returns: + DomainDeferralRequired if the capability needs domain-specific routing, + None if the capability is allowed. + Raises: - ValueError: If capability is prohibited for the agent's tier + ValueError: If capability is NEVER_ALLOWED or TIER_RESTRICTED """ if not capability: - return + return None # Get the category of this capability category = get_capability_category(capability) if not category: # Not a prohibited capability - return + return None # Check if it's a community moderation capability if category.startswith("COMMUNITY_"): @@ -435,17 +448,29 @@ def _validate_capability(self, capability: Optional[str], agent_tier: int = 1) - f"This capability is reserved for agents with stewardship responsibilities." ) # Tier 4-5 can use community moderation - return + return None # Get the severity of this prohibition severity = get_prohibition_severity(category) if severity == ProhibitionSeverity.REQUIRES_SEPARATE_MODULE: - # These require separate licensed systems - raise ValueError( - f"PROHIBITED: {category} capabilities blocked. " - f"Capability '{capability}' requires separate licensed system. " - f"Implementation must be in isolated repository with proper liability controls." + # Route to licensed handler via CIRISNode instead of raising + try: + domain = DomainCategory(category) + except ValueError: + domain = DomainCategory.MEDICAL # Safe fallback + + logger.info( + f"Domain deferral required: {category} capability '{capability}' " + f"will be routed to licensed handler via CIRISNode" + ) + return DomainDeferralRequired( + category=domain, + capability=capability, + reason=( + f"{category} capability '{capability}' requires licensed domain handler. " + f"Routing to qualified agent via CIRISNode." + ), ) elif severity == ProhibitionSeverity.NEVER_ALLOWED: # These are absolutely prohibited @@ -455,6 +480,7 @@ def _validate_capability(self, capability: Optional[str], agent_tier: int = 1) - f"This capability cannot be implemented in any CIRIS system." ) # TIER_RESTRICTED already handled above for community moderation + return None async def _get_matching_services(self, request: GuidanceRequest) -> List[Any]: """Get services matching the request capability.""" @@ -555,7 +581,30 @@ async def request_guidance( # CRITICAL: Validate capability against comprehensive prohibitions if hasattr(request, "capability"): - self._validate_capability(request.capability, agent_tier) + deferral_signal = self._validate_capability(request.capability, agent_tier) + if deferral_signal is not None: + # Route to licensed domain handler via CIRISNode auto-deferral + deferral_context = DeferralContext( + thought_id=f"domain_deferral_{id(request)}", + task_id=f"domain_task_{id(request)}", + reason=deferral_signal.reason, + domain_hint=deferral_signal.category.value, + metadata={ + "domain_category": deferral_signal.category.value, + "capability": deferral_signal.capability, + "domain_hint": deferral_signal.category.value, + }, + ) + success = await self.send_deferral(deferral_context, "domain_auto_deferral") + return GuidanceResponse( + reasoning=deferral_signal.reason, + wa_id="wisebus_domain_deferral", + signature="domain_auto_deferral", + custom_guidance=( + f"This request requires a licensed {deferral_signal.category.value} handler. " + f"{'Deferral sent to CIRISNode for routing.' if success else 'No WA service available for routing.'}" + ), + ) # Get matching services services = await self._get_matching_services(request) diff --git a/ciris_engine/schemas/services/agent_credits.py b/ciris_engine/schemas/services/agent_credits.py new file mode 100644 index 000000000..6b11eb740 --- /dev/null +++ b/ciris_engine/schemas/services/agent_credits.py @@ -0,0 +1,328 @@ +""" +Commons Credits: Off-chain signed attestation records of bilateral verified interactions. + +Credits are NOT tokens, NOT currency, NOT on-chain. They are recognition — +"giving someone credit" not "credit card." They exist as dual-signed +(Ed25519 + ML-DSA-65) attestations stored via persistent HW-rooted agent +identities (CIRISVerify). + +Credits function as governance weight: deferral routing priority, domain +certification, WA consensus voting weight, anti-sybil policy votes, dispute +resolution, luxury goods distribution, and discovery preference. + +USDC (wallet adapter) is completely separate — for paying for services only. + +See FSD/COMMONS_CREDITS.md for the full specification. +See CCA paper (Zenodo 18217688) for the k_eff quality measurement. +""" + +from __future__ import annotations + +import hashlib +from datetime import datetime +from enum import Enum +from typing import Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class InteractionOutcome(str, Enum): + """Outcome of a bilateral agent interaction.""" + + RESOLVED = "resolved" + PARTIAL = "partial" + UNRESOLVED = "unresolved" + REJECTED = "rejected" + + +class DomainCategory(str, Enum): + """Licensed domain categories that trigger auto-deferral via WiseBus. + + Maps to REQUIRES_SEPARATE_MODULE prohibitions in prohibitions.py. + """ + + MEDICAL = "MEDICAL" + FINANCIAL = "FINANCIAL" + LEGAL = "LEGAL" + HOME_SECURITY = "HOME_SECURITY" + IDENTITY_VERIFICATION = "IDENTITY_VERIFICATION" + CONTENT_MODERATION = "CONTENT_MODERATION" + RESEARCH = "RESEARCH" + INFRASTRUCTURE_CONTROL = "INFRASTRUCTURE_CONTROL" + + +class DualSignature(BaseModel): + """Ed25519 + ML-DSA-65 dual signature for quantum safety. + + Both signatures must verify for a record to be valid. + Today Ed25519 provides security; ML-DSA-65 future-proofs. + """ + + model_config = ConfigDict(extra="forbid", defer_build=True) + + ed25519_signature: str = Field(..., description="Base64url-encoded Ed25519 signature") + ed25519_key_id: str = Field(..., description="Signing key ID (agent-{hash[:12]})") + ml_dsa_65_signature: Optional[str] = Field( + None, + description="Base64url-encoded ML-DSA-65 signature (when PQ available)", + ) + ml_dsa_65_key_id: Optional[str] = Field( + None, + description="ML-DSA-65 key ID (when PQ available)", + ) + + +class GratitudeSignal(BaseModel): + """The 'S' in CIRIS (Signalling Gratitude) made concrete. + + An explicit quality signal from one agent to another, dual-signed. + Closes the bilateral verification loop as a cryptographic event. + """ + + model_config = ConfigDict(extra="forbid", defer_build=True) + + from_agent_id: str = Field(..., description="Ed25519 pubkey hash of signaling agent") + to_agent_id: str = Field(..., description="Ed25519 pubkey hash of receiving agent") + interaction_id: str = Field(..., description="ID of the interaction being acknowledged") + quality_score: float = Field( + ..., + ge=0.0, + le=1.0, + description="Quality rating 0.0-1.0 of the interaction", + ) + message: Optional[str] = Field( + None, + max_length=280, + description="Optional gratitude message", + ) + signature: DualSignature = Field(..., description="Dual signature of the signaling agent") + timestamp: datetime = Field(..., description="When the signal was created") + + +class CreditRecord(BaseModel): + """A signed attestation of a verified bilateral interaction. + + NOT a token. NOT currency. A self-authenticating record of mutual benefit + verified against the coherence ratchet. Dual-signed by both parties plus + node attestation. Verifiable offline. + + The interaction_id is deterministic from both trace IDs, preventing + duplicate records for the same interaction. + """ + + model_config = ConfigDict(extra="forbid", defer_build=True) + + # Identity + interaction_id: str = Field( + ..., + description="Deterministic ID from both trace IDs: sha256(sorted(trace_a, trace_b))[:16]", + ) + requesting_agent_id: str = Field(..., description="Ed25519 pubkey hash of requesting agent") + resolving_agent_id: str = Field(..., description="Ed25519 pubkey hash of resolving agent") + + # Traces + requesting_trace_id: str = Field(..., description="ACCORD trace ID from requesting agent") + resolving_trace_id: str = Field(..., description="ACCORD trace ID from resolving agent") + + # Outcome + outcome: InteractionOutcome = Field(..., description="How the interaction was resolved") + domain_category: Optional[DomainCategory] = Field( + None, + description="Licensed domain if applicable (MEDICAL, FINANCIAL, etc.)", + ) + coherence_score: float = Field( + ..., + ge=0.0, + le=1.0, + description="Coherence ratchet score from CIRISLens evaluation", + ) + + # Gratitude + gratitude_signal: Optional[GratitudeSignal] = Field( + None, + description="Optional explicit gratitude from requesting agent", + ) + + # Dual signatures — both parties + node attestation + requesting_agent_signature: DualSignature = Field( + ..., + description="Dual signature of the requesting agent over the record", + ) + resolving_agent_signature: Optional[DualSignature] = Field( + None, + description="Dual signature of the resolving agent (populated when both sides confirmed)", + ) + node_attestation: Optional[str] = Field( + None, + description="CIRISNode (or Veilid WA consensus) signature attesting the interaction", + ) + node_attestation_key_id: Optional[str] = Field( + None, + description="Key ID of the attesting node", + ) + + # Timestamps + created_at: datetime = Field(..., description="When the record was created") + resolved_at: Optional[datetime] = Field(None, description="When the interaction was resolved") + + @staticmethod + def compute_interaction_id(trace_id_a: str, trace_id_b: str) -> str: + """Compute deterministic interaction ID from two trace IDs. + + Sorting ensures the same pair always produces the same ID + regardless of which agent computes it. + """ + sorted_ids = sorted([trace_id_a, trace_id_b]) + combined = f"{sorted_ids[0]}:{sorted_ids[1]}" + return hashlib.sha256(combined.encode()).hexdigest()[:16] + + +class AgentCreditSummary(BaseModel): + """Computed reputation summary from accumulated credit records. + + NOT a 'balance' — it's a governance weight summary derived from + verified interactions. Determines voting power in WA consensus, + routing priority, and domain access. + + k_eff (effective diversity) is the core quality measurement from + CCA paper: k_eff = k / (1 + rho*(k-1)). High k_eff means diverse, + independent interaction partners. Low k_eff (approaching 1) means + correlated or repetitive interactions. + """ + + model_config = ConfigDict(extra="forbid", defer_build=True) + + agent_id: str = Field(..., description="Ed25519 pubkey hash") + total_interactions: int = Field(0, ge=0, description="Total verified bilateral interactions") + resolved_interactions: int = Field(0, ge=0, description="Successfully resolved interactions") + average_coherence: float = Field( + 0.0, + ge=0.0, + le=1.0, + description="Mean coherence score across all interactions", + ) + k_eff: float = Field( + 1.0, + ge=0.0, + description="Effective diversity: k/(1+rho*(k-1)). Higher = more diverse partners.", + ) + unique_partners: int = Field(0, ge=0, description="Number of distinct interaction partners") + domain_expertise: Dict[str, int] = Field( + default_factory=dict, + description="Domain category -> count of resolved interactions", + ) + governance_weight: float = Field( + 0.0, + ge=0.0, + description="Computed governance weight (interactions * k_eff * avg_coherence)", + ) + last_interaction_at: Optional[datetime] = Field( + None, + description="When the most recent interaction occurred", + ) + computed_at: datetime = Field(..., description="When this summary was computed") + + +class CreditGenerationPolicy(BaseModel): + """Anti-gaming rules for credit record generation. + + Distributed via CIRISNode, signed by CIRIS L3C root key. + Enforced locally by each agent. Code-level constants — cannot + be modified by memory, learning, or runtime adaptation. + + These are the parameters that credit-weighted governance could + vote to adjust (via signed policy updates from L3C). + """ + + model_config = ConfigDict(extra="forbid", defer_build=True) + + policy_version: str = Field(..., description="Policy version date string (YYYY-MM-DD)") + + # Rate limiting + cooldown_seconds: int = Field( + 60, + ge=0, + description="Minimum seconds between credit records for the same agent pair", + ) + max_daily_interactions_per_pair: int = Field( + 10, + ge=1, + description="Maximum credit records per unique agent pair per day", + ) + + # Quality thresholds + coherence_threshold: float = Field( + 0.3, + ge=0.0, + le=1.0, + description="Minimum coherence score for a record to be accepted", + ) + + # Circular detection + circular_deferral_window_seconds: int = Field( + 300, + ge=0, + description="Window (seconds) to detect A->B->A circular deferrals", + ) + + # Identity requirements + min_attestation_level: int = Field( + 2, + ge=0, + le=5, + description="Minimum CIRISVerify attestation level to generate records", + ) + + # Policy authentication + policy_signature: Optional[str] = Field( + None, + description="Ed25519 signature of the policy by CIRIS L3C root key", + ) + policy_key_id: Optional[str] = Field( + None, + description="Key ID of the L3C root key that signed the policy", + ) + + +class DomainDeferralRequired(BaseModel): + """Signal from WiseBus that a capability requires domain-specific deferral. + + Replaces the ValueError that was raised for REQUIRES_SEPARATE_MODULE. + The caller auto-constructs a DeferralContext with domain_hint. + """ + + model_config = ConfigDict(extra="forbid", defer_build=True) + + category: DomainCategory = Field( + ..., + description="The licensed domain category (MEDICAL, FINANCIAL, etc.)", + ) + capability: str = Field(..., description="The specific capability that triggered deferral") + reason: str = Field( + ..., + description="Human-readable reason for deferral", + ) + + +class CreditRecordBatch(BaseModel): + """Batch of credit records for CIRISNode submission or DHT replication.""" + + model_config = ConfigDict(extra="forbid", defer_build=True) + + records: List[CreditRecord] = Field(..., description="Credit records to submit") + agent_id: str = Field(..., description="Submitting agent's Ed25519 pubkey hash") + batch_signature: DualSignature = Field(..., description="Dual signature over the batch") + submitted_at: datetime = Field(..., description="When the batch was submitted") + + +__all__ = [ + "InteractionOutcome", + "DomainCategory", + "DualSignature", + "GratitudeSignal", + "CreditRecord", + "AgentCreditSummary", + "CreditGenerationPolicy", + "DomainDeferralRequired", + "CreditRecordBatch", +] diff --git a/ciris_engine/schemas/services/context.py b/ciris_engine/schemas/services/context.py index cbc453b50..1fe006c6e 100644 --- a/ciris_engine/schemas/services/context.py +++ b/ciris_engine/schemas/services/context.py @@ -30,6 +30,10 @@ class DeferralContext(BaseModel): reason: str = Field(..., description="Reason for deferral") defer_until: Optional[datetime] = Field(None, description="When to reconsider") priority: Optional[str] = Field(None, description="Priority level for later consideration") + domain_hint: Optional[str] = Field( + None, + description="Licensed domain category for routing (MEDICAL, FINANCIAL, etc.)", + ) metadata: Dict[str, str] = Field(default_factory=dict, description="Additional deferral metadata") model_config = ConfigDict(extra="forbid", defer_build=True) diff --git a/tests/test_agent_credits.py b/tests/test_agent_credits.py new file mode 100644 index 000000000..7bd7f9aa3 --- /dev/null +++ b/tests/test_agent_credits.py @@ -0,0 +1,396 @@ +""" +Tests for Commons Credits: agent credit schemas, WiseBus domain auto-deferral, +CIRISNode credit generation, and anti-gaming policy. +""" + +import hashlib +from datetime import datetime, timezone + +import pytest + +from ciris_engine.schemas.services.agent_credits import ( + AgentCreditSummary, + CreditGenerationPolicy, + CreditRecord, + CreditRecordBatch, + DomainCategory, + DomainDeferralRequired, + DualSignature, + GratitudeSignal, + InteractionOutcome, +) +from ciris_engine.schemas.services.context import DeferralContext + + +# ========================================================================= +# Schema Tests +# ========================================================================= + + +class TestInteractionOutcome: + def test_enum_values(self) -> None: + assert InteractionOutcome.RESOLVED == "resolved" + assert InteractionOutcome.PARTIAL == "partial" + assert InteractionOutcome.UNRESOLVED == "unresolved" + assert InteractionOutcome.REJECTED == "rejected" + + +class TestDomainCategory: + def test_all_requires_separate_module_domains(self) -> None: + """Verify DomainCategory covers all REQUIRES_SEPARATE_MODULE domains.""" + expected = { + "MEDICAL", "FINANCIAL", "LEGAL", "HOME_SECURITY", + "IDENTITY_VERIFICATION", "CONTENT_MODERATION", + "RESEARCH", "INFRASTRUCTURE_CONTROL", + } + actual = {d.value for d in DomainCategory} + assert actual == expected + + +class TestDualSignature: + def test_create_ed25519_only(self) -> None: + sig = DualSignature( + ed25519_signature="abc123", + ed25519_key_id="agent-abc123def456", + ) + assert sig.ed25519_signature == "abc123" + assert sig.ml_dsa_65_signature is None + + def test_create_full_dual(self) -> None: + sig = DualSignature( + ed25519_signature="abc123", + ed25519_key_id="agent-abc123def456", + ml_dsa_65_signature="pq_sig_here", + ml_dsa_65_key_id="agent-pq-key", + ) + assert sig.ml_dsa_65_signature == "pq_sig_here" + + +class TestCreditRecord: + def _make_signature(self) -> DualSignature: + return DualSignature( + ed25519_signature="test_sig", + ed25519_key_id="agent-testkey123", + ) + + def test_compute_interaction_id_deterministic(self) -> None: + """Interaction ID must be the same regardless of which agent computes it.""" + id1 = CreditRecord.compute_interaction_id("trace-A", "trace-B") + id2 = CreditRecord.compute_interaction_id("trace-B", "trace-A") + assert id1 == id2 + + def test_compute_interaction_id_different_traces(self) -> None: + id1 = CreditRecord.compute_interaction_id("trace-A", "trace-B") + id2 = CreditRecord.compute_interaction_id("trace-A", "trace-C") + assert id1 != id2 + + def test_compute_interaction_id_format(self) -> None: + """Interaction ID should be 16 hex characters.""" + iid = CreditRecord.compute_interaction_id("trace-1", "trace-2") + assert len(iid) == 16 + # Verify it's valid hex + int(iid, 16) + + def test_create_full_record(self) -> None: + now = datetime.now(timezone.utc) + record = CreditRecord( + interaction_id="abc123def4567890", + requesting_agent_id="agent_a_hash", + resolving_agent_id="agent_b_hash", + requesting_trace_id="trace-a-123", + resolving_trace_id="trace-b-456", + outcome=InteractionOutcome.RESOLVED, + domain_category=DomainCategory.MEDICAL, + coherence_score=0.85, + requesting_agent_signature=self._make_signature(), + node_attestation="node_sig_here", + node_attestation_key_id="node-key-123", + created_at=now, + resolved_at=now, + ) + assert record.outcome == InteractionOutcome.RESOLVED + assert record.domain_category == DomainCategory.MEDICAL + assert record.coherence_score == 0.85 + assert record.resolving_agent_signature is None + + def test_coherence_score_validation(self) -> None: + """Coherence score must be between 0 and 1.""" + with pytest.raises(Exception): + CreditRecord( + interaction_id="test", + requesting_agent_id="a", + resolving_agent_id="b", + requesting_trace_id="t1", + resolving_trace_id="t2", + outcome=InteractionOutcome.RESOLVED, + coherence_score=1.5, # Invalid + requesting_agent_signature=self._make_signature(), + created_at=datetime.now(timezone.utc), + ) + + +class TestGratitudeSignal: + def test_create_signal(self) -> None: + sig = DualSignature( + ed25519_signature="test", + ed25519_key_id="agent-test", + ) + signal = GratitudeSignal( + from_agent_id="agent_a", + to_agent_id="agent_b", + interaction_id="interaction_123", + quality_score=0.9, + message="Thank you for the help!", + signature=sig, + timestamp=datetime.now(timezone.utc), + ) + assert signal.quality_score == 0.9 + assert signal.message == "Thank you for the help!" + + def test_quality_score_bounds(self) -> None: + """Quality score must be 0-1.""" + with pytest.raises(Exception): + GratitudeSignal( + from_agent_id="a", + to_agent_id="b", + interaction_id="x", + quality_score=-0.1, # Invalid + signature=DualSignature(ed25519_signature="s", ed25519_key_id="k"), + timestamp=datetime.now(timezone.utc), + ) + + +class TestAgentCreditSummary: + def test_default_values(self) -> None: + summary = AgentCreditSummary( + agent_id="test_agent", + computed_at=datetime.now(timezone.utc), + ) + assert summary.total_interactions == 0 + assert summary.k_eff == 1.0 + assert summary.governance_weight == 0.0 + assert summary.domain_expertise == {} + + def test_with_interactions(self) -> None: + summary = AgentCreditSummary( + agent_id="test_agent", + total_interactions=50, + resolved_interactions=45, + average_coherence=0.8, + k_eff=3.5, + unique_partners=12, + domain_expertise={"MEDICAL": 10, "LEGAL": 5}, + governance_weight=126.0, + computed_at=datetime.now(timezone.utc), + ) + assert summary.unique_partners == 12 + assert summary.domain_expertise["MEDICAL"] == 10 + + +class TestCreditGenerationPolicy: + def test_default_policy(self) -> None: + policy = CreditGenerationPolicy(policy_version="2026-04-08") + assert policy.cooldown_seconds == 60 + assert policy.max_daily_interactions_per_pair == 10 + assert policy.coherence_threshold == 0.3 + assert policy.circular_deferral_window_seconds == 300 + assert policy.min_attestation_level == 2 + + def test_custom_policy(self) -> None: + policy = CreditGenerationPolicy( + policy_version="2026-04-08", + cooldown_seconds=120, + max_daily_interactions_per_pair=5, + coherence_threshold=0.5, + policy_signature="signed_by_l3c", + policy_key_id="ciris-l3c-root", + ) + assert policy.cooldown_seconds == 120 + assert policy.policy_signature == "signed_by_l3c" + + def test_coherence_threshold_validation(self) -> None: + """Coherence threshold must be 0-1.""" + with pytest.raises(Exception): + CreditGenerationPolicy( + policy_version="2026-04-08", + coherence_threshold=1.5, + ) + + +class TestDomainDeferralRequired: + def test_create_signal(self) -> None: + signal = DomainDeferralRequired( + category=DomainCategory.MEDICAL, + capability="diagnosis", + reason="MEDICAL capability 'diagnosis' requires licensed domain handler.", + ) + assert signal.category == DomainCategory.MEDICAL + assert signal.capability == "diagnosis" + + +# ========================================================================= +# DeferralContext domain_hint Tests +# ========================================================================= + + +class TestDeferralContextDomainHint: + def test_domain_hint_field(self) -> None: + ctx = DeferralContext( + thought_id="t1", + task_id="task1", + reason="Domain deferral", + domain_hint="MEDICAL", + ) + assert ctx.domain_hint == "MEDICAL" + + def test_domain_hint_optional(self) -> None: + ctx = DeferralContext( + thought_id="t1", + task_id="task1", + reason="Normal deferral", + ) + assert ctx.domain_hint is None + + def test_domain_hint_in_metadata(self) -> None: + """domain_hint should be a first-class field, not buried in metadata.""" + ctx = DeferralContext( + thought_id="t1", + task_id="task1", + reason="test", + domain_hint="FINANCIAL", + metadata={"extra": "data"}, + ) + assert ctx.domain_hint == "FINANCIAL" + assert "extra" in ctx.metadata + + +# ========================================================================= +# WiseBus Auto-Deferral Tests +# ========================================================================= + + +class TestWiseBusValidateCapability: + """Test that _validate_capability returns DomainDeferralRequired + for REQUIRES_SEPARATE_MODULE instead of raising ValueError.""" + + def _make_bus(self): + """Create a WiseBus with minimal mocking.""" + from unittest.mock import MagicMock + + from ciris_engine.logic.buses.wise_bus import WiseBus + + mock_registry = MagicMock() + mock_registry.get_services_by_type.return_value = [] + mock_time = MagicMock() + mock_time.now.return_value = datetime.now(timezone.utc) + + return WiseBus( + service_registry=mock_registry, + time_service=mock_time, + ) + + def test_medical_returns_deferral_signal(self) -> None: + bus = self._make_bus() + result = bus._validate_capability("diagnosis", agent_tier=1) + assert result is not None + assert isinstance(result, DomainDeferralRequired) + assert result.category == DomainCategory.MEDICAL + + def test_financial_returns_deferral_signal(self) -> None: + bus = self._make_bus() + result = bus._validate_capability("investment_advice", agent_tier=1) + assert result is not None + assert result.category == DomainCategory.FINANCIAL + + def test_legal_returns_deferral_signal(self) -> None: + bus = self._make_bus() + result = bus._validate_capability("legal_advice", agent_tier=1) + assert result is not None + assert result.category == DomainCategory.LEGAL + + def test_never_allowed_still_raises(self) -> None: + """NEVER_ALLOWED capabilities must still raise ValueError.""" + bus = self._make_bus() + with pytest.raises(ValueError, match="ABSOLUTELY PROHIBITED"): + bus._validate_capability("weapon_design", agent_tier=1) + + def test_safe_capability_returns_none(self) -> None: + """Non-prohibited capabilities should return None.""" + bus = self._make_bus() + result = bus._validate_capability("general_chat", agent_tier=1) + assert result is None + + def test_none_capability_returns_none(self) -> None: + bus = self._make_bus() + result = bus._validate_capability(None, agent_tier=1) + assert result is None + + def test_community_mod_tier_restricted(self) -> None: + """Community moderation should still raise for low-tier agents.""" + bus = self._make_bus() + with pytest.raises(ValueError, match="TIER RESTRICTED"): + bus._validate_capability("notify_moderators", agent_tier=1) + + def test_community_mod_allowed_tier4(self) -> None: + """Community moderation should be allowed for Tier 4+ agents.""" + bus = self._make_bus() + result = bus._validate_capability("notify_moderators", agent_tier=4) + assert result is None + + +# ========================================================================= +# Anti-Gaming Policy Tests +# ========================================================================= + + +class TestAntiGamingPolicy: + def test_policy_serialization(self) -> None: + """Policy should round-trip through JSON.""" + policy = CreditGenerationPolicy( + policy_version="2026-04-08", + cooldown_seconds=120, + max_daily_interactions_per_pair=5, + ) + data = policy.model_dump(mode="json") + restored = CreditGenerationPolicy(**data) + assert restored.cooldown_seconds == 120 + assert restored.max_daily_interactions_per_pair == 5 + + def test_policy_signature_optional(self) -> None: + """Policy should work without signature (for local defaults).""" + policy = CreditGenerationPolicy(policy_version="2026-04-08") + assert policy.policy_signature is None + assert policy.policy_key_id is None + + +# ========================================================================= +# Credit Record Batch Tests +# ========================================================================= + + +class TestCreditRecordBatch: + def test_create_batch(self) -> None: + now = datetime.now(timezone.utc) + sig = DualSignature(ed25519_signature="batch_sig", ed25519_key_id="agent-key") + record_sig = DualSignature(ed25519_signature="rec_sig", ed25519_key_id="agent-key") + + record = CreditRecord( + interaction_id="test_id_12345678", + requesting_agent_id="agent_a", + resolving_agent_id="agent_b", + requesting_trace_id="t1", + resolving_trace_id="t2", + outcome=InteractionOutcome.RESOLVED, + coherence_score=0.7, + requesting_agent_signature=record_sig, + created_at=now, + ) + + batch = CreditRecordBatch( + records=[record], + agent_id="agent_a", + batch_signature=sig, + submitted_at=now, + ) + assert len(batch.records) == 1 + assert batch.agent_id == "agent_a"