CI deploy d3d1cf1
Browse files- amanpay/agentic_orchestration/c4_evaluation.py +321 -0
- amanpay/agentic_orchestration/consent.py +372 -0
- amanpay/agentic_orchestration/consent_api.py +152 -0
- amanpay/agentic_orchestration/federated_status.py +88 -0
- api/main.py +4 -0
- build_info.json +1 -1
- web/e2e/agentic-ux.spec.ts +161 -0
- web/src/App.tsx +7 -1
- web/src/api/ai.ts +97 -0
- web/src/components/ConsentControls.test.tsx +108 -0
- web/src/components/ConsentControls.tsx +134 -0
- web/src/components/FederatedStatusCard.test.tsx +60 -0
- web/src/components/FederatedStatusCard.tsx +69 -0
- web/src/i18n/ar.ts +45 -0
- web/src/i18n/en.ts +45 -0
- web/src/pages/FederatedDemoPage.tsx +23 -0
- web/src/pages/SecurityPage.test.tsx +11 -0
- web/src/pages/SecurityPage.tsx +19 -0
- web/src/styles.css +14 -0
amanpay/agentic_orchestration/c4_evaluation.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PR C4 — system evaluation + tracked-artifact generation.
|
| 2 |
+
|
| 3 |
+
Deterministic. Aggregates the honest C1/C2/C3 evidence, adds the C4 consent + required-processing
|
| 4 |
+
model, and emits five tracked artifacts. Every artifact states, explicitly: implemented / simulated /
|
| 5 |
+
design-only / not-implemented status; storage durability; identity assumptions; deletion limitations;
|
| 6 |
+
DP-exhausted status; secure-aggregation design-only status; synthetic-data limitation; and that the
|
| 7 |
+
whole agentic layer has no payment authority.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
from typing import Dict, List, Optional
|
| 15 |
+
|
| 16 |
+
from amanpay.agentic_orchestration.consent import (
|
| 17 |
+
ConsentError, ConsentStore, CONSENT_STATUSES, OPTIONAL_PURPOSES, POLICY_VERSION,
|
| 18 |
+
PURPOSE_KIND, PURPOSES, REQUIRED_PURPOSES, storage_consistency)
|
| 19 |
+
from amanpay.agentic_orchestration.federated_status import federated_status, privacy_view
|
| 20 |
+
from amanpay.federated_risk.simulation import DISCLAIMER, STATUS_LABELS
|
| 21 |
+
from amanpay.storage.kv import reset_kv_for_tests
|
| 22 |
+
|
| 23 |
+
_ART_DIR = os.path.join("docs", "agentic-risk", "artifacts")
|
| 24 |
+
_VERSION = "c4-v2"
|
| 25 |
+
_GENERATED_AT = 0
|
| 26 |
+
|
| 27 |
+
DISCLAIMER_C4 = ("Advisory-only agentic layer. Consent governs the SHADOW behavioural model and the "
|
| 28 |
+
"SIMULATED federated demo; required security processing is separate and is not an "
|
| 29 |
+
"optional consent. Nothing here has payment authority or can override the "
|
| 30 |
+
"deterministic policy engine or Payment Core.")
|
| 31 |
+
|
| 32 |
+
SYNTHETIC_NOTE = ("Engineering evaluation on synthetic data; not evidence of real-world "
|
| 33 |
+
"fraud-detection performance.")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _read(name: str) -> Optional[Dict]:
|
| 37 |
+
path = os.path.join(_ART_DIR, name)
|
| 38 |
+
if not os.path.exists(path):
|
| 39 |
+
return None
|
| 40 |
+
try:
|
| 41 |
+
return json.load(open(path, encoding="utf-8"))
|
| 42 |
+
except Exception:
|
| 43 |
+
return None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _classifications() -> Dict:
|
| 47 |
+
"""The honesty block embedded in every artifact."""
|
| 48 |
+
return {
|
| 49 |
+
"federation": STATUS_LABELS["federation"], # SIMULATED
|
| 50 |
+
"secure_aggregation": STATUS_LABELS["secure_aggregation"], # DESIGN_ONLY
|
| 51 |
+
"differential_privacy": STATUS_LABELS["differential_privacy"], # SIMULATED
|
| 52 |
+
"real_device_training": STATUS_LABELS["real_device_training"], # NOT_IMPLEMENTED
|
| 53 |
+
"native_local_only_training": "NOT_IMPLEMENTED",
|
| 54 |
+
"client_type": "SYNTHETIC_SERVER_SIMULATION",
|
| 55 |
+
"differential_privacy_budget_exhausted": bool(privacy_view().get("budget_exhausted", True)),
|
| 56 |
+
"storage_durability": storage_consistency(),
|
| 57 |
+
"identity_assumptions": "Subject is the authenticated principal (Bearer-token subject); "
|
| 58 |
+
"clients cannot select another subject.",
|
| 59 |
+
"deletion_limitations": "Eligible-scope deletion of derived/optional data + a minimal "
|
| 60 |
+
"non-sensitive tombstone; NOT perfect erasure or machine unlearning.",
|
| 61 |
+
"synthetic_data_limitation": SYNTHETIC_NOTE,
|
| 62 |
+
"no_payment_authority": True,
|
| 63 |
+
"automatic_model_promotion": False,
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _envelope(artifact: str, commit: str, **extra) -> Dict:
|
| 68 |
+
base = {"artifact": artifact, "version": _VERSION, "implementation_commit": commit,
|
| 69 |
+
"generated_at": _GENERATED_AT, "affects_payment": False,
|
| 70 |
+
"classifications": _classifications()}
|
| 71 |
+
base.update(extra)
|
| 72 |
+
return base
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ---- artifacts ---- #
|
| 76 |
+
def consent_model_artifact(commit: str) -> Dict:
|
| 77 |
+
return _envelope(
|
| 78 |
+
"c4_consent_model", commit,
|
| 79 |
+
purposes=[{"name": p, "purpose_kind": PURPOSE_KIND[p], "withdrawable": p in OPTIONAL_PURPOSES}
|
| 80 |
+
for p in PURPOSES],
|
| 81 |
+
required_processing=list(REQUIRED_PURPOSES),
|
| 82 |
+
optional_consent=list(OPTIONAL_PURPOSES),
|
| 83 |
+
consent_statuses=list(CONSENT_STATUSES),
|
| 84 |
+
required_state="active",
|
| 85 |
+
integrity={"profile_generation": "increments on deletion; stale generation rejected",
|
| 86 |
+
"record_version": "monotonic; never resets for the subject lifetime",
|
| 87 |
+
"optimistic_concurrency": "expected_version / expected_generation compare-and-set",
|
| 88 |
+
"idempotency": "request_id replay-safe; reuse with a different payload rejected",
|
| 89 |
+
"binding": ["subject", "purpose", "generation", "expected_version",
|
| 90 |
+
"policy_version", "request_id", "timestamp"]},
|
| 91 |
+
rules=[
|
| 92 |
+
"required security processing is NOT optional consent and cannot be withdrawn here",
|
| 93 |
+
"optional purposes default to not_granted (explicit opt-in; no silent enrolment)",
|
| 94 |
+
"personalization and federation are independent — one never implies the other",
|
| 95 |
+
"withdrawal takes effect immediately; pre-withdrawal/previous-generation updates rejected",
|
| 96 |
+
"profile deletion is eligible-scope (not perfect erasure or machine unlearning)",
|
| 97 |
+
"consent has no payment authority and cannot override the deterministic PDP",
|
| 98 |
+
],
|
| 99 |
+
policy_version=POLICY_VERSION, disclaimer=DISCLAIMER_C4)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def privacy_report_artifact(commit: str) -> Dict:
|
| 103 |
+
return _envelope(
|
| 104 |
+
"c4_privacy_report", commit,
|
| 105 |
+
differential_privacy=privacy_view(),
|
| 106 |
+
secure_aggregation=STATUS_LABELS["secure_aggregation"],
|
| 107 |
+
data_categories={
|
| 108 |
+
"device_authentication": "Secure confirmation only; biometric templates stay on the device.",
|
| 109 |
+
"derived_behavioural_features": "Non-identifying features used by the SHADOW model only.",
|
| 110 |
+
"consent_records": "Per-subject purpose states/statuses + versions + bounded audit trail.",
|
| 111 |
+
},
|
| 112 |
+
retention={
|
| 113 |
+
"required_security_processing_records": "Retained (payment security/fraud/integrity/obligations).",
|
| 114 |
+
"derived_behavioural_and_optional_learning": "Deleted on eligible-scope profile deletion.",
|
| 115 |
+
"deletion_tombstone": "Minimal, non-sensitive (retired generation, last version, audit ref).",
|
| 116 |
+
"raw_iban_or_keys": "Never stored in the agentic layer.",
|
| 117 |
+
},
|
| 118 |
+
deletion={"endpoint": "DELETE /ai/v1/profile", "type": "eligible_scope_deletion",
|
| 119 |
+
"not_perfect_erasure": True, "not_machine_unlearning": True,
|
| 120 |
+
"deleted_categories": ["derived_behavioural_features", "optional_personalization_state",
|
| 121 |
+
"pending_optional_federated_updates", "raw_model_input"],
|
| 122 |
+
"retained_categories": ["required_security_processing_records",
|
| 123 |
+
"minimal_deletion_tombstone"],
|
| 124 |
+
"cross_user_isolation": True, "idempotent": True},
|
| 125 |
+
data_leaves_device={"real_federated_updates": False, "real_user_data_used": False,
|
| 126 |
+
"claim_data_stays_on_device": False,
|
| 127 |
+
"note": "Federation is a synthetic server-side simulation; there is no real "
|
| 128 |
+
"device training, so no 'data stays on your device' claim is made."},
|
| 129 |
+
disclaimer=DISCLAIMER)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def system_evaluation_artifact(commit: str) -> Dict:
|
| 133 |
+
c1 = _read("risk_baseline.json") or {}
|
| 134 |
+
fed = _read("federated_simulation.json") or {}
|
| 135 |
+
cand = _read("federated_model_candidate.json") or {}
|
| 136 |
+
priv = privacy_view()
|
| 137 |
+
|
| 138 |
+
logreg = (c1.get("aggregate_stability") or {}).get("logreg", {})
|
| 139 |
+
rules = (c1.get("aggregate_stability") or {}).get("rules", {})
|
| 140 |
+
c1_pr = (logreg.get("pr_auc") or {}).get("mean")
|
| 141 |
+
rules_pr = (rules.get("pr_auc") or {}).get("mean")
|
| 142 |
+
per_seed0 = (fed.get("per_seed") or [{}])[0]
|
| 143 |
+
fed_fedavg = (per_seed0.get("federated") or {}).get("fedavg", {})
|
| 144 |
+
fed_c1 = per_seed0.get("centralized_c1", {})
|
| 145 |
+
|
| 146 |
+
phases = {
|
| 147 |
+
"behavioural_c1": {
|
| 148 |
+
"model": c1.get("primary_model", "logreg"),
|
| 149 |
+
"pr_auc_mean_synthetic": c1_pr, "reference_baseline": c1.get("reference_baseline", "rules"),
|
| 150 |
+
"reference_pr_auc_mean_synthetic": rules_pr,
|
| 151 |
+
"beats_rules_in_synthetic_eval": bool(c1_pr and rules_pr and c1_pr > rules_pr),
|
| 152 |
+
"metric_caveat": SYNTHETIC_NOTE,
|
| 153 |
+
"shadow_only": True, "affects_payment": False,
|
| 154 |
+
},
|
| 155 |
+
"orchestration_c2": {
|
| 156 |
+
"agents": ["context", "risk", "auth_recommender", "rail", "explanation"],
|
| 157 |
+
"risk_role": "advisory (shadow) into the deterministic PDP",
|
| 158 |
+
"invariants": ["agents cannot override a deny", "agents cannot weaken step-up",
|
| 159 |
+
"only Payment Core initiates", "replay blocked"],
|
| 160 |
+
"affects_payment": False,
|
| 161 |
+
},
|
| 162 |
+
"federated_c3": {
|
| 163 |
+
"client_type": "SYNTHETIC_SERVER_SIMULATION",
|
| 164 |
+
"fedavg_pr_auc_synthetic": fed_fedavg.get("pr_auc"),
|
| 165 |
+
"centralized_c1_pr_auc_synthetic": fed_c1.get("pr_auc"),
|
| 166 |
+
"metric_caveat": SYNTHETIC_NOTE,
|
| 167 |
+
"candidate_status": cand.get("status", "candidate"),
|
| 168 |
+
"candidate_promotable": False,
|
| 169 |
+
"differential_privacy": priv["differential_privacy"],
|
| 170 |
+
"dp_budget_exhausted": priv["budget_exhausted"],
|
| 171 |
+
"candidate_privacy_eligibility": priv["candidate_privacy_eligibility"],
|
| 172 |
+
"affects_payment": False,
|
| 173 |
+
},
|
| 174 |
+
"consent_c4": {
|
| 175 |
+
"purpose_kinds": {p: PURPOSE_KIND[p] for p in PURPOSES},
|
| 176 |
+
"required_is_not_consent": True,
|
| 177 |
+
"optional_defaults_opt_in": True, "withdrawal_supported": True,
|
| 178 |
+
"immediate_withdrawal_enforced": True,
|
| 179 |
+
"profile_generation_and_versioning": True,
|
| 180 |
+
"eligible_scope_deletion": True, "principal_bound_identity": True,
|
| 181 |
+
"cross_user_isolation": True, "affects_payment": False,
|
| 182 |
+
},
|
| 183 |
+
}
|
| 184 |
+
invariants = {
|
| 185 |
+
"no_ai_payment_authority": True,
|
| 186 |
+
"pdp_authoritative": True,
|
| 187 |
+
"shadow_model_advisory_only": True,
|
| 188 |
+
"required_processing_not_optional_consent": True,
|
| 189 |
+
"federation_simulated_not_real": STATUS_LABELS["federation"] == "SIMULATED",
|
| 190 |
+
"secure_aggregation_design_only": STATUS_LABELS["secure_aggregation"] == "DESIGN_ONLY",
|
| 191 |
+
"differential_privacy_simulated": STATUS_LABELS["differential_privacy"] == "SIMULATED",
|
| 192 |
+
"dp_budget_exhausted_surfaced": bool(priv["budget_exhausted"]),
|
| 193 |
+
"candidate_not_privacy_eligible": priv["candidate_privacy_eligibility"] == "ineligible",
|
| 194 |
+
"no_auto_promotion": True,
|
| 195 |
+
"consent_opt_in": True,
|
| 196 |
+
"identity_bound_to_principal": True,
|
| 197 |
+
"deletion_is_eligible_scope_only": True,
|
| 198 |
+
}
|
| 199 |
+
return _envelope("c4_system_evaluation", commit, phases=phases, invariants=invariants,
|
| 200 |
+
disclaimer=DISCLAIMER_C4,
|
| 201 |
+
notes=[SYNTHETIC_NOTE,
|
| 202 |
+
"The whole agentic stack (C1-C4) is advisory; the deterministic engine and "
|
| 203 |
+
"Payment Core remain the only authority over money.",
|
| 204 |
+
"An exhausted simulated DP budget is not a production privacy guarantee."])
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def threat_model_artifact(commit: str) -> Dict:
|
| 208 |
+
threats = [
|
| 209 |
+
{"id": "C4-T1", "threat": "Consent bypass — behavioural model personalises without opt-in",
|
| 210 |
+
"mitigation": "Optional purposes default not_granted; has_consent() fails closed.",
|
| 211 |
+
"tests": ["test_default_status_opt_in", "test_has_consent_gates_personalization"]},
|
| 212 |
+
{"id": "C4-T2", "threat": "Required processing misrepresented as optional consent",
|
| 213 |
+
"mitigation": "purpose_kind=required_processing; state=active (not 'granted'); cannot be "
|
| 214 |
+
"modified via /consent.",
|
| 215 |
+
"tests": ["test_required_is_not_optional_consent", "test_cannot_modify_required_processing"]},
|
| 216 |
+
{"id": "C4-T3", "threat": "Cross-user read/modify/delete via spoofed subject",
|
| 217 |
+
"mitigation": "Subject derived from Bearer-token; body/query user_id that differs -> 403.",
|
| 218 |
+
"tests": ["test_principal_binding", "test_body_user_id_substitution_rejected",
|
| 219 |
+
"test_cross_user_delete_isolated"]},
|
| 220 |
+
{"id": "C4-T4", "threat": "Stale/replayed pre-deletion consent or update accepted",
|
| 221 |
+
"mitigation": "profile_generation retired on deletion; stale generation rejected; "
|
| 222 |
+
"assert_update_allowed rejects pre-withdrawal/previous-generation updates.",
|
| 223 |
+
"tests": ["test_previous_generation_update_rejected", "test_pre_withdrawal_update_rejected",
|
| 224 |
+
"test_reenrollment_new_generation"]},
|
| 225 |
+
{"id": "C4-T5", "threat": "Idempotency-key reuse / version rollback / concurrent conflict",
|
| 226 |
+
"mitigation": "request_id replay-safe; reuse with different payload rejected; "
|
| 227 |
+
"expected_version/generation compare-and-set.",
|
| 228 |
+
"tests": ["test_request_id_reuse_conflict", "test_stale_version_conflict",
|
| 229 |
+
"test_concurrent_grant_withdraw_conflict"]},
|
| 230 |
+
{"id": "C4-T6", "threat": "Deletion overclaimed as perfect erasure / deletes payment records",
|
| 231 |
+
"mitigation": "erasure_type=eligible_scope_deletion; retained categories listed; payment "
|
| 232 |
+
"records not in deletable scope.",
|
| 233 |
+
"tests": ["test_deletion_is_eligible_scope", "test_deletion_does_not_touch_payment_records"]},
|
| 234 |
+
{"id": "C4-T7", "threat": "Federated/DP status shown as real/healthy/compliant",
|
| 235 |
+
"mitigation": "SYNTHETIC_SERVER_SIMULATION; DP SIMULATED + budget exhausted; candidate "
|
| 236 |
+
"privacy-ineligible; no 'data stays on device' claim.",
|
| 237 |
+
"tests": ["test_federated_status_fields", "test_dp_exhausted_surfaced",
|
| 238 |
+
"test_no_data_stays_on_device_claim"]},
|
| 239 |
+
{"id": "C4-T8", "threat": "Consent used to influence a payment decision",
|
| 240 |
+
"mitigation": "No Payment-Core / PDP / capability imports; affects_payment always False.",
|
| 241 |
+
"tests": ["test_consent_module_has_no_payment_imports", "test_consent_api_no_payment_authority"]},
|
| 242 |
+
]
|
| 243 |
+
return _envelope("c4_threat_model", commit, threats=threats, disclaimer=DISCLAIMER_C4)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _run_conformance_checks() -> List[Dict]:
|
| 247 |
+
reset_kv_for_tests()
|
| 248 |
+
s = ConsentStore()
|
| 249 |
+
checks: List[Dict] = []
|
| 250 |
+
|
| 251 |
+
def record(cid: str, desc: str, ok: bool):
|
| 252 |
+
checks.append({"id": cid, "check": desc, "result": "PASS" if ok else "FAIL"})
|
| 253 |
+
|
| 254 |
+
st = s.status("u1", now=1.0)
|
| 255 |
+
record("C4-C1", "optional purposes default not_granted (opt-in)",
|
| 256 |
+
st["purposes"]["optional_personalization"]["status"] == "not_granted")
|
| 257 |
+
record("C4-C2", "required processing reported as required_processing/active (not consent)",
|
| 258 |
+
st["purposes"]["service_essential"]["purpose_kind"] == "required_processing"
|
| 259 |
+
and st["purposes"]["service_essential"]["state"] == "active")
|
| 260 |
+
|
| 261 |
+
r = s.set_consent("u1", "optional_personalization", True, request_id="r1", now=2.0)
|
| 262 |
+
record("C4-C3", "grant sets granted + bumps version",
|
| 263 |
+
r["purposes"]["optional_personalization"]["status"] == "granted"
|
| 264 |
+
and r["personalization_enabled"])
|
| 265 |
+
|
| 266 |
+
try:
|
| 267 |
+
s.set_consent("u1", "service_essential", False, request_id="r2", now=3.0)
|
| 268 |
+
req_ok = False
|
| 269 |
+
except ConsentError as e:
|
| 270 |
+
req_ok = str(e) == "cannot_modify_required_processing"
|
| 271 |
+
record("C4-C4", "required processing cannot be modified via consent", req_ok)
|
| 272 |
+
|
| 273 |
+
try:
|
| 274 |
+
s.set_consent("u1", "optional_personalization", True, request_id="r1b",
|
| 275 |
+
expected_version=999, now=4.0)
|
| 276 |
+
cas_ok = False
|
| 277 |
+
except ConsentError as e:
|
| 278 |
+
cas_ok = str(e) == "version_conflict"
|
| 279 |
+
record("C4-C5", "stale expected_version -> version_conflict", cas_ok)
|
| 280 |
+
|
| 281 |
+
gen_before = s.current_generation("u1", now=5.0)
|
| 282 |
+
s.delete_profile("u1", request_id="d1", now=5.0)
|
| 283 |
+
gen_after = s.current_generation("u1", now=6.0)
|
| 284 |
+
record("C4-C6", "deletion retires the generation (monotonic, no reuse)", gen_after > gen_before)
|
| 285 |
+
|
| 286 |
+
try:
|
| 287 |
+
s.assert_update_allowed("u1", "optional_personalization", generation=gen_before, now=6.0)
|
| 288 |
+
stale_ok = False
|
| 289 |
+
except ConsentError:
|
| 290 |
+
stale_ok = True
|
| 291 |
+
record("C4-C7", "previous-generation update rejected after deletion", stale_ok)
|
| 292 |
+
|
| 293 |
+
fs = federated_status()
|
| 294 |
+
record("C4-C8", "federated status SIMULATED/synthetic + DP exhausted + not promotable",
|
| 295 |
+
fs["client_type"] == "SYNTHETIC_SERVER_SIMULATION" and fs["candidate_promotable"] is False
|
| 296 |
+
and fs["differential_privacy_detail"]["budget_exhausted"] is True
|
| 297 |
+
and fs["affects_payment_authorization"] is False)
|
| 298 |
+
|
| 299 |
+
record("C4-C9", "consent status carries no payment authority",
|
| 300 |
+
s.status("u1", now=7.0)["affects_payment"] is False)
|
| 301 |
+
|
| 302 |
+
return checks
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def conformance_artifact(commit: str) -> Dict:
|
| 306 |
+
checks = _run_conformance_checks()
|
| 307 |
+
passed = sum(1 for c in checks if c["result"] == "PASS")
|
| 308 |
+
return _envelope("c4_conformance", commit, checks=checks,
|
| 309 |
+
summary={"total": len(checks), "passed": passed, "failed": len(checks) - passed,
|
| 310 |
+
"conformant": passed == len(checks)},
|
| 311 |
+
disclaimer=DISCLAIMER_C4)
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def generate_c4_artifacts(commit: str) -> Dict[str, Dict]:
|
| 315 |
+
return {
|
| 316 |
+
"c4_consent_model.json": consent_model_artifact(commit),
|
| 317 |
+
"c4_privacy_report.json": privacy_report_artifact(commit),
|
| 318 |
+
"c4_system_evaluation.json": system_evaluation_artifact(commit),
|
| 319 |
+
"c4_threat_model.json": threat_model_artifact(commit),
|
| 320 |
+
"c4_conformance.json": conformance_artifact(commit),
|
| 321 |
+
}
|
amanpay/agentic_orchestration/consent.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PR C4 — consent + required-processing model for the agentic/behavioural layer.
|
| 2 |
+
|
| 3 |
+
Honest separation of THREE purposes with an explicit ``purpose_kind``:
|
| 4 |
+
|
| 5 |
+
* ``service_essential`` — **required_processing**. Processing required for payment security,
|
| 6 |
+
fraud prevention, service integrity and applicable obligations.
|
| 7 |
+
NOT an optional consent toggle; cannot be withdrawn through the
|
| 8 |
+
personalization interface. Subject to documented purpose,
|
| 9 |
+
minimisation, retention and legal review.
|
| 10 |
+
* ``optional_personalization`` — **optional_consent**. Opt-in; starts not_granted; withdrawable
|
| 11 |
+
independently. Lets the SHADOW behavioural model personalise.
|
| 12 |
+
* ``optional_federation`` — **optional_consent**. Opt-in; starts not_granted; withdrawable
|
| 13 |
+
independently; never implied by personalization consent. Nominal
|
| 14 |
+
inclusion in the SIMULATED federated demo (synthetic clients only).
|
| 15 |
+
|
| 16 |
+
Required processing is never shown as "consent granted".
|
| 17 |
+
|
| 18 |
+
Integrity model (replay / ordering / deletion):
|
| 19 |
+
* A monotonic ``record_version`` (never resets for the life of a subject) + a ``profile_generation``
|
| 20 |
+
that increments on deletion. Every mutation binds subject, purpose, generation, expected version,
|
| 21 |
+
policy version, request id and timestamp.
|
| 22 |
+
* Optimistic concurrency: ``expected_version`` / ``expected_generation`` compare-and-set. Idempotency:
|
| 23 |
+
a repeated ``request_id`` returns the same safe result; a reused ``request_id`` with a different
|
| 24 |
+
payload is rejected.
|
| 25 |
+
* Deletion erases eligible derived/optional-learning data and leaves a minimal non-sensitive
|
| 26 |
+
tombstone (retired generation + last version + audit reference) so stale/previous-generation
|
| 27 |
+
updates are rejected and re-enrolment never collides with a deleted generation.
|
| 28 |
+
|
| 29 |
+
Advisory only: consent/required-processing has **no payment authority** and cannot override the
|
| 30 |
+
deterministic PDP or Payment Core. Storage durability is reported honestly (in-process MemoryKV vs
|
| 31 |
+
Redis) — no distributed-atomicity claims.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
import hashlib
|
| 37 |
+
import json
|
| 38 |
+
import time
|
| 39 |
+
from typing import Dict, List, Optional, Tuple
|
| 40 |
+
|
| 41 |
+
from amanpay.storage.kv import get_kv, kv_backend
|
| 42 |
+
|
| 43 |
+
# ---- vocabulary ---- #
|
| 44 |
+
PURPOSES = ("service_essential", "optional_personalization", "optional_federation")
|
| 45 |
+
REQUIRED_PURPOSES = ("service_essential",)
|
| 46 |
+
OPTIONAL_PURPOSES = ("optional_personalization", "optional_federation")
|
| 47 |
+
PURPOSE_KIND = {
|
| 48 |
+
"service_essential": "required_processing",
|
| 49 |
+
"optional_personalization": "optional_consent",
|
| 50 |
+
"optional_federation": "optional_consent",
|
| 51 |
+
}
|
| 52 |
+
CONSENT_STATUSES = ("not_granted", "granted", "withdrawn") # optional_consent states
|
| 53 |
+
REQUIRED_STATE = "active" # required_processing state (NOT consent)
|
| 54 |
+
POLICY_VERSION = "c4-policy-v1"
|
| 55 |
+
|
| 56 |
+
_KEY = "consent:{subject}"
|
| 57 |
+
_TOMB = "consent:tomb:{subject}"
|
| 58 |
+
_HISTORY_CAP = 50
|
| 59 |
+
_IDEMPOTENCY_CAP = 50
|
| 60 |
+
_NAMESPACE = "ai.consent.v2"
|
| 61 |
+
|
| 62 |
+
# Categories used in deletion receipts (honest, non-sensitive labels).
|
| 63 |
+
DELETABLE_CATEGORIES = ("derived_behavioural_features", "optional_personalization_state",
|
| 64 |
+
"pending_optional_federated_updates", "raw_model_input")
|
| 65 |
+
RETAINED_CATEGORIES = ("required_security_processing_records", "minimal_deletion_tombstone")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class ConsentError(ValueError):
|
| 69 |
+
"""Normalised, non-sensitive consent failure reason."""
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _now(now: Optional[float]) -> float:
|
| 73 |
+
return float(now) if now is not None else time.time()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _clean_subject(subject: str) -> str:
|
| 77 |
+
if not isinstance(subject, str) or not subject.strip():
|
| 78 |
+
raise ConsentError("invalid_subject")
|
| 79 |
+
return subject.strip()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _payload_hash(purpose: str, grant: bool) -> str:
|
| 83 |
+
return hashlib.sha256(f"{purpose}|{int(grant)}".encode()).hexdigest()[:16]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def storage_consistency() -> Dict:
|
| 87 |
+
"""Honest description of the durability/atomicity actually available — no over-claiming."""
|
| 88 |
+
backend = kv_backend()
|
| 89 |
+
if backend == "redis":
|
| 90 |
+
return {"backend": "redis",
|
| 91 |
+
"atomicity": "single_key_atomic_ops",
|
| 92 |
+
"consent_writes": "optimistic_read_modify_write", # store uses get/set, not distributed CAS
|
| 93 |
+
"durable_multi_replica": False,
|
| 94 |
+
"note": "Redis provides atomic single-key ops; the consent store uses optimistic "
|
| 95 |
+
"read-modify-write, so mutations are not distributed compare-and-set."}
|
| 96 |
+
return {"backend": "memory",
|
| 97 |
+
"atomicity": "in_process_only",
|
| 98 |
+
"consent_writes": "optimistic_in_process",
|
| 99 |
+
"durable_multi_replica": False,
|
| 100 |
+
"note": "MemoryKV is per-process and ephemeral; no cross-replica consistency or durability."}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _default_purposes(now: float) -> Dict:
|
| 104 |
+
return {
|
| 105 |
+
"service_essential": {"kind": "required_processing", "state": REQUIRED_STATE,
|
| 106 |
+
"version": 1, "updated_at": now},
|
| 107 |
+
"optional_personalization": {"kind": "optional_consent", "status": "not_granted",
|
| 108 |
+
"version": 0, "updated_at": now},
|
| 109 |
+
"optional_federation": {"kind": "optional_consent", "status": "not_granted",
|
| 110 |
+
"version": 0, "updated_at": now},
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class ConsentStore:
|
| 115 |
+
"""Per-subject consent + required-processing over the shared KV. Subject MUST be an authenticated
|
| 116 |
+
principal (never a client-supplied user id). No Payment-Core / PDP / capability access."""
|
| 117 |
+
|
| 118 |
+
def __init__(self) -> None:
|
| 119 |
+
self._kv = get_kv()
|
| 120 |
+
|
| 121 |
+
# ---- low-level ---- #
|
| 122 |
+
def _load(self, subject: str, now: float) -> Optional[Dict]:
|
| 123 |
+
rec = self._kv.get(_KEY.format(subject=subject), now=now)
|
| 124 |
+
return rec if isinstance(rec, dict) else None
|
| 125 |
+
|
| 126 |
+
def _load_tomb(self, subject: str, now: float) -> Optional[Dict]:
|
| 127 |
+
t = self._kv.get(_TOMB.format(subject=subject), now=now)
|
| 128 |
+
return t if isinstance(t, dict) else None
|
| 129 |
+
|
| 130 |
+
def _save(self, rec: Dict, now: float) -> None:
|
| 131 |
+
rec["updated_at"] = now
|
| 132 |
+
self._kv.set(_KEY.format(subject=rec["subject"]), rec, now=now)
|
| 133 |
+
|
| 134 |
+
def _new_record(self, subject: str, now: float) -> Dict:
|
| 135 |
+
"""Fresh record. If a deletion tombstone exists, start in the NEXT generation and continue the
|
| 136 |
+
monotonic record_version so versions never collide with the retired generation."""
|
| 137 |
+
tomb = self._load_tomb(subject, now)
|
| 138 |
+
generation = (int(tomb["retired_generation"]) + 1) if tomb else 1
|
| 139 |
+
start_version = (int(tomb["last_record_version"]) + 1) if tomb else 1
|
| 140 |
+
return {
|
| 141 |
+
"namespace": _NAMESPACE, "subject": subject, "policy_version": POLICY_VERSION,
|
| 142 |
+
"profile_generation": generation, "record_version": start_version,
|
| 143 |
+
"created_at": now, "updated_at": now,
|
| 144 |
+
"purposes": _default_purposes(now),
|
| 145 |
+
"history": [{"event": "created", "generation": generation,
|
| 146 |
+
"record_version": start_version, "at": now, "actor": "system"}],
|
| 147 |
+
"idempotency": {},
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
# ---- read ---- #
|
| 151 |
+
def status(self, subject: str, now: Optional[float] = None,
|
| 152 |
+
include_generation: bool = True) -> Dict:
|
| 153 |
+
"""Safe status view. Required processing is reported with purpose_kind=required_processing and
|
| 154 |
+
state=active (never as consent). Optional purposes carry status + version."""
|
| 155 |
+
subject = _clean_subject(subject)
|
| 156 |
+
now = _now(now)
|
| 157 |
+
rec = self._load(subject, now) or self._new_record(subject, now)
|
| 158 |
+
purposes = {}
|
| 159 |
+
for p in PURPOSES:
|
| 160 |
+
meta = rec["purposes"][p]
|
| 161 |
+
if PURPOSE_KIND[p] == "required_processing":
|
| 162 |
+
purposes[p] = {"purpose_kind": "required_processing", "state": meta.get("state", REQUIRED_STATE),
|
| 163 |
+
"withdrawable": False, "version": meta["version"], "updated_at": meta["updated_at"]}
|
| 164 |
+
else:
|
| 165 |
+
purposes[p] = {"purpose_kind": "optional_consent", "status": meta["status"],
|
| 166 |
+
"withdrawable": True, "version": meta["version"], "updated_at": meta["updated_at"]}
|
| 167 |
+
out = {
|
| 168 |
+
"subject": subject,
|
| 169 |
+
"policy_version": rec.get("policy_version", POLICY_VERSION),
|
| 170 |
+
"record_version": rec["record_version"],
|
| 171 |
+
"purposes": purposes,
|
| 172 |
+
"personalization_enabled": rec["purposes"]["optional_personalization"]["status"] == "granted",
|
| 173 |
+
"federation_enrolled": rec["purposes"]["optional_federation"]["status"] == "granted",
|
| 174 |
+
# honest "personalization without shared learning" derived flag
|
| 175 |
+
"personalization_without_shared_learning":
|
| 176 |
+
rec["purposes"]["optional_personalization"]["status"] == "granted"
|
| 177 |
+
and rec["purposes"]["optional_federation"]["status"] != "granted",
|
| 178 |
+
"affects_payment": False,
|
| 179 |
+
"authoritative": False,
|
| 180 |
+
"storage_consistency": storage_consistency(),
|
| 181 |
+
"label": "Advisory only · required security processing is separate from optional learning",
|
| 182 |
+
}
|
| 183 |
+
if include_generation:
|
| 184 |
+
# generation is developer-safe metadata (not necessarily surfaced to ordinary customers)
|
| 185 |
+
out["profile_generation"] = rec["profile_generation"]
|
| 186 |
+
return out
|
| 187 |
+
|
| 188 |
+
def has_consent(self, subject: str, purpose: str, now: Optional[float] = None) -> bool:
|
| 189 |
+
"""Is an OPTIONAL purpose currently granted? Required processing always applies (returns True);
|
| 190 |
+
unknown purpose -> False (fail-closed)."""
|
| 191 |
+
if purpose not in PURPOSES:
|
| 192 |
+
return False
|
| 193 |
+
if purpose in REQUIRED_PURPOSES:
|
| 194 |
+
return True
|
| 195 |
+
subject = _clean_subject(subject)
|
| 196 |
+
rec = self._load(subject, _now(now))
|
| 197 |
+
return bool(rec) and rec["purposes"][purpose]["status"] == "granted"
|
| 198 |
+
|
| 199 |
+
def current_generation(self, subject: str, now: Optional[float] = None) -> int:
|
| 200 |
+
subject = _clean_subject(subject)
|
| 201 |
+
rec = self._load(subject, _now(now))
|
| 202 |
+
if rec:
|
| 203 |
+
return int(rec["profile_generation"])
|
| 204 |
+
tomb = self._load_tomb(subject, _now(now))
|
| 205 |
+
return (int(tomb["retired_generation"]) + 1) if tomb else 1
|
| 206 |
+
|
| 207 |
+
# ---- write ---- #
|
| 208 |
+
def set_consent(self, subject: str, purpose: str, grant: bool, *, request_id: str,
|
| 209 |
+
expected_generation: Optional[int] = None, expected_version: Optional[int] = None,
|
| 210 |
+
policy_version: str = POLICY_VERSION, now: Optional[float] = None,
|
| 211 |
+
actor: str = "user") -> Dict:
|
| 212 |
+
"""Grant/withdraw ONE optional purpose with optimistic concurrency + idempotency.
|
| 213 |
+
|
| 214 |
+
Errors (normalised): unknown_purpose, cannot_modify_required_processing, invalid_request_id,
|
| 215 |
+
idempotency_key_reuse, generation_mismatch, version_conflict.
|
| 216 |
+
"""
|
| 217 |
+
subject = _clean_subject(subject)
|
| 218 |
+
if purpose not in PURPOSES:
|
| 219 |
+
raise ConsentError("unknown_purpose")
|
| 220 |
+
if PURPOSE_KIND[purpose] == "required_processing":
|
| 221 |
+
# required processing is not a consent toggle — neither grant nor withdraw
|
| 222 |
+
raise ConsentError("cannot_modify_required_processing")
|
| 223 |
+
if not isinstance(request_id, str) or not request_id.strip():
|
| 224 |
+
raise ConsentError("invalid_request_id")
|
| 225 |
+
now = _now(now)
|
| 226 |
+
rec = self._load(subject, now) or self._new_record(subject, now)
|
| 227 |
+
|
| 228 |
+
# idempotency: same request_id + same payload -> replay the safe result; different -> reject
|
| 229 |
+
idem = rec.get("idempotency", {})
|
| 230 |
+
phash = _payload_hash(purpose, grant)
|
| 231 |
+
if request_id in idem:
|
| 232 |
+
if idem[request_id].get("payload_hash") == phash:
|
| 233 |
+
return self.status(subject, now)
|
| 234 |
+
raise ConsentError("idempotency_key_reuse")
|
| 235 |
+
|
| 236 |
+
# optimistic concurrency
|
| 237 |
+
if expected_generation is not None and int(expected_generation) != int(rec["profile_generation"]):
|
| 238 |
+
raise ConsentError("generation_mismatch")
|
| 239 |
+
if expected_version is not None and int(expected_version) != int(rec["record_version"]):
|
| 240 |
+
raise ConsentError("version_conflict")
|
| 241 |
+
|
| 242 |
+
pmeta = rec["purposes"][purpose]
|
| 243 |
+
pmeta["status"] = "granted" if grant else "withdrawn"
|
| 244 |
+
pmeta["version"] = int(pmeta["version"]) + 1
|
| 245 |
+
pmeta["updated_at"] = now
|
| 246 |
+
rec["record_version"] = int(rec["record_version"]) + 1
|
| 247 |
+
rec["policy_version"] = policy_version
|
| 248 |
+
|
| 249 |
+
# withdrawal takes effect immediately: drop any pending optional payload for this purpose
|
| 250 |
+
if not grant:
|
| 251 |
+
self._deactivate_optional(subject, purpose)
|
| 252 |
+
|
| 253 |
+
self._bind_history(rec, {"event": "consent_change", "purpose": purpose,
|
| 254 |
+
"status": pmeta["status"], "grant": grant, "request_id": request_id,
|
| 255 |
+
"policy_version": policy_version, "actor": actor}, now)
|
| 256 |
+
self._remember_request(rec, request_id, phash, rec["record_version"])
|
| 257 |
+
self._save(rec, now)
|
| 258 |
+
return self.status(subject, now)
|
| 259 |
+
|
| 260 |
+
def assert_update_allowed(self, subject: str, purpose: str, generation: int,
|
| 261 |
+
now: Optional[float] = None) -> None:
|
| 262 |
+
"""Gate for an OPTIONAL profile/federated update prepared earlier and submitted now. Rejects
|
| 263 |
+
it if the purpose is no longer granted or the generation is stale (pre-deletion). Enforces
|
| 264 |
+
immediate withdrawal + previous-generation rejection."""
|
| 265 |
+
subject = _clean_subject(subject)
|
| 266 |
+
now = _now(now)
|
| 267 |
+
if purpose not in OPTIONAL_PURPOSES:
|
| 268 |
+
raise ConsentError("unknown_purpose")
|
| 269 |
+
rec = self._load(subject, now)
|
| 270 |
+
if rec is None:
|
| 271 |
+
raise ConsentError("stale_generation")
|
| 272 |
+
if int(generation) != int(rec["profile_generation"]):
|
| 273 |
+
raise ConsentError("stale_generation")
|
| 274 |
+
if rec["purposes"][purpose]["status"] != "granted":
|
| 275 |
+
raise ConsentError("consent_withdrawn")
|
| 276 |
+
|
| 277 |
+
def delete_profile(self, subject: str, *, request_id: Optional[str] = None,
|
| 278 |
+
now: Optional[float] = None, reason: str = "user_request",
|
| 279 |
+
actor: str = "user") -> Dict:
|
| 280 |
+
"""Delete eligible derived behavioural/optional-learning data. Retains only required
|
| 281 |
+
security records and a minimal non-sensitive deletion tombstone. Retires the generation so
|
| 282 |
+
stale/previous-generation updates are rejected and re-enrolment starts fresh. Idempotent.
|
| 283 |
+
|
| 284 |
+
This is eligible-scope deletion, NOT perfect erasure or machine unlearning.
|
| 285 |
+
"""
|
| 286 |
+
subject = _clean_subject(subject)
|
| 287 |
+
now = _now(now)
|
| 288 |
+
rec = self._load(subject, now)
|
| 289 |
+
existing_tomb = self._load_tomb(subject, now)
|
| 290 |
+
|
| 291 |
+
if rec is None:
|
| 292 |
+
# already deleted (or never existed) -> idempotent no-op receipt
|
| 293 |
+
if existing_tomb:
|
| 294 |
+
return self._deletion_receipt(subject, existing_tomb, deleted_now=False)
|
| 295 |
+
retired_generation, last_version = 0, 0
|
| 296 |
+
else:
|
| 297 |
+
retired_generation = int(rec["profile_generation"])
|
| 298 |
+
last_version = int(rec["record_version"])
|
| 299 |
+
|
| 300 |
+
# Erase eligible optional/derived payloads. Required-processing records are NOT touched here.
|
| 301 |
+
for key in (f"behavprofile:{subject}", f"fedpending:{subject}",
|
| 302 |
+
f"personalization:{subject}"):
|
| 303 |
+
try:
|
| 304 |
+
self._kv.delete(key)
|
| 305 |
+
except Exception:
|
| 306 |
+
pass
|
| 307 |
+
|
| 308 |
+
audit_ref = hashlib.sha256(f"{subject}|{retired_generation}|{now}".encode()).hexdigest()[:16]
|
| 309 |
+
tomb = {
|
| 310 |
+
"namespace": _NAMESPACE, "subject": subject,
|
| 311 |
+
"retired_generation": retired_generation,
|
| 312 |
+
"last_record_version": last_version,
|
| 313 |
+
"deleted_at": now, "audit_ref": audit_ref, "reason": reason,
|
| 314 |
+
"deleted_categories": list(DELETABLE_CATEGORIES),
|
| 315 |
+
"retained_categories": list(RETAINED_CATEGORIES),
|
| 316 |
+
"policy_version": POLICY_VERSION,
|
| 317 |
+
}
|
| 318 |
+
# Remove the behavioural record; keep only the minimal tombstone.
|
| 319 |
+
try:
|
| 320 |
+
self._kv.delete(_KEY.format(subject=subject))
|
| 321 |
+
except Exception:
|
| 322 |
+
pass
|
| 323 |
+
self._kv.set(_TOMB.format(subject=subject), tomb, now=now)
|
| 324 |
+
return self._deletion_receipt(subject, tomb, deleted_now=rec is not None)
|
| 325 |
+
|
| 326 |
+
@staticmethod
|
| 327 |
+
def _deletion_receipt(subject: str, tomb: Dict, deleted_now: bool) -> Dict:
|
| 328 |
+
return {
|
| 329 |
+
"subject": subject,
|
| 330 |
+
"profile": "deleted",
|
| 331 |
+
"deleted_now": deleted_now,
|
| 332 |
+
"deleted_categories": tomb["deleted_categories"],
|
| 333 |
+
"retained_categories": tomb["retained_categories"],
|
| 334 |
+
"retention_reason": "Required for payment security, fraud prevention, service integrity "
|
| 335 |
+
"and applicable obligations; plus a minimal deletion audit record.",
|
| 336 |
+
"retired_generation": tomb["retired_generation"],
|
| 337 |
+
"deleted_at": tomb["deleted_at"],
|
| 338 |
+
"audit_reference": tomb["audit_ref"],
|
| 339 |
+
"personalization_enabled": False,
|
| 340 |
+
"federation_enrolled": False,
|
| 341 |
+
"affects_payment": False,
|
| 342 |
+
"erasure_type": "eligible_scope_deletion", # NOT perfect erasure / machine unlearning
|
| 343 |
+
"note": "Deletes eligible derived behavioural and optional-learning profile data while "
|
| 344 |
+
"retaining only required payment/security records and a minimal non-sensitive "
|
| 345 |
+
"deletion audit record. Not perfect erasure or machine unlearning.",
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
# ---- helpers ---- #
|
| 349 |
+
def _deactivate_optional(self, subject: str, purpose: str) -> None:
|
| 350 |
+
key = {"optional_personalization": f"personalization:{subject}",
|
| 351 |
+
"optional_federation": f"fedpending:{subject}"}.get(purpose)
|
| 352 |
+
if key:
|
| 353 |
+
try:
|
| 354 |
+
self._kv.delete(key)
|
| 355 |
+
except Exception:
|
| 356 |
+
pass
|
| 357 |
+
|
| 358 |
+
def _bind_history(self, rec: Dict, entry: Dict, now: float) -> None:
|
| 359 |
+
bound = {**entry, "subject": rec["subject"], "generation": rec["profile_generation"],
|
| 360 |
+
"record_version": rec["record_version"], "at": now}
|
| 361 |
+
hist = rec.setdefault("history", [])
|
| 362 |
+
hist.append(bound)
|
| 363 |
+
if len(hist) > _HISTORY_CAP:
|
| 364 |
+
del hist[: len(hist) - _HISTORY_CAP]
|
| 365 |
+
|
| 366 |
+
@staticmethod
|
| 367 |
+
def _remember_request(rec: Dict, request_id: str, payload_hash: str, record_version: int) -> None:
|
| 368 |
+
idem = rec.setdefault("idempotency", {})
|
| 369 |
+
idem[request_id] = {"payload_hash": payload_hash, "record_version": record_version}
|
| 370 |
+
if len(idem) > _IDEMPOTENCY_CAP:
|
| 371 |
+
for k in list(idem.keys())[: len(idem) - _IDEMPOTENCY_CAP]:
|
| 372 |
+
del idem[k]
|
amanpay/agentic_orchestration/consent_api.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PR C4 — consent, profile-deletion and federated-status HTTP surface (/ai/v1).
|
| 2 |
+
|
| 3 |
+
Endpoint classification:
|
| 4 |
+
* Authenticated customer (subject derived from the Bearer token — NEVER from a client-supplied
|
| 5 |
+
user id): GET /profile/status, POST /consent, DELETE /profile, GET /consent/history.
|
| 6 |
+
* Authenticated-safe / demo-gated aggregate: GET /federated/status.
|
| 7 |
+
* Static safe policy metadata (no thresholds/coefficients/feature defs): GET /consent/model.
|
| 8 |
+
|
| 9 |
+
None of these can move money, mint capabilities, change the PDP, or expose scores/thresholds/keys/
|
| 10 |
+
raw IBANs. Identity is bound to the authenticated principal; a client cannot select another subject.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import time
|
| 18 |
+
import uuid
|
| 19 |
+
from typing import Dict, Optional
|
| 20 |
+
|
| 21 |
+
from fastapi import APIRouter, Body, HTTPException, Request
|
| 22 |
+
|
| 23 |
+
from amanpay.agentic_orchestration.consent import (
|
| 24 |
+
ConsentError, ConsentStore, OPTIONAL_PURPOSES, POLICY_VERSION, PURPOSE_KIND,
|
| 25 |
+
PURPOSES, REQUIRED_PURPOSES, storage_consistency)
|
| 26 |
+
from amanpay.agentic_orchestration.federated_status import federated_status
|
| 27 |
+
from api.auth import require_auth, verify_token
|
| 28 |
+
|
| 29 |
+
router = APIRouter(prefix="/ai/v1", tags=["agentic-consent"])
|
| 30 |
+
|
| 31 |
+
_CLIENT_ERRORS = {
|
| 32 |
+
"unknown_purpose", "cannot_modify_required_processing", "invalid_request_id",
|
| 33 |
+
"idempotency_key_reuse", "generation_mismatch", "version_conflict", "invalid_subject",
|
| 34 |
+
"stale_generation", "consent_withdrawn",
|
| 35 |
+
}
|
| 36 |
+
# Idempotency-key reuse / version-generation conflicts are 409; other client faults are 422.
|
| 37 |
+
_CONFLICT = {"idempotency_key_reuse", "generation_mismatch", "version_conflict"}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _store() -> ConsentStore:
|
| 41 |
+
return ConsentStore()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _principal(request: Request, claimed_user_id: Optional[str] = None) -> str:
|
| 45 |
+
"""Authoritative subject = the Bearer token's subject. A client can NEVER select another subject.
|
| 46 |
+
|
| 47 |
+
* Valid token -> its subject. If the body/query also claims a user_id that differs -> 403.
|
| 48 |
+
* No/invalid token -> 401 (consent is inherently per-user; there is no anonymous consent).
|
| 49 |
+
Even when AMANPAY_REQUIRE_AUTH is off (public demo), the subject still comes from the token
|
| 50 |
+
minted at sign-in; unauthenticated callers cannot read or mutate anyone's consent.
|
| 51 |
+
"""
|
| 52 |
+
auth = request.headers.get("authorization", "")
|
| 53 |
+
token = auth[7:].strip() if auth.lower().startswith("bearer ") else None
|
| 54 |
+
subject = verify_token(token) if token else None
|
| 55 |
+
if not subject:
|
| 56 |
+
raise HTTPException(status_code=401, detail="authentication required")
|
| 57 |
+
if claimed_user_id is not None and str(claimed_user_id).strip() and str(claimed_user_id) != subject:
|
| 58 |
+
# never honour a client-selected subject that isn't the authenticated principal
|
| 59 |
+
raise HTTPException(status_code=403, detail="subject does not match authenticated principal")
|
| 60 |
+
return subject
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _raise(exc: ConsentError):
|
| 64 |
+
reason = str(exc)
|
| 65 |
+
code = 409 if reason in _CONFLICT else (422 if reason in _CLIENT_ERRORS else 400)
|
| 66 |
+
raise HTTPException(status_code=code, detail=reason)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ---- authenticated customer endpoints ---- #
|
| 70 |
+
@router.get("/profile/status")
|
| 71 |
+
def profile_status(request: Request) -> Dict:
|
| 72 |
+
"""This authenticated user's consent + required-processing status (safe view)."""
|
| 73 |
+
subject = _principal(request)
|
| 74 |
+
try:
|
| 75 |
+
return _store().status(subject)
|
| 76 |
+
except ConsentError as exc:
|
| 77 |
+
_raise(exc)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@router.post("/consent")
|
| 81 |
+
def set_consent(request: Request, body: dict = Body(...)) -> Dict:
|
| 82 |
+
"""Grant/withdraw ONE optional purpose. Required processing cannot be changed here.
|
| 83 |
+
Body: {purpose, grant, request_id?, expected_version?, expected_generation?, policy_version?}."""
|
| 84 |
+
subject = _principal(request, body.get("user_id"))
|
| 85 |
+
purpose = body.get("purpose")
|
| 86 |
+
grant = body.get("grant")
|
| 87 |
+
if purpose not in PURPOSES:
|
| 88 |
+
raise HTTPException(status_code=422, detail="unknown_purpose")
|
| 89 |
+
if purpose in REQUIRED_PURPOSES:
|
| 90 |
+
raise HTTPException(status_code=422, detail="cannot_modify_required_processing")
|
| 91 |
+
if not isinstance(grant, bool):
|
| 92 |
+
raise HTTPException(status_code=422, detail="grant_must_be_boolean")
|
| 93 |
+
request_id = (body.get("request_id") or request.headers.get("idempotency-key")
|
| 94 |
+
or f"auto-{uuid.uuid4().hex[:16]}")
|
| 95 |
+
try:
|
| 96 |
+
return _store().set_consent(
|
| 97 |
+
subject, purpose, grant, request_id=request_id,
|
| 98 |
+
expected_generation=body.get("expected_generation"),
|
| 99 |
+
expected_version=body.get("expected_version"),
|
| 100 |
+
policy_version=body.get("policy_version", POLICY_VERSION))
|
| 101 |
+
except ConsentError as exc:
|
| 102 |
+
_raise(exc)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@router.delete("/profile")
|
| 106 |
+
def delete_profile(request: Request, body: Optional[dict] = Body(default=None)) -> Dict:
|
| 107 |
+
"""Delete eligible behavioural/optional-learning profile data for the authenticated user.
|
| 108 |
+
Required security records + a minimal deletion tombstone are retained."""
|
| 109 |
+
subject = _principal(request, (body or {}).get("user_id"))
|
| 110 |
+
request_id = ((body or {}).get("request_id") or request.headers.get("idempotency-key")
|
| 111 |
+
or f"del-{uuid.uuid4().hex[:16]}")
|
| 112 |
+
try:
|
| 113 |
+
return _store().delete_profile(subject, request_id=request_id)
|
| 114 |
+
except ConsentError as exc:
|
| 115 |
+
_raise(exc)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# ---- safe aggregate / demo-gated ---- #
|
| 119 |
+
@router.get("/federated/status")
|
| 120 |
+
def get_federated_status() -> Dict:
|
| 121 |
+
return federated_status()
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ---- static safe policy metadata ---- #
|
| 125 |
+
@router.get("/consent/model")
|
| 126 |
+
def consent_model() -> Dict:
|
| 127 |
+
"""Static, safe policy metadata only. Exposes NO thresholds, coefficients, feature definitions,
|
| 128 |
+
client identifiers or raw audit payloads."""
|
| 129 |
+
return {
|
| 130 |
+
"purposes": [{"name": p, "purpose_kind": PURPOSE_KIND[p],
|
| 131 |
+
"withdrawable": p in OPTIONAL_PURPOSES,
|
| 132 |
+
"default": ("active" if p in REQUIRED_PURPOSES else "not_granted")}
|
| 133 |
+
for p in PURPOSES],
|
| 134 |
+
"purpose_kinds": {"required_processing": "Required for payment security, fraud prevention, "
|
| 135 |
+
"service integrity and applicable obligations; not an optional toggle.",
|
| 136 |
+
"optional_consent": "Opt-in; starts not_granted; withdrawable independently."},
|
| 137 |
+
"statuses": {"required_processing": ["active"],
|
| 138 |
+
"optional_consent": ["not_granted", "granted", "withdrawn"]},
|
| 139 |
+
"rules": [
|
| 140 |
+
"required security processing is not optional consent and cannot be withdrawn here",
|
| 141 |
+
"optional purposes default to not_granted (explicit opt-in; no silent enrolment)",
|
| 142 |
+
"personalization and federation are independent — one never implies the other",
|
| 143 |
+
"every mutation binds subject, purpose, generation, expected version, policy, request id",
|
| 144 |
+
"withdrawal takes effect immediately; pre-withdrawal/previous-generation updates are rejected",
|
| 145 |
+
"profile deletion is eligible-scope (not perfect erasure or machine unlearning)",
|
| 146 |
+
"consent has no payment authority and cannot override the deterministic PDP",
|
| 147 |
+
],
|
| 148 |
+
"policy_version": POLICY_VERSION,
|
| 149 |
+
"storage_consistency": storage_consistency(),
|
| 150 |
+
"affects_payment": False,
|
| 151 |
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(0)),
|
| 152 |
+
}
|
amanpay/agentic_orchestration/federated_status.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PR C4 — honest, non-authoritative federated-learning status.
|
| 2 |
+
|
| 3 |
+
Surfaces the C3 SIMULATION truthfully. There are NO real devices and NO real user training, so we do
|
| 4 |
+
NOT claim "data never leaves your device". Instead we report explicit, unambiguous statuses:
|
| 5 |
+
|
| 6 |
+
* client_type: SYNTHETIC_SERVER_SIMULATION (synthetic clients, server-side only)
|
| 7 |
+
* real_device_training: NOT_IMPLEMENTED
|
| 8 |
+
* native_local_only_training: NOT_IMPLEMENTED
|
| 9 |
+
* real_user_data_used: false
|
| 10 |
+
* secure_aggregation: DESIGN_ONLY
|
| 11 |
+
* differential_privacy: SIMULATED (+ exhausted budget surfaced honestly)
|
| 12 |
+
* automatic_model_promotion: false
|
| 13 |
+
* affects_payment_authorization: false
|
| 14 |
+
|
| 15 |
+
The simulated DP budget is reported as exhausted when the C3 accountant says so, and the candidate is
|
| 16 |
+
reported ineligible for promotion — an exhausted simulated budget is never shown as healthy/approved.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
from typing import Dict, Optional
|
| 24 |
+
|
| 25 |
+
from amanpay.federated_risk.simulation import DISCLAIMER, STATUS_LABELS
|
| 26 |
+
|
| 27 |
+
_ART_DIR = os.path.join("docs", "agentic-risk", "artifacts")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _read(name: str) -> Optional[Dict]:
|
| 31 |
+
path = os.path.join(_ART_DIR, name)
|
| 32 |
+
if not os.path.exists(path):
|
| 33 |
+
return None
|
| 34 |
+
try:
|
| 35 |
+
return json.load(open(path, encoding="utf-8"))
|
| 36 |
+
except Exception:
|
| 37 |
+
return None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def privacy_view() -> Dict:
|
| 41 |
+
"""Honest DP view from the C3 privacy artifact. An exhausted simulated budget is surfaced as
|
| 42 |
+
such and marks the candidate privacy-ineligible."""
|
| 43 |
+
pr = _read("federated_privacy_report.json") or {}
|
| 44 |
+
exhausted = bool(pr.get("exhausted", True))
|
| 45 |
+
return {
|
| 46 |
+
"differential_privacy": STATUS_LABELS.get("differential_privacy", "SIMULATED"),
|
| 47 |
+
"accountant": pr.get("accountant", "basic_composition (conservative, non-tight)"),
|
| 48 |
+
"cumulative_epsilon": pr.get("cumulative_epsilon"),
|
| 49 |
+
"budget_epsilon": pr.get("budget_epsilon"),
|
| 50 |
+
"budget_exhausted": exhausted,
|
| 51 |
+
"candidate_privacy_eligibility": "ineligible" if exhausted else "eligible_simulated",
|
| 52 |
+
"production_privacy_guarantee": False,
|
| 53 |
+
"note": "SIMULATED privacy accounting only. An exhausted simulated budget is not a production "
|
| 54 |
+
"privacy guarantee and does not indicate compliance.",
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def federated_status() -> Dict:
|
| 59 |
+
cand = _read("federated_model_candidate.json") or {}
|
| 60 |
+
priv = privacy_view()
|
| 61 |
+
out: Dict = {
|
| 62 |
+
# explicit, unambiguous statuses (no "data stays on your device" claim)
|
| 63 |
+
"client_type": "SYNTHETIC_SERVER_SIMULATION",
|
| 64 |
+
"real_device_training": STATUS_LABELS.get("real_device_training", "NOT_IMPLEMENTED"),
|
| 65 |
+
"native_local_only_training": "NOT_IMPLEMENTED",
|
| 66 |
+
"real_user_data_used": False,
|
| 67 |
+
"federation": STATUS_LABELS.get("federation", "SIMULATED"),
|
| 68 |
+
"secure_aggregation": STATUS_LABELS.get("secure_aggregation", "DESIGN_ONLY"),
|
| 69 |
+
"differential_privacy": priv["differential_privacy"],
|
| 70 |
+
"differential_privacy_detail": priv,
|
| 71 |
+
"automatic_model_promotion": False,
|
| 72 |
+
"affects_payment_authorization": False,
|
| 73 |
+
"affects_pdp": False,
|
| 74 |
+
"affects_authentication": False,
|
| 75 |
+
"affects_payment_execution": False,
|
| 76 |
+
"no_payment_authority": True,
|
| 77 |
+
"candidate_status": cand.get("status", "candidate"),
|
| 78 |
+
"candidate_promotable": False, # never auto-promoted; simulated + budget exhausted
|
| 79 |
+
"promotion_boundary": cand.get("promotion_boundary", "approved_for_shadow"),
|
| 80 |
+
"disclaimer": DISCLAIMER,
|
| 81 |
+
"label": "SIMULATED federated learning · synthetic server-side clients · no payment authority",
|
| 82 |
+
}
|
| 83 |
+
# coarse, non-identifying facts if available
|
| 84 |
+
sim = _read("federated_simulation.json")
|
| 85 |
+
if sim:
|
| 86 |
+
per_seed = sim.get("per_seed") or []
|
| 87 |
+
out["aggregators"] = sorted(list((per_seed[0].get("federated") if per_seed else {}) or {}))
|
| 88 |
+
return out
|
api/main.py
CHANGED
|
@@ -146,6 +146,10 @@ app.include_router(agent_security_router)
|
|
| 146 |
from amanpay.agentic_orchestration.api import router as ai_router # noqa: E402
|
| 147 |
app.include_router(ai_router)
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
_ROOT = os.path.dirname(os.path.dirname(__file__))
|
| 151 |
_FRONTEND = os.path.join(_ROOT, "frontend", "index.html") # legacy (rollback)
|
|
|
|
| 146 |
from amanpay.agentic_orchestration.api import router as ai_router # noqa: E402
|
| 147 |
app.include_router(ai_router)
|
| 148 |
|
| 149 |
+
# Consent, profile-deletion and federated-status surface (/ai/v1 — advisory only, no payment authority).
|
| 150 |
+
from amanpay.agentic_orchestration.consent_api import router as consent_router # noqa: E402
|
| 151 |
+
app.include_router(consent_router)
|
| 152 |
+
|
| 153 |
|
| 154 |
_ROOT = os.path.dirname(os.path.dirname(__file__))
|
| 155 |
_FRONTEND = os.path.join(_ROOT, "frontend", "index.html") # legacy (rollback)
|
build_info.json
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
{"commit":"
|
|
|
|
| 1 |
+
{"commit":"d3d1cf1","build_time":"2026-07-14T01:50:52Z","frontend":"1.0.0"}
|
web/e2e/agentic-ux.spec.ts
CHANGED
|
@@ -36,6 +36,54 @@ async function mockApi(page: Page) {
|
|
| 36 |
await page.route('**/ai/v1/models/status', (r) => r.fulfill(json({ behavioural_model: 'logreg', model_version: 'v1', feature_version: 'features-v1', reason_codes_version: 'reason_codes-v1', shadow_only: true, affects_payment: false, label: 'DEMO ONLY · Mock provider · No real-money payment' })))
|
| 37 |
await page.route('**/ai/v1/demo/payee', (r) => r.fulfill(json({ payee_ref: 'payee_demo', display: 'SA03 **** 7519' })))
|
| 38 |
await page.route('**/ai/v1/demo/orchestrate', (r) => r.fulfill(json(orchestrateResult(r.request().postDataJSON() ?? {}))))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
}
|
| 40 |
|
| 41 |
test.beforeEach(async ({ page }) => { await mockApi(page) })
|
|
@@ -189,6 +237,119 @@ test('21. unknown route shows a safe not-found screen', async ({ page }) => {
|
|
| 189 |
await expect(page.getByText(/Page not found/i)).toBeVisible()
|
| 190 |
})
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
test('22. Arabic payment review renders', async ({ page }, info) => {
|
| 193 |
await signIn(page)
|
| 194 |
await page.goto('/#/home')
|
|
|
|
| 36 |
await page.route('**/ai/v1/models/status', (r) => r.fulfill(json({ behavioural_model: 'logreg', model_version: 'v1', feature_version: 'features-v1', reason_codes_version: 'reason_codes-v1', shadow_only: true, affects_payment: false, label: 'DEMO ONLY · Mock provider · No real-money payment' })))
|
| 37 |
await page.route('**/ai/v1/demo/payee', (r) => r.fulfill(json({ payee_ref: 'payee_demo', display: 'SA03 **** 7519' })))
|
| 38 |
await page.route('**/ai/v1/demo/orchestrate', (r) => r.fulfill(json(orchestrateResult(r.request().postDataJSON() ?? {}))))
|
| 39 |
+
// C4: consent + federated status. Subject is the authenticated principal (token); the mock keeps
|
| 40 |
+
// in-memory state keyed by a single demo subject so grant/withdraw/delete is observable.
|
| 41 |
+
const consent: Record<string, string> = {}
|
| 42 |
+
let version = 3
|
| 43 |
+
const meta = (purpose: string) =>
|
| 44 |
+
purpose === 'service_essential'
|
| 45 |
+
? { purpose_kind: 'required_processing', withdrawable: false, state: 'active', version: 1, updated_at: 1 }
|
| 46 |
+
: { purpose_kind: 'optional_consent', withdrawable: true, status: consent[purpose] ?? 'not_granted', version: 1, updated_at: 1 }
|
| 47 |
+
const profile = () => {
|
| 48 |
+
const pers = consent['optional_personalization'] ?? 'not_granted'
|
| 49 |
+
const fed = consent['optional_federation'] ?? 'not_granted'
|
| 50 |
+
return {
|
| 51 |
+
subject: 'demo', policy_version: 'c4-policy-v1', record_version: version, profile_generation: 1,
|
| 52 |
+
purposes: { service_essential: meta('service_essential'), optional_personalization: meta('optional_personalization'), optional_federation: meta('optional_federation') },
|
| 53 |
+
personalization_enabled: pers === 'granted', federation_enrolled: fed === 'granted',
|
| 54 |
+
personalization_without_shared_learning: pers === 'granted' && fed !== 'granted',
|
| 55 |
+
affects_payment: false, authoritative: false,
|
| 56 |
+
storage_consistency: { backend: 'memory', durable_multi_replica: false, note: 'ephemeral' },
|
| 57 |
+
label: 'Advisory only',
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
await page.route('**/ai/v1/profile/status', (r) => r.fulfill(json(profile())))
|
| 61 |
+
await page.route('**/ai/v1/consent', (r) => {
|
| 62 |
+
const b = r.request().postDataJSON() ?? {}
|
| 63 |
+
consent[b.purpose] = b.grant ? 'granted' : 'withdrawn'
|
| 64 |
+
version += 1
|
| 65 |
+
return r.fulfill(json(profile()))
|
| 66 |
+
})
|
| 67 |
+
await page.route('**/ai/v1/profile', (r) => {
|
| 68 |
+
delete consent['optional_personalization']; delete consent['optional_federation']; version += 1
|
| 69 |
+
return r.fulfill(json({ subject: 'demo', profile: 'deleted', deleted_now: true,
|
| 70 |
+
deleted_categories: ['derived_behavioural_features', 'optional_personalization_state'],
|
| 71 |
+
retained_categories: ['required_security_processing_records', 'minimal_deletion_tombstone'],
|
| 72 |
+
retention_reason: 'required', retired_generation: 1, audit_reference: 'abc123',
|
| 73 |
+
erasure_type: 'eligible_scope_deletion', affects_payment: false, note: 'eligible-scope' }))
|
| 74 |
+
})
|
| 75 |
+
await page.route('**/ai/v1/federated/status', (r) => r.fulfill(json({
|
| 76 |
+
client_type: 'SYNTHETIC_SERVER_SIMULATION', real_device_training: 'NOT_IMPLEMENTED',
|
| 77 |
+
native_local_only_training: 'NOT_IMPLEMENTED', real_user_data_used: false,
|
| 78 |
+
federation: 'SIMULATED', secure_aggregation: 'DESIGN_ONLY', differential_privacy: 'SIMULATED',
|
| 79 |
+
differential_privacy_detail: { differential_privacy: 'SIMULATED', budget_exhausted: true,
|
| 80 |
+
candidate_privacy_eligibility: 'ineligible', cumulative_epsilon: 38.76, budget_epsilon: 8,
|
| 81 |
+
production_privacy_guarantee: false, note: 'simulated only' },
|
| 82 |
+
automatic_model_promotion: false, affects_payment_authorization: false,
|
| 83 |
+
candidate_status: 'candidate', candidate_promotable: false,
|
| 84 |
+
disclaimer: 'Server-side simulation using synthetic clients; not real cross-device federated learning.',
|
| 85 |
+
label: 'SIMULATED', aggregators: ['coordinate_median', 'fedavg', 'trimmed_mean'],
|
| 86 |
+
})))
|
| 87 |
}
|
| 88 |
|
| 89 |
test.beforeEach(async ({ page }) => { await mockApi(page) })
|
|
|
|
| 237 |
await expect(page.getByText(/Page not found/i)).toBeVisible()
|
| 238 |
})
|
| 239 |
|
| 240 |
+
test('23. required security row is informational (no toggle, not "granted consent")', async ({ page }, info) => {
|
| 241 |
+
await signIn(page)
|
| 242 |
+
await goHash(page, '#/security')
|
| 243 |
+
await expect(page.getByTestId('required-state')).toHaveText('Active')
|
| 244 |
+
await expect(page.getByTestId('consent-toggle-service_essential')).toHaveCount(0)
|
| 245 |
+
await expect(page.getByTestId('consent-service_essential')).not.toContainText(/granted/i)
|
| 246 |
+
await shot(page, 'security-required', info.project.name)
|
| 247 |
+
})
|
| 248 |
+
|
| 249 |
+
test('24. personalization and federation are separate opt-ins; one never grants the other', async ({ page }, info) => {
|
| 250 |
+
await signIn(page)
|
| 251 |
+
await goHash(page, '#/security')
|
| 252 |
+
await expect(page.getByTestId('consent-state-optional_personalization')).toHaveText('Not granted')
|
| 253 |
+
await expect(page.getByTestId('consent-state-optional_federation')).toHaveText('Not granted')
|
| 254 |
+
await page.getByTestId('consent-toggle-optional_personalization').click()
|
| 255 |
+
await expect(page.getByTestId('consent-state-optional_personalization')).toHaveText('Granted')
|
| 256 |
+
await expect(page.getByTestId('consent-state-optional_federation')).toHaveText('Not granted') // NOT granted
|
| 257 |
+
await expect(page.getByTestId('pwsl')).toBeVisible() // personalization without shared learning
|
| 258 |
+
await shot(page, 'consent-personalization', info.project.name)
|
| 259 |
+
})
|
| 260 |
+
|
| 261 |
+
test('25. granting federation does not alter payment; personalization stays independent', async ({ page }, info) => {
|
| 262 |
+
await signIn(page)
|
| 263 |
+
await goHash(page, '#/security')
|
| 264 |
+
await page.getByTestId('consent-toggle-optional_federation').click()
|
| 265 |
+
await expect(page.getByTestId('consent-state-optional_federation')).toHaveText('Granted')
|
| 266 |
+
await expect(page.getByTestId('consent-state-optional_personalization')).toHaveText('Not granted')
|
| 267 |
+
// withdrawing federation leaves personalization independently configurable
|
| 268 |
+
await page.getByTestId('consent-toggle-optional_federation').click()
|
| 269 |
+
await expect(page.getByTestId('consent-state-optional_federation')).toHaveText('Not granted')
|
| 270 |
+
await shot(page, 'consent-federation', info.project.name)
|
| 271 |
+
})
|
| 272 |
+
|
| 273 |
+
test('26. federated status: synthetic server simulation, DP simulated+exhausted, secure-agg design-only', async ({ page }, info) => {
|
| 274 |
+
await page.goto('/#/security')
|
| 275 |
+
await expect(page.getByTestId('fed-label-client_type')).toHaveText('SYNTHETIC_SERVER_SIMULATION')
|
| 276 |
+
await expect(page.getByTestId('fed-label-secure_aggregation')).toHaveText('DESIGN_ONLY')
|
| 277 |
+
await expect(page.getByTestId('fed-label-real_device_training')).toHaveText('NOT_IMPLEMENTED')
|
| 278 |
+
await expect(page.getByTestId('dp-exhausted')).toContainText(/budget is exhausted/i)
|
| 279 |
+
await expect(page.getByTestId('fed-no-authority')).toContainText(/No payment authority/i)
|
| 280 |
+
// no "data stays on device" claim anywhere on the page
|
| 281 |
+
await expect(page.locator('body')).not.toContainText(/stays only on your (phone|device)|never leaves your device/i)
|
| 282 |
+
await shot(page, 'security-federated', info.project.name)
|
| 283 |
+
})
|
| 284 |
+
|
| 285 |
+
test('27. delete profile lists eligible-deleted + retained-required, resets optional state', async ({ page }, info) => {
|
| 286 |
+
await signIn(page)
|
| 287 |
+
await goHash(page, '#/security')
|
| 288 |
+
await page.getByTestId('consent-toggle-optional_federation').click()
|
| 289 |
+
await expect(page.getByTestId('consent-state-optional_federation')).toHaveText('Granted')
|
| 290 |
+
page.once('dialog', (d) => d.accept())
|
| 291 |
+
await page.getByTestId('consent-delete').click()
|
| 292 |
+
await expect(page.getByText(/eligible behavioural profile data was deleted/i)).toBeVisible()
|
| 293 |
+
const receipt = page.getByTestId('deletion-receipt')
|
| 294 |
+
await expect(receipt).toContainText(/derived_behavioural_features/)
|
| 295 |
+
await expect(receipt).toContainText(/required_security_processing_records/)
|
| 296 |
+
await expect(receipt).toContainText(/not perfect erasure or machine unlearning/i)
|
| 297 |
+
await expect(page.getByTestId('consent-state-optional_federation')).toHaveText('Not granted') // reset
|
| 298 |
+
await shot(page, 'consent-deleted', info.project.name)
|
| 299 |
+
})
|
| 300 |
+
|
| 301 |
+
test('28. re-enrollment works after deletion', async ({ page }) => {
|
| 302 |
+
await signIn(page)
|
| 303 |
+
await goHash(page, '#/security')
|
| 304 |
+
page.once('dialog', (d) => d.accept())
|
| 305 |
+
await page.getByTestId('consent-delete').click()
|
| 306 |
+
await expect(page.getByTestId('deletion-receipt')).toBeVisible()
|
| 307 |
+
await page.getByTestId('consent-toggle-optional_personalization').click()
|
| 308 |
+
await expect(page.getByTestId('consent-state-optional_personalization')).toHaveText('Granted')
|
| 309 |
+
})
|
| 310 |
+
|
| 311 |
+
test('29. no raw feature/client-id/weight/coefficient/capability leaks on Security', async ({ page }) => {
|
| 312 |
+
await signIn(page)
|
| 313 |
+
await goHash(page, '#/security')
|
| 314 |
+
await expect(page.getByTestId('federated-status')).toBeVisible()
|
| 315 |
+
const body = (await page.locator('body').textContent()) ?? ''
|
| 316 |
+
expect(body).not.toMatch(/coefficient|feature_vector|CapabilityGrant|ExecutionCapability|BEGIN [A-Z ]*PRIVATE KEY|608010167519/)
|
| 317 |
+
})
|
| 318 |
+
|
| 319 |
+
test('30. shared-learning demo explains the simulation honestly (synthetic, not promotable)', async ({ page }, info) => {
|
| 320 |
+
await page.goto('/#/demo/federated-learning')
|
| 321 |
+
await expect(page.getByRole('heading', { name: /Shared-learning demo/i })).toBeVisible()
|
| 322 |
+
await expect(page.getByText('Shadow mode').first()).toBeVisible()
|
| 323 |
+
await expect(page.getByText(/not real cross-device training/i)).toBeVisible()
|
| 324 |
+
await expect(page.getByTestId('fed-not-promotable')).toContainText(/not promotable/i)
|
| 325 |
+
await expect(page.getByTestId('fed-synthetic')).toContainText(/no real user data is used/i)
|
| 326 |
+
await shot(page, 'federated-demo', info.project.name)
|
| 327 |
+
})
|
| 328 |
+
|
| 329 |
+
test('31. Arabic consent + deletion wording renders', async ({ page }, info) => {
|
| 330 |
+
await signIn(page)
|
| 331 |
+
await page.getByRole('button', { name: 'العربية' }).click()
|
| 332 |
+
await goHash(page, '#/security')
|
| 333 |
+
await expect(page.getByTestId('required-state')).toHaveText('مُفعَّلة') // required processing = Active (AR)
|
| 334 |
+
await expect(page.getByTestId('consent-service_essential')).toContainText('معالجة الأمان المطلوبة') // required security processing
|
| 335 |
+
await expect(page.getByTestId('consent-optional_personalization')).toContainText('التخصيص الاختياري') // optional personalization
|
| 336 |
+
await shot(page, 'arabic-consent', info.project.name)
|
| 337 |
+
page.once('dialog', (d) => d.accept())
|
| 338 |
+
await page.getByTestId('consent-delete').click()
|
| 339 |
+
await expect(page.getByTestId('deletion-receipt')).toBeVisible()
|
| 340 |
+
await shot(page, 'arabic-deletion', info.project.name)
|
| 341 |
+
})
|
| 342 |
+
|
| 343 |
+
test('32. payment journey is unchanged by consent selections', async ({ page }) => {
|
| 344 |
+
await signIn(page)
|
| 345 |
+
await goHash(page, '#/security')
|
| 346 |
+
await page.getByTestId('consent-toggle-optional_personalization').click()
|
| 347 |
+
await expect(page.getByTestId('consent-state-optional_personalization')).toHaveText('Granted')
|
| 348 |
+
await walkToReview(page) // full staged journey still works
|
| 349 |
+
await expect(page.getByTestId('stage-review')).toBeVisible()
|
| 350 |
+
await expect(page.getByText(/SA03 \*\*\*\* \*\*\*\* 7519/)).toBeVisible()
|
| 351 |
+
})
|
| 352 |
+
|
| 353 |
test('22. Arabic payment review renders', async ({ page }, info) => {
|
| 354 |
await signIn(page)
|
| 355 |
await page.goto('/#/home')
|
web/src/App.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { HomePage } from './pages/HomePage'
|
|
| 6 |
import { SecurityPage } from './pages/SecurityPage'
|
| 7 |
import { BiometricsLabPage } from './pages/BiometricsLabPage'
|
| 8 |
import { AgenticDemoPage } from './pages/AgenticDemoPage'
|
|
|
|
| 9 |
import { ActivityPage } from './pages/ActivityPage'
|
| 10 |
import { BiometricsDashboard } from './pages/BiometricsDashboard'
|
| 11 |
import { BiometricEnrollPage } from './pages/BiometricEnrollPage'
|
|
@@ -21,7 +22,7 @@ import { Button } from './components/ui'
|
|
| 21 |
import { AGENTIC_DEMO_ENABLED } from './api/ai'
|
| 22 |
|
| 23 |
const ROUTES = [
|
| 24 |
-
'home', 'pay', 'security', 'activity', 'demo/agentic-security', 'biometrics/lab',
|
| 25 |
'enroll', 'biometrics', 'biometrics/enroll', 'biometrics/verify',
|
| 26 |
'biometrics/liveness', 'biometrics/reportcard', 'biometrics/oob', 'results', 'notfound',
|
| 27 |
] as const
|
|
@@ -76,6 +77,7 @@ export function App() {
|
|
| 76 |
<nav aria-label="Primary" className="desktop-nav">
|
| 77 |
{PRIMARY.map((p) => <span key={p.route}>{tab(p.route, t(p.key as never))}</span>)}
|
| 78 |
{AGENTIC_DEMO_ENABLED && tab('demo/agentic-security', t('nav.demo'))}
|
|
|
|
| 79 |
{tab('biometrics/lab', t('nav.lab'))}
|
| 80 |
</nav>
|
| 81 |
<div className="topbar-right">
|
|
@@ -89,6 +91,7 @@ export function App() {
|
|
| 89 |
{route === 'pay' && <PayPage onNavEnroll={() => nav('enroll')} onNav={(r) => nav(r)} />}
|
| 90 |
{route === 'security' && <SecurityPage onNav={(r) => nav(r)} />}
|
| 91 |
{route === 'demo/agentic-security' && <AgenticDemoPage />}
|
|
|
|
| 92 |
{route === 'biometrics/lab' && <BiometricsLabPage onNav={(r) => nav(r)} />}
|
| 93 |
{route === 'activity' && <ActivityPage />}
|
| 94 |
{route === 'enroll' && <EnrollPage />}
|
|
@@ -106,6 +109,9 @@ export function App() {
|
|
| 106 |
{AGENTIC_DEMO_ENABLED && (
|
| 107 |
<button role="menuitem" className="more-item" onClick={() => { setMoreOpen(false); nav('demo/agentic-security') }}>{t('nav.demo')}</button>
|
| 108 |
)}
|
|
|
|
|
|
|
|
|
|
| 109 |
<button role="menuitem" className="more-item" onClick={() => { setMoreOpen(false); nav('biometrics/lab') }}>{t('nav.lab')}</button>
|
| 110 |
</div>
|
| 111 |
)}
|
|
|
|
| 6 |
import { SecurityPage } from './pages/SecurityPage'
|
| 7 |
import { BiometricsLabPage } from './pages/BiometricsLabPage'
|
| 8 |
import { AgenticDemoPage } from './pages/AgenticDemoPage'
|
| 9 |
+
import { FederatedDemoPage } from './pages/FederatedDemoPage'
|
| 10 |
import { ActivityPage } from './pages/ActivityPage'
|
| 11 |
import { BiometricsDashboard } from './pages/BiometricsDashboard'
|
| 12 |
import { BiometricEnrollPage } from './pages/BiometricEnrollPage'
|
|
|
|
| 22 |
import { AGENTIC_DEMO_ENABLED } from './api/ai'
|
| 23 |
|
| 24 |
const ROUTES = [
|
| 25 |
+
'home', 'pay', 'security', 'activity', 'demo/agentic-security', 'demo/federated-learning', 'biometrics/lab',
|
| 26 |
'enroll', 'biometrics', 'biometrics/enroll', 'biometrics/verify',
|
| 27 |
'biometrics/liveness', 'biometrics/reportcard', 'biometrics/oob', 'results', 'notfound',
|
| 28 |
] as const
|
|
|
|
| 77 |
<nav aria-label="Primary" className="desktop-nav">
|
| 78 |
{PRIMARY.map((p) => <span key={p.route}>{tab(p.route, t(p.key as never))}</span>)}
|
| 79 |
{AGENTIC_DEMO_ENABLED && tab('demo/agentic-security', t('nav.demo'))}
|
| 80 |
+
{AGENTIC_DEMO_ENABLED && tab('demo/federated-learning', t('nav.federatedDemo'))}
|
| 81 |
{tab('biometrics/lab', t('nav.lab'))}
|
| 82 |
</nav>
|
| 83 |
<div className="topbar-right">
|
|
|
|
| 91 |
{route === 'pay' && <PayPage onNavEnroll={() => nav('enroll')} onNav={(r) => nav(r)} />}
|
| 92 |
{route === 'security' && <SecurityPage onNav={(r) => nav(r)} />}
|
| 93 |
{route === 'demo/agentic-security' && <AgenticDemoPage />}
|
| 94 |
+
{route === 'demo/federated-learning' && <FederatedDemoPage />}
|
| 95 |
{route === 'biometrics/lab' && <BiometricsLabPage onNav={(r) => nav(r)} />}
|
| 96 |
{route === 'activity' && <ActivityPage />}
|
| 97 |
{route === 'enroll' && <EnrollPage />}
|
|
|
|
| 109 |
{AGENTIC_DEMO_ENABLED && (
|
| 110 |
<button role="menuitem" className="more-item" onClick={() => { setMoreOpen(false); nav('demo/agentic-security') }}>{t('nav.demo')}</button>
|
| 111 |
)}
|
| 112 |
+
{AGENTIC_DEMO_ENABLED && (
|
| 113 |
+
<button role="menuitem" className="more-item" onClick={() => { setMoreOpen(false); nav('demo/federated-learning') }}>{t('nav.federatedDemo')}</button>
|
| 114 |
+
)}
|
| 115 |
<button role="menuitem" className="more-item" onClick={() => { setMoreOpen(false); nav('biometrics/lab') }}>{t('nav.lab')}</button>
|
| 116 |
</div>
|
| 117 |
)}
|
web/src/api/ai.ts
CHANGED
|
@@ -65,10 +65,107 @@ export interface ScenarioInput {
|
|
| 65 |
now?: number
|
| 66 |
}
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
export function modelStatus(signal?: AbortSignal): Promise<ModelStatus> {
|
| 69 |
return apiRequest<ModelStatus>('/ai/v1/models/status', { signal })
|
| 70 |
}
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
export function demoPayee(iban: string, signal?: AbortSignal): Promise<{ payee_ref: string; display: string }> {
|
| 73 |
return apiRequest('/ai/v1/demo/payee', { body: { iban }, signal })
|
| 74 |
}
|
|
|
|
| 65 |
now?: number
|
| 66 |
}
|
| 67 |
|
| 68 |
+
// ---- C4: consent, profile deletion, federated status (advisory only, no payment authority) ---- //
|
| 69 |
+
// Identity is the authenticated principal (the session token's subject) — the client never sends a
|
| 70 |
+
// user_id. Every mutation carries a fresh request_id (idempotency) and the last-known record_version
|
| 71 |
+
// (optimistic concurrency); a 409 means a newer decision exists, so the caller refetches.
|
| 72 |
+
export type ConsentPurpose = 'service_essential' | 'optional_personalization' | 'optional_federation'
|
| 73 |
+
export type PurposeKind = 'required_processing' | 'optional_consent'
|
| 74 |
+
export type ConsentStatus = 'not_granted' | 'granted' | 'withdrawn'
|
| 75 |
+
|
| 76 |
+
export interface PurposeMeta {
|
| 77 |
+
purpose_kind: PurposeKind
|
| 78 |
+
withdrawable: boolean
|
| 79 |
+
version: number
|
| 80 |
+
updated_at: number
|
| 81 |
+
status?: ConsentStatus // optional_consent only
|
| 82 |
+
state?: string // required_processing only ('active')
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
export interface ProfileStatus {
|
| 86 |
+
subject: string
|
| 87 |
+
policy_version: string
|
| 88 |
+
record_version: number
|
| 89 |
+
profile_generation?: number
|
| 90 |
+
purposes: Record<ConsentPurpose, PurposeMeta>
|
| 91 |
+
personalization_enabled: boolean
|
| 92 |
+
federation_enrolled: boolean
|
| 93 |
+
personalization_without_shared_learning: boolean
|
| 94 |
+
affects_payment: boolean
|
| 95 |
+
authoritative: boolean
|
| 96 |
+
storage_consistency: { backend: string; durable_multi_replica: boolean; note: string }
|
| 97 |
+
label: string
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
export interface DifferentialPrivacyDetail {
|
| 101 |
+
differential_privacy: string
|
| 102 |
+
budget_exhausted: boolean
|
| 103 |
+
candidate_privacy_eligibility: string
|
| 104 |
+
cumulative_epsilon?: number | null
|
| 105 |
+
budget_epsilon?: number | null
|
| 106 |
+
production_privacy_guarantee: boolean
|
| 107 |
+
note: string
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
export interface FederatedStatus {
|
| 111 |
+
client_type: string
|
| 112 |
+
real_device_training: string
|
| 113 |
+
native_local_only_training: string
|
| 114 |
+
real_user_data_used: boolean
|
| 115 |
+
federation: string
|
| 116 |
+
secure_aggregation: string
|
| 117 |
+
differential_privacy: string
|
| 118 |
+
differential_privacy_detail: DifferentialPrivacyDetail
|
| 119 |
+
automatic_model_promotion: boolean
|
| 120 |
+
affects_payment_authorization: boolean
|
| 121 |
+
candidate_status: string
|
| 122 |
+
candidate_promotable: boolean
|
| 123 |
+
disclaimer: string
|
| 124 |
+
label: string
|
| 125 |
+
aggregators?: string[]
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
export interface DeleteProfileResult {
|
| 129 |
+
subject: string
|
| 130 |
+
profile: string
|
| 131 |
+
deleted_now: boolean
|
| 132 |
+
deleted_categories: string[]
|
| 133 |
+
retained_categories: string[]
|
| 134 |
+
retention_reason: string
|
| 135 |
+
retired_generation: number
|
| 136 |
+
audit_reference: string
|
| 137 |
+
erasure_type: string
|
| 138 |
+
affects_payment: boolean
|
| 139 |
+
note: string
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function requestId(): string {
|
| 143 |
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto
|
| 144 |
+
return c?.randomUUID ? c.randomUUID() : `req-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
export function modelStatus(signal?: AbortSignal): Promise<ModelStatus> {
|
| 148 |
return apiRequest<ModelStatus>('/ai/v1/models/status', { signal })
|
| 149 |
}
|
| 150 |
|
| 151 |
+
export function profileStatus(signal?: AbortSignal): Promise<ProfileStatus> {
|
| 152 |
+
return apiRequest<ProfileStatus>('/ai/v1/profile/status', { signal })
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
export function setConsent(purpose: ConsentPurpose, grant: boolean, expectedVersion?: number, signal?: AbortSignal): Promise<ProfileStatus> {
|
| 156 |
+
return apiRequest<ProfileStatus>('/ai/v1/consent', {
|
| 157 |
+
body: { purpose, grant, request_id: requestId(), expected_version: expectedVersion }, signal,
|
| 158 |
+
})
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
export function deleteProfile(signal?: AbortSignal): Promise<DeleteProfileResult> {
|
| 162 |
+
return apiRequest<DeleteProfileResult>('/ai/v1/profile', { method: 'DELETE', body: { request_id: requestId() }, signal })
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
export function federatedStatus(signal?: AbortSignal): Promise<FederatedStatus> {
|
| 166 |
+
return apiRequest<FederatedStatus>('/ai/v1/federated/status', { signal })
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
export function demoPayee(iban: string, signal?: AbortSignal): Promise<{ payee_ref: string; display: string }> {
|
| 170 |
return apiRequest('/ai/v1/demo/payee', { body: { iban }, signal })
|
| 171 |
}
|
web/src/components/ConsentControls.test.tsx
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
| 2 |
+
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
| 3 |
+
import { I18nProvider } from '../i18n'
|
| 4 |
+
import { ConsentControls } from './ConsentControls'
|
| 5 |
+
import * as ai from '../api/ai'
|
| 6 |
+
import { ApiRequestError } from '../api/client'
|
| 7 |
+
|
| 8 |
+
vi.mock('../api/ai')
|
| 9 |
+
|
| 10 |
+
function mkStatus(over: Partial<Record<ai.ConsentPurpose, Partial<ai.PurposeMeta>>> = {}): ai.ProfileStatus {
|
| 11 |
+
const opt = (status: ai.ConsentStatus, version: number): ai.PurposeMeta =>
|
| 12 |
+
({ purpose_kind: 'optional_consent', withdrawable: true, status, version, updated_at: 1 })
|
| 13 |
+
const req: ai.PurposeMeta = { purpose_kind: 'required_processing', withdrawable: false, state: 'active', version: 1, updated_at: 1 }
|
| 14 |
+
const purposes = {
|
| 15 |
+
service_essential: { ...req, ...(over.service_essential ?? {}) },
|
| 16 |
+
optional_personalization: { ...opt('not_granted', 0), ...(over.optional_personalization ?? {}) },
|
| 17 |
+
optional_federation: { ...opt('not_granted', 0), ...(over.optional_federation ?? {}) },
|
| 18 |
+
} as ai.ProfileStatus['purposes']
|
| 19 |
+
return {
|
| 20 |
+
subject: 'alice', policy_version: 'c4-policy-v1', record_version: 3, profile_generation: 1,
|
| 21 |
+
purposes,
|
| 22 |
+
personalization_enabled: purposes.optional_personalization.status === 'granted',
|
| 23 |
+
federation_enrolled: purposes.optional_federation.status === 'granted',
|
| 24 |
+
personalization_without_shared_learning:
|
| 25 |
+
purposes.optional_personalization.status === 'granted' && purposes.optional_federation.status !== 'granted',
|
| 26 |
+
affects_payment: false, authoritative: false,
|
| 27 |
+
storage_consistency: { backend: 'memory', durable_multi_replica: false, note: 'ephemeral' },
|
| 28 |
+
label: 'Advisory only',
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const renderCtl = () => render(<I18nProvider initial="en"><ConsentControls /></I18nProvider>)
|
| 33 |
+
|
| 34 |
+
describe('ConsentControls', () => {
|
| 35 |
+
beforeEach(() => { vi.spyOn(ai, 'profileStatus').mockResolvedValue(mkStatus()) })
|
| 36 |
+
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
| 37 |
+
|
| 38 |
+
it('shows required processing as informational (no toggle, not "granted consent")', async () => {
|
| 39 |
+
renderCtl()
|
| 40 |
+
await screen.findByTestId('consent-service_essential')
|
| 41 |
+
expect(screen.getByTestId('required-state')).toHaveTextContent('Active')
|
| 42 |
+
expect(screen.getByText('Required')).toBeInTheDocument()
|
| 43 |
+
expect(screen.queryByTestId('consent-toggle-service_essential')).toBeNull()
|
| 44 |
+
// required processing must not be labelled as granted consent
|
| 45 |
+
expect(screen.getByTestId('consent-service_essential').textContent).not.toMatch(/granted/i)
|
| 46 |
+
})
|
| 47 |
+
|
| 48 |
+
it('optional purposes default to not-granted (opt-in)', async () => {
|
| 49 |
+
renderCtl()
|
| 50 |
+
expect(await screen.findByTestId('consent-state-optional_personalization')).toHaveTextContent('Not granted')
|
| 51 |
+
expect(screen.getByTestId('consent-state-optional_federation')).toHaveTextContent('Not granted')
|
| 52 |
+
})
|
| 53 |
+
|
| 54 |
+
it('granting personalization sends expected_version and reflects the new state', async () => {
|
| 55 |
+
vi.spyOn(ai, 'setConsent').mockResolvedValue(mkStatus({ optional_personalization: { status: 'granted', version: 1 } }))
|
| 56 |
+
renderCtl()
|
| 57 |
+
fireEvent.click(await screen.findByTestId('consent-toggle-optional_personalization'))
|
| 58 |
+
await waitFor(() => expect(ai.setConsent).toHaveBeenCalledWith('optional_personalization', true, 3))
|
| 59 |
+
expect(await screen.findByTestId('consent-state-optional_personalization')).toHaveTextContent('Granted')
|
| 60 |
+
expect(screen.getByTestId('pwsl')).toBeInTheDocument() // personalization without shared learning
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
it('personalization and federation are independent toggles', async () => {
|
| 64 |
+
vi.spyOn(ai, 'setConsent').mockResolvedValue(mkStatus({ optional_federation: { status: 'granted', version: 1 } }))
|
| 65 |
+
renderCtl()
|
| 66 |
+
fireEvent.click(await screen.findByTestId('consent-toggle-optional_federation'))
|
| 67 |
+
await waitFor(() => expect(ai.setConsent).toHaveBeenCalledWith('optional_federation', true, 3))
|
| 68 |
+
// federation granted, personalization still not granted
|
| 69 |
+
expect(screen.getByTestId('consent-state-optional_personalization')).toHaveTextContent('Not granted')
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
it('on a 409 conflict, refetches and shows a conflict notice', async () => {
|
| 73 |
+
vi.spyOn(ai, 'setConsent').mockRejectedValue(new ApiRequestError({ status: 409, message: 'version_conflict' }))
|
| 74 |
+
const refetch = vi.spyOn(ai, 'profileStatus')
|
| 75 |
+
renderCtl()
|
| 76 |
+
fireEvent.click(await screen.findByTestId('consent-toggle-optional_personalization'))
|
| 77 |
+
await waitFor(() => expect(screen.getByText(/changed elsewhere/i)).toBeInTheDocument())
|
| 78 |
+
expect(refetch.mock.calls.length).toBeGreaterThanOrEqual(2) // initial + refetch after conflict
|
| 79 |
+
})
|
| 80 |
+
|
| 81 |
+
it('does not delete when the confirm dialog is cancelled', async () => {
|
| 82 |
+
const confirmMock = vi.fn(() => false)
|
| 83 |
+
vi.stubGlobal('confirm', confirmMock)
|
| 84 |
+
const del = vi.spyOn(ai, 'deleteProfile')
|
| 85 |
+
renderCtl()
|
| 86 |
+
fireEvent.click(await screen.findByTestId('consent-delete'))
|
| 87 |
+
await waitFor(() => expect(confirmMock).toHaveBeenCalled())
|
| 88 |
+
expect(del).not.toHaveBeenCalled()
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
it('deletes eligible data and shows deleted/retained categories', async () => {
|
| 92 |
+
vi.stubGlobal('confirm', vi.fn(() => true))
|
| 93 |
+
vi.spyOn(ai, 'deleteProfile').mockResolvedValue({
|
| 94 |
+
subject: 'alice', profile: 'deleted', deleted_now: true,
|
| 95 |
+
deleted_categories: ['derived_behavioural_features', 'optional_personalization_state'],
|
| 96 |
+
retained_categories: ['required_security_processing_records', 'minimal_deletion_tombstone'],
|
| 97 |
+
retention_reason: 'required', retired_generation: 1, audit_reference: 'abc123',
|
| 98 |
+
erasure_type: 'eligible_scope_deletion', affects_payment: false, note: 'eligible-scope',
|
| 99 |
+
})
|
| 100 |
+
renderCtl()
|
| 101 |
+
fireEvent.click(await screen.findByTestId('consent-delete'))
|
| 102 |
+
await waitFor(() => expect(ai.deleteProfile).toHaveBeenCalled())
|
| 103 |
+
const receipt = await screen.findByTestId('deletion-receipt')
|
| 104 |
+
expect(receipt).toHaveTextContent(/derived_behavioural_features/)
|
| 105 |
+
expect(receipt).toHaveTextContent(/required_security_processing_records/)
|
| 106 |
+
expect(receipt).toHaveTextContent(/not perfect erasure or machine unlearning/i)
|
| 107 |
+
})
|
| 108 |
+
})
|
web/src/components/ConsentControls.tsx
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { useI18n } from '../i18n'
|
| 3 |
+
import { Callout, Spinner } from './ui'
|
| 4 |
+
import { ApiRequestError } from '../api/client'
|
| 5 |
+
import {
|
| 6 |
+
deleteProfile, profileStatus, setConsent,
|
| 7 |
+
type ConsentPurpose, type DeleteProfileResult, type ProfileStatus,
|
| 8 |
+
} from '../api/ai'
|
| 9 |
+
|
| 10 |
+
/** Customer privacy/consent controls (PR C4). Advisory only — these choices never approve, deny or
|
| 11 |
+
* change a payment. Required security processing is shown as informational (not a consent toggle).
|
| 12 |
+
* Identity is the authenticated principal (session token); no user id is sent. */
|
| 13 |
+
export function ConsentControls() {
|
| 14 |
+
const { t } = useI18n()
|
| 15 |
+
const [status, setStatus] = useState<ProfileStatus | null>(null)
|
| 16 |
+
const [loading, setLoading] = useState(true)
|
| 17 |
+
const [err, setErr] = useState(false)
|
| 18 |
+
const [busy, setBusy] = useState<ConsentPurpose | 'delete' | null>(null)
|
| 19 |
+
const [notice, setNotice] = useState<{ tone: 'ok' | 'warn'; msg: string } | null>(null)
|
| 20 |
+
const [deleted, setDeleted] = useState<DeleteProfileResult | null>(null)
|
| 21 |
+
|
| 22 |
+
async function refresh(signal?: AbortSignal) {
|
| 23 |
+
setLoading(true)
|
| 24 |
+
try {
|
| 25 |
+
setStatus(await profileStatus(signal))
|
| 26 |
+
} catch {
|
| 27 |
+
setErr(true)
|
| 28 |
+
} finally {
|
| 29 |
+
setLoading(false)
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
useEffect(() => {
|
| 34 |
+
const c = new AbortController()
|
| 35 |
+
refresh(c.signal)
|
| 36 |
+
return () => c.abort()
|
| 37 |
+
}, [])
|
| 38 |
+
|
| 39 |
+
async function toggle(purpose: ConsentPurpose, grant: boolean) {
|
| 40 |
+
if (!status) return
|
| 41 |
+
setBusy(purpose); setNotice(null)
|
| 42 |
+
try {
|
| 43 |
+
const next = await setConsent(purpose, grant, status.record_version)
|
| 44 |
+
setStatus(next); setNotice({ tone: 'ok', msg: t('consent.saved') })
|
| 45 |
+
} catch (e) {
|
| 46 |
+
if (e instanceof ApiRequestError && e.status === 409) {
|
| 47 |
+
await refresh(); setNotice({ tone: 'warn', msg: t('consent.conflict') })
|
| 48 |
+
} else {
|
| 49 |
+
setErr(true)
|
| 50 |
+
}
|
| 51 |
+
} finally {
|
| 52 |
+
setBusy(null)
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
async function onDelete() {
|
| 57 |
+
if (!window.confirm(t('consent.delete.confirm'))) return
|
| 58 |
+
setBusy('delete'); setNotice(null)
|
| 59 |
+
try {
|
| 60 |
+
const res = await deleteProfile()
|
| 61 |
+
setDeleted(res)
|
| 62 |
+
await refresh()
|
| 63 |
+
setNotice({ tone: 'ok', msg: t('consent.delete.done') })
|
| 64 |
+
} catch {
|
| 65 |
+
setErr(true)
|
| 66 |
+
} finally {
|
| 67 |
+
setBusy(null)
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
if (loading && !status) return <Spinner label={t('common.loading')} />
|
| 72 |
+
if (err && !status) return <Callout tone="warn">{t('home.security.unavailable')}</Callout>
|
| 73 |
+
if (!status) return null
|
| 74 |
+
|
| 75 |
+
const optionalRow = (purpose: ConsentPurpose, titleKey: string, explainKey: string) => {
|
| 76 |
+
const meta = status.purposes[purpose]
|
| 77 |
+
const granted = meta.status === 'granted'
|
| 78 |
+
return (
|
| 79 |
+
<div className="consent-row" data-testid={`consent-${purpose}`}>
|
| 80 |
+
<div className="consent-row-text">
|
| 81 |
+
<strong>{t(titleKey as never)}</strong>
|
| 82 |
+
<p className="muted small">{t(explainKey as never)}</p>
|
| 83 |
+
</div>
|
| 84 |
+
<div className="consent-row-control">
|
| 85 |
+
<span className={`badge ${granted ? 'tone-ok' : 'tone-info'}`} data-testid={`consent-state-${purpose}`}>
|
| 86 |
+
{granted ? t('consent.on') : t('consent.off')}
|
| 87 |
+
</span>
|
| 88 |
+
<button className="btn" disabled={busy === purpose}
|
| 89 |
+
onClick={() => toggle(purpose, !granted)}
|
| 90 |
+
data-testid={`consent-toggle-${purpose}`}>
|
| 91 |
+
{granted ? t('consent.withdraw') : t('consent.grant')}
|
| 92 |
+
</button>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
)
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
return (
|
| 99 |
+
<div className="consent-controls">
|
| 100 |
+
{/* Required security processing — informational, NOT a consent toggle, not "granted". */}
|
| 101 |
+
<div className="consent-row" data-testid="consent-service_essential">
|
| 102 |
+
<div className="consent-row-text">
|
| 103 |
+
<strong>{t('required.title')} <span className="badge tone-warn">{t('required.badge')}</span></strong>
|
| 104 |
+
<p className="muted small">{t('required.explain')}</p>
|
| 105 |
+
</div>
|
| 106 |
+
<span className="badge tone-neutral" data-testid="required-state">{t('required.state')}</span>
|
| 107 |
+
</div>
|
| 108 |
+
|
| 109 |
+
{optionalRow('optional_personalization', 'consent.personalization.title', 'consent.personalization.explain')}
|
| 110 |
+
{optionalRow('optional_federation', 'consent.federation.title', 'consent.federation.explain')}
|
| 111 |
+
|
| 112 |
+
{status.personalization_without_shared_learning && (
|
| 113 |
+
<Callout tone="info"><span data-testid="pwsl">{t('consent.pwsl')}</span></Callout>
|
| 114 |
+
)}
|
| 115 |
+
{notice && <Callout tone={notice.tone}>{notice.msg}</Callout>}
|
| 116 |
+
|
| 117 |
+
<div className="consent-delete">
|
| 118 |
+
<strong>{t('consent.delete.title')}</strong>
|
| 119 |
+
<p className="muted small">{t('consent.delete.explain')}</p>
|
| 120 |
+
<button className="btn danger" disabled={busy === 'delete'}
|
| 121 |
+
onClick={onDelete} data-testid="consent-delete">
|
| 122 |
+
{t('consent.delete.button')}
|
| 123 |
+
</button>
|
| 124 |
+
{deleted && (
|
| 125 |
+
<div className="deletion-receipt" data-testid="deletion-receipt">
|
| 126 |
+
<p className="small"><strong>{t('consent.delete.deletedLabel')}:</strong> {deleted.deleted_categories.join(', ')}</p>
|
| 127 |
+
<p className="small"><strong>{t('consent.delete.retainedLabel')}:</strong> {deleted.retained_categories.join(', ')}</p>
|
| 128 |
+
<p className="muted small">{t('consent.delete.notUnlearning')}</p>
|
| 129 |
+
</div>
|
| 130 |
+
)}
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
)
|
| 134 |
+
}
|
web/src/components/FederatedStatusCard.test.tsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
| 2 |
+
import { render, screen } from '@testing-library/react'
|
| 3 |
+
import { I18nProvider } from '../i18n'
|
| 4 |
+
import { FederatedStatusCard } from './FederatedStatusCard'
|
| 5 |
+
import * as ai from '../api/ai'
|
| 6 |
+
|
| 7 |
+
vi.mock('../api/ai')
|
| 8 |
+
|
| 9 |
+
const status: ai.FederatedStatus = {
|
| 10 |
+
client_type: 'SYNTHETIC_SERVER_SIMULATION',
|
| 11 |
+
real_device_training: 'NOT_IMPLEMENTED',
|
| 12 |
+
native_local_only_training: 'NOT_IMPLEMENTED',
|
| 13 |
+
real_user_data_used: false,
|
| 14 |
+
federation: 'SIMULATED',
|
| 15 |
+
secure_aggregation: 'DESIGN_ONLY',
|
| 16 |
+
differential_privacy: 'SIMULATED',
|
| 17 |
+
differential_privacy_detail: {
|
| 18 |
+
differential_privacy: 'SIMULATED', budget_exhausted: true, candidate_privacy_eligibility: 'ineligible',
|
| 19 |
+
cumulative_epsilon: 38.76, budget_epsilon: 8, production_privacy_guarantee: false, note: 'simulated only',
|
| 20 |
+
},
|
| 21 |
+
automatic_model_promotion: false, affects_payment_authorization: false,
|
| 22 |
+
candidate_status: 'candidate', candidate_promotable: false,
|
| 23 |
+
disclaimer: 'Server-side simulation using synthetic clients; not real cross-device federated learning.',
|
| 24 |
+
label: 'SIMULATED', aggregators: ['coordinate_median', 'fedavg', 'trimmed_mean'],
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const renderCard = (detailed = false) =>
|
| 28 |
+
render(<I18nProvider initial="en"><FederatedStatusCard detailed={detailed} /></I18nProvider>)
|
| 29 |
+
|
| 30 |
+
describe('FederatedStatusCard', () => {
|
| 31 |
+
beforeEach(() => { vi.spyOn(ai, 'federatedStatus').mockResolvedValue(status) })
|
| 32 |
+
afterEach(() => vi.restoreAllMocks())
|
| 33 |
+
|
| 34 |
+
it('reports synthetic server simulation + design-only + not-implemented labels', async () => {
|
| 35 |
+
renderCard()
|
| 36 |
+
expect(await screen.findByTestId('fed-label-client_type')).toHaveTextContent('SYNTHETIC_SERVER_SIMULATION')
|
| 37 |
+
expect(screen.getByTestId('fed-label-secure_aggregation')).toHaveTextContent('DESIGN_ONLY')
|
| 38 |
+
expect(screen.getByTestId('fed-label-real_device_training')).toHaveTextContent('NOT_IMPLEMENTED')
|
| 39 |
+
expect(screen.getByTestId('fed-label-native_local_only_training')).toHaveTextContent('NOT_IMPLEMENTED')
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
it('surfaces the exhausted simulated DP budget as a warning', async () => {
|
| 43 |
+
renderCard()
|
| 44 |
+
expect(await screen.findByTestId('dp-exhausted')).toHaveTextContent(/budget is exhausted/i)
|
| 45 |
+
expect(screen.getByTestId('dp-exhausted')).toHaveTextContent(/not a production privacy guarantee/i)
|
| 46 |
+
})
|
| 47 |
+
|
| 48 |
+
it('never claims data stays on the device', async () => {
|
| 49 |
+
const { container } = renderCard(true)
|
| 50 |
+
await screen.findByTestId('federated-status')
|
| 51 |
+
expect(container.textContent).not.toMatch(/stays (only )?on your (phone|device)/i)
|
| 52 |
+
expect(container.textContent).not.toMatch(/never leaves your device/i)
|
| 53 |
+
})
|
| 54 |
+
|
| 55 |
+
it('detailed mode states the candidate is not promotable and clients are synthetic', async () => {
|
| 56 |
+
renderCard(true)
|
| 57 |
+
expect(await screen.findByTestId('fed-not-promotable')).toHaveTextContent(/not promotable/i)
|
| 58 |
+
expect(screen.getByTestId('fed-synthetic')).toHaveTextContent(/no real user data is used/i)
|
| 59 |
+
})
|
| 60 |
+
})
|
web/src/components/FederatedStatusCard.tsx
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { useI18n } from '../i18n'
|
| 3 |
+
import { Callout, Spinner } from './ui'
|
| 4 |
+
import { federatedStatus, type FederatedStatus } from '../api/ai'
|
| 5 |
+
|
| 6 |
+
/** Honest, non-authoritative view of the SIMULATED federated-learning demo (PR C3/C4).
|
| 7 |
+
* Reports synthetic-server-simulation status, secure-aggregation DESIGN_ONLY, DP SIMULATED with an
|
| 8 |
+
* exhausted-budget warning, and no payment authority. Never claims "data stays on your device". */
|
| 9 |
+
export function FederatedStatusCard({ detailed = false }: { detailed?: boolean }) {
|
| 10 |
+
const { t } = useI18n()
|
| 11 |
+
const [status, setStatus] = useState<FederatedStatus | null>(null)
|
| 12 |
+
const [loading, setLoading] = useState(true)
|
| 13 |
+
const [err, setErr] = useState(false)
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
const c = new AbortController()
|
| 17 |
+
federatedStatus(c.signal)
|
| 18 |
+
.then(setStatus)
|
| 19 |
+
.catch(() => setErr(true))
|
| 20 |
+
.finally(() => setLoading(false))
|
| 21 |
+
return () => c.abort()
|
| 22 |
+
}, [])
|
| 23 |
+
|
| 24 |
+
if (loading) return <Spinner label={t('common.loading')} />
|
| 25 |
+
if (err || !status) return <Callout tone="warn">{t('fed.unavailable')}</Callout>
|
| 26 |
+
|
| 27 |
+
const dp = status.differential_privacy_detail
|
| 28 |
+
const rows: [string, string][] = [
|
| 29 |
+
['client_type', status.client_type],
|
| 30 |
+
['federation', status.federation],
|
| 31 |
+
['secure_aggregation', status.secure_aggregation],
|
| 32 |
+
['differential_privacy', status.differential_privacy],
|
| 33 |
+
['real_device_training', status.real_device_training],
|
| 34 |
+
['native_local_only_training', status.native_local_only_training],
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<div className="federated-status" data-testid="federated-status">
|
| 39 |
+
<ul className="status-list">
|
| 40 |
+
{rows.map(([key, value]) => (
|
| 41 |
+
<li key={key}>
|
| 42 |
+
<span>{t(`federated.label.${key}` as never)}</span>
|
| 43 |
+
<span className="badge tone-info mono" data-testid={`fed-label-${key}`}>{value}</span>
|
| 44 |
+
</li>
|
| 45 |
+
))}
|
| 46 |
+
</ul>
|
| 47 |
+
|
| 48 |
+
{dp?.budget_exhausted && (
|
| 49 |
+
<Callout tone="warn">
|
| 50 |
+
<span data-testid="dp-exhausted">{t('fed.dp.exhausted')}</span>
|
| 51 |
+
</Callout>
|
| 52 |
+
)}
|
| 53 |
+
|
| 54 |
+
<p className="muted small" data-testid="fed-no-authority">{t('federated.noAuthority')}</p>
|
| 55 |
+
|
| 56 |
+
{detailed && (
|
| 57 |
+
<>
|
| 58 |
+
<p className="muted small" data-testid="fed-synthetic">{t('fed.syntheticClients')}</p>
|
| 59 |
+
{status.aggregators && status.aggregators.length > 0 && (
|
| 60 |
+
<p className="muted small">{t('fed.aggregators')}: <span className="mono">{status.aggregators.join(', ')}</span></p>
|
| 61 |
+
)}
|
| 62 |
+
<p className="muted small">{t('fed.candidate')}: <span className="mono">{status.candidate_status}</span>
|
| 63 |
+
{' · '}<span data-testid="fed-not-promotable">{t('fed.notPromotable')}</span></p>
|
| 64 |
+
<p className="muted small">{status.disclaimer}</p>
|
| 65 |
+
</>
|
| 66 |
+
)}
|
| 67 |
+
</div>
|
| 68 |
+
)
|
| 69 |
+
}
|
web/src/i18n/ar.ts
CHANGED
|
@@ -292,6 +292,51 @@ export const ar: Record<MessageKey, string> = {
|
|
| 292 |
'security.oob.title': 'التأكيد خارج القناة',
|
| 293 |
'security.oob.explain': 'قد تتطلب المدفوعات عالية الخطورة تأكيدًا منفصلًا عبر قناتك المفضلة.',
|
| 294 |
'security.activity.title': 'نشاط الأمان',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
'shadow.badge': 'وضع الظل',
|
| 296 |
'shadow.advisory': 'استشاري فقط',
|
| 297 |
'shadow.noChange': 'لا يغيّر تفويض الدفع',
|
|
|
|
| 292 |
'security.oob.title': 'التأكيد خارج القناة',
|
| 293 |
'security.oob.explain': 'قد تتطلب المدفوعات عالية الخطورة تأكيدًا منفصلًا عبر قناتك المفضلة.',
|
| 294 |
'security.activity.title': 'نشاط الأمان',
|
| 295 |
+
'security.privacy.title': 'الخصوصية والموافقة',
|
| 296 |
+
'security.privacy.explain': 'أنت تتحكم في خيارات التخصيص الاختيارية والتعلّم المشترك. هذه الخيارات لا توافق على أي دفعة أو ترفضها أو تغيّرها. معالجة الأمان المطلوبة منفصلة وتُطبَّق دائمًا.',
|
| 297 |
+
'security.privacy.signInFirst': 'سجّل الدخول لإدارة خيارات التخصيص والخصوصية الخاصة بك.',
|
| 298 |
+
'required.title': 'معالجة الأمان المطلوبة',
|
| 299 |
+
'required.badge': 'مطلوب',
|
| 300 |
+
'required.state': 'مُفعَّلة',
|
| 301 |
+
'required.explain': 'تُستخدم معالجة الأمان المطلوبة لحماية المدفوعات والحفاظ على سلامة الخدمة. وهي منفصلة عن التعلّم الاختياري ولا يمكن إيقافها من هنا.',
|
| 302 |
+
'consent.personalization.title': 'التخصيص الاختياري',
|
| 303 |
+
'consent.personalization.explain': 'اسمح للنموذج التجريبي في وضع الظل بتخصيص الفحوصات من أنماط دفعك. مُعطَّل افتراضيًا. إيقافه يوقف التخصيص — ولا يُضعف أي قرار أمني.',
|
| 304 |
+
'consent.federation.title': 'محاكاة التعلّم المشترك الاختيارية',
|
| 305 |
+
'consent.federation.explain': 'المشاركة في عرض تعلّم مشترك (موحّد) مُحاكى. مُعطَّل افتراضيًا. هذه محاكاة على الخادم تستخدم عملاء اصطناعيين؛ وهي منفصلة عن التخصيص ولا يُفعّلها أبدًا.',
|
| 306 |
+
'consent.pwsl': 'تخصيص بدون تعلّم مشترك: التخصيص مُفعَّل ومحاكاة التعلّم المشترك مُعطَّلة.',
|
| 307 |
+
'consent.on': 'مُفعَّل',
|
| 308 |
+
'consent.off': 'غير مُفعَّل',
|
| 309 |
+
'consent.grant': 'تفعيل',
|
| 310 |
+
'consent.withdraw': 'إيقاف',
|
| 311 |
+
'consent.saved': 'تم حفظ اختيارك.',
|
| 312 |
+
'consent.conflict': 'تم تغيير هذا في مكان آخر. حدّثنا إلى أحدث حالة — يُرجى المحاولة مرة أخرى.',
|
| 313 |
+
'consent.delete.title': 'حذف ملفي السلوكي',
|
| 314 |
+
'consent.delete.explain': 'يحذف البيانات السلوكية المشتقة والتعلّم الاختياري المؤهّلة للحذف. تُحتفظ سجلات الدفع/الأمان المطلوبة مع سجل حذف بسيط. لا يُحذف سجل مدفوعاتك.',
|
| 315 |
+
'consent.delete.button': 'حذف الملف السلوكي',
|
| 316 |
+
'consent.delete.confirm': 'هل تريد حذف بياناتك السلوكية والتعلّم الاختياري المؤهّلة؟ تُحتفظ سجلات الأمان المطلوبة. لا يمكن التراجع.',
|
| 317 |
+
'consent.delete.done': 'تم حذف بيانات ملفك السلوكي المؤهّلة للحذف.',
|
| 318 |
+
'consent.delete.deletedLabel': 'محذوف',
|
| 319 |
+
'consent.delete.retainedLabel': 'مُحتفظ به (مطلوب)',
|
| 320 |
+
'consent.delete.notUnlearning': 'هذا حذف ضمن النطاق المؤهّل، وليس محوًا تامًّا أو إلغاء تعلّم آلي.',
|
| 321 |
+
'security.federated.title': 'التعلّم المشترك (مُحاكى)',
|
| 322 |
+
'security.federated.explain': 'نموذج بحثي أوّلي مُدرَّب على عملاء اصطناعيين على الخادم. لا يعمل على جهازك ولا يؤثر في دفعاتك.',
|
| 323 |
+
'federated.label.client_type': 'نوع العميل',
|
| 324 |
+
'federated.label.federation': 'التعلّم الموحّد',
|
| 325 |
+
'federated.label.secure_aggregation': 'التجميع الآمن',
|
| 326 |
+
'federated.label.differential_privacy': 'الخصوصية التفاضلية',
|
| 327 |
+
'federated.label.real_device_training': 'تدريب حقيقي على الجهاز',
|
| 328 |
+
'federated.label.native_local_only_training': 'تدريب محلي على الجهاز فقط',
|
| 329 |
+
'federated.noAuthority': 'لا سلطة على الدفع · لا ترقية تلقائية · محاكاة اصطناعية على الخادم',
|
| 330 |
+
'fed.dp.exhausted': 'ميزانية الخصوصية التفاضلية المُحاكاة مُستنفدة. هذا ليس ضمانًا للخصوصية في الإنتاج والنموذج المرشّح غير مؤهّل للخصوصية.',
|
| 331 |
+
'fed.syntheticClients': 'العملاء اصطناعيون وعلى الخادم؛ لا تُستخدم أي بيانات مستخدم حقيقية.',
|
| 332 |
+
'fed.notPromotable': 'غير قابل للترقية',
|
| 333 |
+
'nav.federatedDemo': 'عرض التعلّم المشترك',
|
| 334 |
+
'fed.title': 'عرض التعلّم المشترك (مُحاكى)',
|
| 335 |
+
'fed.intro': 'تشرح هذه الصفحة ن��وذجًا بحثيًا أوّليًا. التعلّم المشترك هنا محاكاة على الخادم عبر عملاء اصطناعيين — وليس تدريبًا حقيقيًا عبر الأجهزة ولا يؤثر في دفعاتك.',
|
| 336 |
+
'fed.status.title': 'الحالة الصادقة',
|
| 337 |
+
'fed.aggregators': 'أدوات التجميع',
|
| 338 |
+
'fed.candidate': 'النموذج المرشّح',
|
| 339 |
+
'fed.unavailable': 'حالة التعلّم المشترك غير متاحة حاليًا.',
|
| 340 |
'shadow.badge': 'وضع الظل',
|
| 341 |
'shadow.advisory': 'استشاري فقط',
|
| 342 |
'shadow.noChange': 'لا يغيّر تفويض الدفع',
|
web/src/i18n/en.ts
CHANGED
|
@@ -290,6 +290,51 @@ export const en = {
|
|
| 290 |
'security.oob.title': 'Out-of-band confirmation',
|
| 291 |
'security.oob.explain': 'High-risk payments can require a separate confirmation on your preferred channel.',
|
| 292 |
'security.activity.title': 'Security activity',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
'shadow.badge': 'Shadow mode',
|
| 294 |
'shadow.advisory': 'Advisory only',
|
| 295 |
'shadow.noChange': 'Does not change payment authorization',
|
|
|
|
| 290 |
'security.oob.title': 'Out-of-band confirmation',
|
| 291 |
'security.oob.explain': 'High-risk payments can require a separate confirmation on your preferred channel.',
|
| 292 |
'security.activity.title': 'Security activity',
|
| 293 |
+
'security.privacy.title': 'Privacy & consent',
|
| 294 |
+
'security.privacy.explain': 'You control optional personalization and shared-learning choices. These choices never approve, deny or change a payment. Required security processing is separate and always applies.',
|
| 295 |
+
'security.privacy.signInFirst': 'Sign in to manage your personalization and privacy choices.',
|
| 296 |
+
'required.title': 'Required security processing',
|
| 297 |
+
'required.badge': 'Required',
|
| 298 |
+
'required.state': 'Active',
|
| 299 |
+
'required.explain': 'Required security processing is used to protect payments and maintain service integrity. It is separate from optional learning and cannot be turned off here.',
|
| 300 |
+
'consent.personalization.title': 'Optional personalization',
|
| 301 |
+
'consent.personalization.explain': 'Let the experimental shadow model personalize checks from your payment patterns. Off by default. Turning it off stops personalization — it never weakens a security decision.',
|
| 302 |
+
'consent.federation.title': 'Optional shared-learning simulation',
|
| 303 |
+
'consent.federation.explain': 'Take part in a simulated shared-learning (federated) demo. Off by default. This is a server-side simulation using synthetic clients; it is separate from personalization and never enabled by it.',
|
| 304 |
+
'consent.pwsl': 'Personalization without shared learning: personalization is on and the shared-learning simulation is off.',
|
| 305 |
+
'consent.on': 'Granted',
|
| 306 |
+
'consent.off': 'Not granted',
|
| 307 |
+
'consent.grant': 'Turn on',
|
| 308 |
+
'consent.withdraw': 'Turn off',
|
| 309 |
+
'consent.saved': 'Your choice was saved.',
|
| 310 |
+
'consent.conflict': 'This was changed elsewhere. We refreshed to the latest state — please try again.',
|
| 311 |
+
'consent.delete.title': 'Delete my behavioural profile',
|
| 312 |
+
'consent.delete.explain': 'Deletes eligible derived behavioural and optional-learning data. Required payment/security records are kept, along with a minimal deletion record. Your payment history is not deleted.',
|
| 313 |
+
'consent.delete.button': 'Delete behavioural profile',
|
| 314 |
+
'consent.delete.confirm': 'Delete your eligible behavioural and optional-learning data? Required security records are kept. This cannot be undone.',
|
| 315 |
+
'consent.delete.done': 'Your eligible behavioural profile data was deleted.',
|
| 316 |
+
'consent.delete.deletedLabel': 'Deleted',
|
| 317 |
+
'consent.delete.retainedLabel': 'Retained (required)',
|
| 318 |
+
'consent.delete.notUnlearning': 'This is eligible-scope deletion, not perfect erasure or machine unlearning.',
|
| 319 |
+
'security.federated.title': 'Shared learning (simulated)',
|
| 320 |
+
'security.federated.explain': 'A research prototype trained on synthetic server-side clients. It does not run on your device and does not affect your payments.',
|
| 321 |
+
'federated.label.client_type': 'Client type',
|
| 322 |
+
'federated.label.federation': 'Federation',
|
| 323 |
+
'federated.label.secure_aggregation': 'Secure aggregation',
|
| 324 |
+
'federated.label.differential_privacy': 'Differential privacy',
|
| 325 |
+
'federated.label.real_device_training': 'Real device training',
|
| 326 |
+
'federated.label.native_local_only_training': 'On-device local-only training',
|
| 327 |
+
'federated.noAuthority': 'No payment authority · not auto-promoted · synthetic server-side simulation',
|
| 328 |
+
'fed.dp.exhausted': 'Simulated differential-privacy budget is exhausted. This is not a production privacy guarantee and the candidate is not privacy-eligible.',
|
| 329 |
+
'fed.syntheticClients': 'Clients are synthetic and server-side; no real user data is used.',
|
| 330 |
+
'fed.notPromotable': 'not promotable',
|
| 331 |
+
'nav.federatedDemo': 'Shared-learning demo',
|
| 332 |
+
'fed.title': 'Shared-learning demo (simulated)',
|
| 333 |
+
'fed.intro': 'This page explains a research prototype. Shared learning here is a server-side simulation over synthetic clients — it is not real cross-device training and has no effect on your payments.',
|
| 334 |
+
'fed.status.title': 'Honest status',
|
| 335 |
+
'fed.aggregators': 'Aggregators',
|
| 336 |
+
'fed.candidate': 'Model candidate',
|
| 337 |
+
'fed.unavailable': 'Shared-learning status is unavailable right now.',
|
| 338 |
'shadow.badge': 'Shadow mode',
|
| 339 |
'shadow.advisory': 'Advisory only',
|
| 340 |
'shadow.noChange': 'Does not change payment authorization',
|
web/src/pages/FederatedDemoPage.tsx
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useI18n } from '../i18n'
|
| 2 |
+
import { ShadowBadges } from '../components/ShadowBadge'
|
| 3 |
+
import { FederatedStatusCard } from '../components/FederatedStatusCard'
|
| 4 |
+
|
| 5 |
+
/** Labelled federated-learning demo (PR C4). Explains the C3 SIMULATION honestly — it is a
|
| 6 |
+
* server-side simulation over synthetic clients, not real cross-device training, and has no
|
| 7 |
+
* effect on payments. Route: #/demo/federated-learning. */
|
| 8 |
+
export function FederatedDemoPage() {
|
| 9 |
+
const { t } = useI18n()
|
| 10 |
+
return (
|
| 11 |
+
<section className="page federated-demo" aria-labelledby="fed-h">
|
| 12 |
+
<h2 id="fed-h">{t('fed.title')}</h2>
|
| 13 |
+
<ShadowBadges />
|
| 14 |
+
<div className="card">
|
| 15 |
+
<p>{t('fed.intro')}</p>
|
| 16 |
+
</div>
|
| 17 |
+
<div className="card">
|
| 18 |
+
<h3>{t('fed.status.title')}</h3>
|
| 19 |
+
<FederatedStatusCard detailed />
|
| 20 |
+
</div>
|
| 21 |
+
</section>
|
| 22 |
+
)
|
| 23 |
+
}
|
web/src/pages/SecurityPage.test.tsx
CHANGED
|
@@ -16,6 +16,17 @@ describe('SecurityPage', () => {
|
|
| 16 |
reason_codes_version: 'reason_codes-v1', shadow_only: true, affects_payment: false,
|
| 17 |
label: 'DEMO ONLY',
|
| 18 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
})
|
| 20 |
afterEach(() => vi.restoreAllMocks())
|
| 21 |
|
|
|
|
| 16 |
reason_codes_version: 'reason_codes-v1', shadow_only: true, affects_payment: false,
|
| 17 |
label: 'DEMO ONLY',
|
| 18 |
})
|
| 19 |
+
// C4 additions: the Security page now also renders a federated-status card.
|
| 20 |
+
vi.spyOn(ai, 'federatedStatus').mockResolvedValue({
|
| 21 |
+
client_type: 'SYNTHETIC_SERVER_SIMULATION', real_device_training: 'NOT_IMPLEMENTED',
|
| 22 |
+
native_local_only_training: 'NOT_IMPLEMENTED', real_user_data_used: false,
|
| 23 |
+
federation: 'SIMULATED', secure_aggregation: 'DESIGN_ONLY', differential_privacy: 'SIMULATED',
|
| 24 |
+
differential_privacy_detail: { differential_privacy: 'SIMULATED', budget_exhausted: true,
|
| 25 |
+
candidate_privacy_eligibility: 'ineligible', production_privacy_guarantee: false, note: 'x' },
|
| 26 |
+
automatic_model_promotion: false, affects_payment_authorization: false,
|
| 27 |
+
candidate_status: 'candidate', candidate_promotable: false,
|
| 28 |
+
disclaimer: 'Server-side simulation using synthetic clients.', label: 'SIMULATED',
|
| 29 |
+
})
|
| 30 |
})
|
| 31 |
afterEach(() => vi.restoreAllMocks())
|
| 32 |
|
web/src/pages/SecurityPage.tsx
CHANGED
|
@@ -3,6 +3,8 @@ import { useI18n } from '../i18n'
|
|
| 3 |
import { useSession } from '../hooks/useSession'
|
| 4 |
import { Callout, Spinner } from '../components/ui'
|
| 5 |
import { ShadowBadges, ShadowFootnote } from '../components/ShadowBadge'
|
|
|
|
|
|
|
| 6 |
import { modelStatus, type ModelStatus } from '../api/ai'
|
| 7 |
|
| 8 |
/** Customer-facing security page: device authentication + behavioural-protection (shadow) status.
|
|
@@ -58,6 +60,23 @@ export function SecurityPage({ onNav }: { onNav: (r: string) => void }) {
|
|
| 58 |
)}
|
| 59 |
</div>
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
<div className="card">
|
| 62 |
<h3>{t('security.oob.title')}</h3>
|
| 63 |
<p className="muted">{t('security.oob.explain')}</p>
|
|
|
|
| 3 |
import { useSession } from '../hooks/useSession'
|
| 4 |
import { Callout, Spinner } from '../components/ui'
|
| 5 |
import { ShadowBadges, ShadowFootnote } from '../components/ShadowBadge'
|
| 6 |
+
import { ConsentControls } from '../components/ConsentControls'
|
| 7 |
+
import { FederatedStatusCard } from '../components/FederatedStatusCard'
|
| 8 |
import { modelStatus, type ModelStatus } from '../api/ai'
|
| 9 |
|
| 10 |
/** Customer-facing security page: device authentication + behavioural-protection (shadow) status.
|
|
|
|
| 60 |
)}
|
| 61 |
</div>
|
| 62 |
|
| 63 |
+
<div className="card" data-testid="privacy-card">
|
| 64 |
+
<h3>{t('security.privacy.title')}</h3>
|
| 65 |
+
<p className="muted">{t('security.privacy.explain')}</p>
|
| 66 |
+
{activeUser ? (
|
| 67 |
+
<ConsentControls />
|
| 68 |
+
) : (
|
| 69 |
+
<Callout tone="info">{t('security.privacy.signInFirst')}</Callout>
|
| 70 |
+
)}
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<div className="card" data-testid="federated-card">
|
| 74 |
+
<h3>{t('security.federated.title')}</h3>
|
| 75 |
+
<p className="muted">{t('security.federated.explain')}</p>
|
| 76 |
+
<FederatedStatusCard />
|
| 77 |
+
<button className="btn" onClick={() => onNav('demo/federated-learning')}>{t('nav.federatedDemo')}</button>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
<div className="card">
|
| 81 |
<h3>{t('security.oob.title')}</h3>
|
| 82 |
<p className="muted">{t('security.oob.explain')}</p>
|
web/src/styles.css
CHANGED
|
@@ -180,3 +180,17 @@ h2 { margin: 0 0 12px; font-size: 18px; }
|
|
| 180 |
.more-sheet { position:fixed; bottom:3.6rem; inset-inline:0; z-index:25; background:var(--bg,#fff); border-top:1px solid rgba(0,0,0,.12); display:flex; flex-direction:column; }
|
| 181 |
.more-item { padding:.9rem 1rem; text-align:start; background:none; border:none; border-bottom:1px solid rgba(0,0,0,.06); font:inherit; min-height:44px; cursor:pointer; }
|
| 182 |
@media (prefers-reduced-motion: reduce) { .spin { animation: none !important; } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
.more-sheet { position:fixed; bottom:3.6rem; inset-inline:0; z-index:25; background:var(--bg,#fff); border-top:1px solid rgba(0,0,0,.12); display:flex; flex-direction:column; }
|
| 181 |
.more-item { padding:.9rem 1rem; text-align:start; background:none; border:none; border-bottom:1px solid rgba(0,0,0,.06); font:inherit; min-height:44px; cursor:pointer; }
|
| 182 |
@media (prefers-reduced-motion: reduce) { .spin { animation: none !important; } }
|
| 183 |
+
|
| 184 |
+
/* PR C4 — consent controls + federated status */
|
| 185 |
+
.consent-row { display:flex; gap:1rem; align-items:flex-start; justify-content:space-between; padding:.7rem 0; border-bottom:1px solid rgba(0,0,0,.07); }
|
| 186 |
+
.consent-row:last-of-type { border-bottom:none; }
|
| 187 |
+
.consent-row-text { flex:1 1 auto; min-width:0; }
|
| 188 |
+
.consent-row-control { display:flex; flex-direction:column; align-items:flex-end; gap:.4rem; flex:0 0 auto; }
|
| 189 |
+
.consent-delete { margin-top:1rem; padding-top:.8rem; border-top:1px dashed rgba(0,0,0,.15); }
|
| 190 |
+
.btn.danger { border-color:#b42318; color:#b42318; }
|
| 191 |
+
.btn.danger:hover:not(:disabled) { background:#b42318; color:#fff; }
|
| 192 |
+
.federated-status .status-list { margin:.4rem 0; }
|
| 193 |
+
@media (max-width:520px){ .consent-row { flex-direction:column; } .consent-row-control { align-items:flex-start; } }
|
| 194 |
+
.badge.tone-neutral { background:rgba(0,0,0,.06); color:inherit; }
|
| 195 |
+
.deletion-receipt { margin-top:.6rem; padding:.5rem .6rem; border:1px solid rgba(0,0,0,.1); border-radius:.5rem; }
|
| 196 |
+
.deletion-receipt p { margin:.15rem 0; word-break:break-word; }
|