MHamdan commited on
Commit
0f4d9ea
·
verified ·
1 Parent(s): 5c7c79a

CI deploy d3d1cf1

Browse files
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":"f7e2045","build_time":"2026-07-14T00:53:35Z","frontend":"1.0.0"}
 
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; }