Lab 01 · Continuous risk identification
risk.engineer · Risk engineering lifecycle, step 1
A security tool produces events. A business system supplies context. Neither produces a risk statement by itself.
In this lab, you will run a repeatable data pipeline that turns 49,200 vendor-shaped mock records into a small set of cited candidate risk statements:
Okta + Zscaler → validation → OCSF → business context → bounded clusters → agent investigation → human review
The pipeline identifies plausible scenarios. It does not score likelihood, rate impact, prioritize, recommend treatment, or accept risk.
Time: 45–60 minutes · Cost: $0 in replay mode; variable in live mode · Prerequisites: basic Python/JSON and either Python 3.12 or Docker
Learning objectives
- Land and receipt multiple changing sources without losing provenance.
- Map vendor fields to validated OCSF events while keeping business context separate.
- Use an agent to investigate every bounded cluster and abstain when the evidence is incomplete, contradictory, or ambiguous.
- Validate citations and measure the workflow against known synthetic cases.
Expected output: a visual candidate-risk register, an evidence graph, batch history, the full agent tool trace, and a machine-readable evaluation scorecard.
Show code · cell 2
import jsonfrom pathlib import Pathimport duckdbfrom IPython.display import display, Markdownfrom fixture_factory import generatefrom ingestion import ingestfrom pipeline import build_warehouse, correlatefrom engine import run, VERSIONfrom evaluation import evaluatefrom visuals import show_volume, show_funnel, show_evidence, show_investigation_board, show_register, show_history
ROOT = Path.cwd()LIVE = False # True makes paid API calls using OPENAI_API_KEY. False replays saved real model traces.display(Markdown("**Mode:** " + ("live bounded model run" if LIVE else "saved real model output over regenerated mock evidence")))Mode: saved real model output over regenerated mock evidence
1 · Begin with the objective, not the alert
The fictional organization needs former-worker access removed so customer information, financial reporting, and software delivery remain protected. NIST CSF outcome PR.AA-05 supplies target-profile context: access permissions are managed, enforced, and reviewed.
This is not a maturity score. It tells us which desired outcome may be affected. The security evidence still has to support a condition, and a plausible threat event still has to connect that condition to a potential consequence.
Show code · cell 4
manifest = generate(ROOT)manifest{'seed': 41, 'source_kind': 'deterministic mock data shaped like documented vendor exports', 'counts': {'hr': 1000, 'okta': 8000, 'zscaler': 40000, 'business_services': 200}, 'batches': [1, 2], 'generated_for': 'risk.engineer Lab 01; not production telemetry'}2 · Register, land and receipt every source
The fixture factory stands in for upstream collectors. It produces deterministic JSON shaped like records teams receive from Okta System Log and Zscaler NSS. HR and the service catalog provide separate context. These are mock records—no vendor tenant or real person is represented.
Ingestion is a separate boundary. A source registry declares the role, format, adapter, identity field and batch field for every feed. The ingestor copies each file into a content-addressed landing path, calculates a SHA-256 checksum, counts records and batches, reports missing or duplicate source IDs, and issues a validated receipt. Reprocessing the same files produces the same receipt and does not duplicate the landed object.
This stage does not decide whether a security record is meaningful or ask an LLM to repair it. A valid JSON record may still fail vendor mapping or OCSF validation later.
At this volume, the lesson is visible: sending every event to a model would be expensive, hard to audit, and unnecessary.
Show code · cell 6
receipt = ingest(ROOT)show_volume({'pipeline': {'manifest': {'raw_counts': receipt['counts']}}})display([{ 'source': source['label'], 'role': source['role'], 'records': source['record_count'], 'batches': source['batch_counts'] or 'snapshot', 'status': source['transport_status'], 'sha256': source['sha256'][:12] + '…'} for source in receipt['sources']])
def first_jsonl(path): with path.open(encoding='utf-8') as handle: return json.loads(next(handle))
display({'Okta source record': first_jsonl(ROOT / 'data/raw/okta_system_log.jsonl')})display({'Zscaler source record': first_jsonl(ROOT / 'data/raw/zscaler_nss_web.jsonl')})print('Ingestion run:', receipt['run_id'], '· ready for:', receipt['ready_for'])
[{'source': 'Okta System Log', 'role': 'security-telemetry', 'records': 8000, 'batches': {'1': 4003, '2': 3997}, 'status': 'accepted-with-envelope-warnings', 'sha256': '76a30700743d…'}, {'source': 'Zscaler NSS Web', 'role': 'security-telemetry', 'records': 40000, 'batches': {'1': 20002, '2': 19998}, 'status': 'accepted-with-envelope-warnings', 'sha256': '7b8ee77bcf8c…'}, {'source': 'HRIS worker snapshot', 'role': 'identity-context', 'records': 1000, 'batches': 'snapshot', 'status': 'accepted', 'sha256': '3fa8ecd749e8…'}, {'source': 'Business service catalog', 'role': 'business-context', 'records': 200, 'batches': 'snapshot', 'status': 'accepted', 'sha256': '1d3bf5e11092…'}]{'Okta source record': {'uuid': 'OKTA-N-00000', 'published': '2026-09-01T12:00:00Z', 'eventType': 'user.authentication.sso', 'outcome': {'result': 'SUCCESS'}, 'actor': {'id': '00u0010', 'type': 'User', 'alternateId': 'user0010@corp.example.test', 'displayName': 'user0010'}, 'client': {'ipAddress': '198.51.100.11'}, 'target': [{'id': '00u0010', 'type': 'User', 'alternateId': 'user0010@corp.example.test', 'displayName': 'user0010'}, {'id': '0oa00000', 'type': 'AppInstance', 'alternateId': 'support', 'displayName': 'Support'}], '_collection': {'batch_id': 1, 'source': 'okta-system-log', 'complete': True}}}{'Zscaler source record': {'datetime': '2026-09-01T12:00:00Z', 'event_id': 'ZIA-N-00000', 'action': 'Allowed', 'user': 'user0010@corp.example.test', 'login': 'user0010@corp.example.test', 'url': 'https://support.apps.example.test/records', 'urlcategory': 'Business Use', 'hostname': 'support.apps.example.test', 'ClientIP': '203.0.113.11', 'requestmethod': 'GET', 'product': 'NSS', 'vendor': 'Zscaler', '_collection': {'batch_id': 1, 'source': 'zscaler-nss-web', 'complete': True}}}Ingestion run: d65020b6c5439dd9 · ready for: ['leaver-access-v1']3 · Validate and normalize landed telemetry to OCSF
The pipeline reads the landed copies referenced by the receipt. It rejects malformed source rows into quarantine. Valid Okta and Zscaler records are mapped to OCSF 1.8.0 classes and checked against the packaged OCSF JSON Schema before they are written to Parquet.
- Okta authentication → Authentication [3002]
- Okta account deactivation → Account Change [3001]
- Zscaler web request → HTTP Activity [4002]
HR and business-service data remain enrichment. Forcing them into a security-event schema would erase useful meaning.
The adapter is an explicit field contract
| Source field | OCSF / query projection | Why it survives |
|---|---|---|
Okta published |
time |
orders identity activity |
Okta uuid |
metadata.original_event_uid |
preserves source provenance |
| Okta target user | user |
supports identity joins |
| Okta target app | service |
connects access to a business service |
Okta outcome.result |
status_id |
distinguishes successful activity |
Zscaler datetime |
time |
aligns web and identity timelines |
Zscaler event_id |
metadata.original_event_uid |
preserves source provenance |
Zscaler login |
src_endpoint.owner.name |
supports identity correlation |
Zscaler hostname |
dst_endpoint.hostname |
resolves the destination service |
Zscaler url / requestmethod |
http_request |
retains the observed request |
The normalized event is not invented by the model. An adapter performs this mapping and OCSF Schema validation determines whether the result is accepted.
Show code · cell 9
warehouse = build_warehouse(ROOT)display(warehouse)
con = duckdb.connect()try: con.read_parquet(str(ROOT / 'data/normalized/ocsf_events.parquet')).create_view('events') sample = con.execute("SELECT source, evidence_id, class_uid, activity_id, ocsf_json FROM events WHERE evidence_id='OKTA-P1'").fetchone()finally: con.close()display({'source': sample[0], 'evidence_id': sample[1], 'class_uid': sample[2], 'activity_id': sample[3], 'ocsf_event': json.loads(sample[4])}){'mapping_version': '4', 'ingestion_run_id': 'd65020b6c5439dd9', 'ingestion_registry_version': '1.0.0', 'ingestion_ready_for': ['leaver-access-v1'], 'ocsf_version': '1.8.0', 'raw_counts': {'okta': 8000, 'zscaler': 40000, 'hr': 1000, 'business_services': 200}, 'normalized_events': 47998, 'quarantined': 2, 'class_counts': {'authentication': 7997, 'account_change': 2, 'http_activity': 39999}, 'validation': 'Every emitted event passed the packaged OCSF 1.8.0 JSON Schema for its class.', 'provenance': {'raw': 'deterministic mock data shaped like documented vendor exports', 'normalized': 'OCSF projection plus query index columns', 'context': 'fictional HR and business-service enrichment'}}{'source': 'okta', 'evidence_id': 'OKTA-P1', 'class_uid': 3002, 'activity_id': 1, 'ocsf_event': {'category_uid': 3, 'metadata': {'version': '1.8.0', 'product': {'vendor_name': 'Okta', 'name': 'System Log'}, 'original_event_uid': 'OKTA-P1'}, 'severity_id': 1, 'time': 1788264720000, 'status_id': 1, 'user': {'uid': '00u0001', 'name': 'user0001@corp.example.test'}, 'class_name': 'Authentication', 'activity_name': 'Logon', 'type_name': 'Authentication: Logon', 'activity_id': 1, 'class_uid': 3002, 'type_uid': 300201, 'service': {'uid': '0oa00000', 'name': 'support'}}}4 · Form an investigation queue
DuckDB groups observations by worker login and expected service, retaining all observed identity UIDs. A login match is a lead; a unique identity consistent with HR is required before supporting this scenario.
Our reference method requires successful authentication and allowed web activity after termination and after the latest observed disable, if present. Multiple or conflicting identities mean ambiguity; no observed identity means insufficient evidence. A disable with no later qualifying activity contradicts the narrow condition for this window only.
These conditions define one scenario family. The method is visible in code for comparison, while the agent receives the facts without the reference labels.
Show code · cell 11
pipelines = [correlate(1, ROOT), correlate(2, ROOT)]display([{k: c[k] for k in ('person_id', 'service_id', 'identity_uids', 'evidence_ids')} for c in pipelines[0]['clusters']])[{'person_id': 'P0001', 'service_id': 'support', 'identity_uids': ['00u0001'], 'evidence_ids': ['HR-0001', 'OKTA-P1', 'ZIA-P1']}, {'person_id': 'P0002', 'service_id': 'support', 'identity_uids': ['00u0002A', '00u0002B'], 'evidence_ids': ['HR-0002', 'OKTA-P2A', 'OKTA-P2B', 'ZIA-P2']}, {'person_id': 'P0003', 'service_id': 'support', 'identity_uids': ['00u0003'], 'evidence_ids': ['HR-0003', 'OKTA-P3-DISABLE']}, {'person_id': 'P0004', 'service_id': 'support', 'identity_uids': ['00u0004'], 'evidence_ids': ['HR-0004', 'OKTA-P4']}, {'person_id': 'P0005', 'service_id': 'finance', 'identity_uids': ['00u0005'], 'evidence_ids': ['HR-0005', 'OKTA-P5', 'ZIA-P5']}, {'person_id': 'P0006', 'service_id': 'engineering', 'identity_uids': ['00u0006'], 'evidence_ids': ['HR-0006', 'OKTA-P6', 'ZIA-P6']}]Learner checkpoint — predict before revealing batch 2:
- P0001 has a supported trail in batch 1. What should happen if a successful disable event appears in batch 2?
- P0002 resolves to two identity UIDs in batch 1. Should the system guess?
- P0004 has only part of the required event pair. Is absence of evidence proof that no risk exists?
Run the later history visual to test your answers. Consider what each result tells you, and what it cannot establish.
5 · Investigate six bounded clusters with a constrained agent
Code reduces 49,200 records to six terminated-worker investigation clusters. It does not tell the model which ones are supported. Each cluster contains neutral observed facts, stable identifiers, and the exact evidence IDs available for citation.
The bounded agent can only list clusters, inspect a timeline, look up business context, submit a candidate, and close a cluster. It has no raw-file access and no arbitrary SQL. Code rejects invented citations, assessment language, oversized statements, and decisions made before inspection. Every cluster must end as candidate-submitted, insufficient-evidence, contradicted, or ambiguous.
Show code · cell 14
results = []for batch in (1, 2): if LIVE: results.append(run(batch)) else: saved = ROOT / f'examples/batch-{batch}.json' result = json.loads(saved.read_text(encoding='utf-8')) assert result['version'] == VERSION, 'Saved traces are from a different engine revision.' assert result['pipeline']['clusters'] == pipelines[batch-1]['clusters'], 'Saved evidence differs from local input. Use live mode for changed inputs.' results.append(result)
show_funnel(results[0])display([{'cluster_id': c['cluster_id'], 'records': len(c['evidence_records'])} for c in results[0]['pipeline']['clusters']])print('Model:', results[0]['reasoning']['model'])for event in results[0]['reasoning']['trace']: target = event['arguments'].get('cluster_id', 'pipeline') print(f"turn {event['turn']:>2} · {event['tool']:<25} · {target:<35} · accepted={event['accepted']}")
[{'cluster_id': 'leaver-access:P0001:support', 'records': 3}, {'cluster_id': 'leaver-access:P0002:support', 'records': 4}, {'cluster_id': 'leaver-access:P0003:support', 'records': 2}, {'cluster_id': 'leaver-access:P0004:support', 'records': 2}, {'cluster_id': 'leaver-access:P0005:finance', 'records': 3}, {'cluster_id': 'leaver-access:P0006:engineering', 'records': 3}]Model: gpt-4.1-mini-2025-04-14turn 1 · inspect_pipeline · pipeline · accepted=Trueturn 2 · list_evidence_clusters · pipeline · accepted=Trueturn 3 · inspect_entity_timeline · leaver-access:P0001:support · accepted=Trueturn 3 · inspect_entity_timeline · leaver-access:P0002:support · accepted=Trueturn 3 · inspect_entity_timeline · leaver-access:P0003:support · accepted=Trueturn 3 · inspect_entity_timeline · leaver-access:P0004:support · accepted=Trueturn 3 · inspect_entity_timeline · leaver-access:P0005:finance · accepted=Trueturn 3 · inspect_entity_timeline · leaver-access:P0006:engineering · accepted=Trueturn 4 · lookup_business_context · leaver-access:P0001:support · accepted=Trueturn 4 · lookup_business_context · leaver-access:P0002:support · accepted=Trueturn 4 · lookup_business_context · leaver-access:P0003:support · accepted=Trueturn 4 · lookup_business_context · leaver-access:P0004:support · accepted=Trueturn 4 · lookup_business_context · leaver-access:P0005:finance · accepted=Trueturn 4 · lookup_business_context · leaver-access:P0006:engineering · accepted=Trueturn 5 · close_cluster · leaver-access:P0001:support · accepted=Falseturn 5 · close_cluster · leaver-access:P0002:support · accepted=Trueturn 5 · close_cluster · leaver-access:P0003:support · accepted=Trueturn 5 · close_cluster · leaver-access:P0004:support · accepted=Trueturn 5 · close_cluster · leaver-access:P0005:finance · accepted=Falseturn 5 · close_cluster · leaver-access:P0006:engineering · accepted=Falseturn 6 · submit_statement · leaver-access:P0001:support · accepted=Trueturn 7 · close_cluster · leaver-access:P0001:support · accepted=Trueturn 8 · submit_statement · leaver-access:P0005:finance · accepted=Trueturn 9 · close_cluster · leaver-access:P0005:finance · accepted=Trueturn 10 · submit_statement · leaver-access:P0006:engineering · accepted=Trueturn 11 · close_cluster · leaver-access:P0006:engineering · accepted=True6 · Review the identified scenarios and their history
The risk statement follows a reviewable structure:
If a plausible threat event acts through the observed condition, then a potential business consequence may occur, affecting an objective.
The event records support account activity, not the identity of the person operating the account, malicious intent, compromise or loss. A second collection may strengthen, weaken or remove evidence support while the stable scenario ID preserves history.
Review the model, too: several saved statements attribute activity to the departed worker when the records only identify the account. Business consequences are broad hypotheses. SEMANTIC-REVIEW.md records these limitations and a more careful editorial formulation; exact model outputs remain visible below.
Show code · cell 16
show_investigation_board(results[0])show_evidence(results[0])show_register(results[0])show_history(results)
display({'batch_1': results[0]['pipeline']['stage_counts'], 'batch_2': results[1]['pipeline']['stage_counts']})

Rich HTML output is available in the downloadable notebook; this reader omits executable HTML.


{'batch_1': {'raw_records': 49200, 'normalized_events': 47998, 'quarantined': 2, 'relevant_terminated_user_events': 11, 'investigation_clusters': 6, 'supported_evidence_bundles': 3}, 'batch_2': {'raw_records': 49200, 'normalized_events': 47998, 'quarantined': 2, 'relevant_terminated_user_events': 3, 'investigation_clusters': 6, 'supported_evidence_bundles': 1}}Evaluate the workflow, not the prose
The included benchmark contains twelve author-labeled synthetic cases across two collection batches. It tests candidate precision/recall, abstention and contradiction decisions, citation sets, the identification-only boundary, and tool-budget compliance. This is a regression harness—not independent evidence that the approach works on a real enterprise estate.
Show code · cell 18
scorecard = evaluate(results)display(scorecard)assert scorecard['passed'], 'Review the failed metric before treating this run as validated.'{'benchmark': 'Author-labeled synthetic cases for testing the lab harness; not independent real-world validation.', 'cases': 12, 'correct_dispositions': 12, 'observed_cases_match_benchmark': True, 'disposition_accuracy': 1.0, 'candidate_precision': 1.0, 'candidate_recall': 1.0, 'unsupported_candidates': 0, 'citation_sets_valid': True, 'assessment_language_violations': 0, 'tool_calls': 47, 'tool_budget_respected': True, 'agent_trace_verified': True, 'baseline_correct_dispositions': 12, 'model_additional_correct_dispositions': 0, 'models': ['gpt-4.1-mini-2025-04-14'], 'interpretation': 'This benchmark tests known-case behavior. It does not establish model superiority or semantic correctness of business consequences.', 'passed': True}Change the input and prove the difference
P0001’s second collection contains a disable. Predict what happens if successful authentication and allowed web activity arrive after that disable.
The next cell creates an isolated fixture workspace, appends two vendor JSON records, and reruns ingestion, normalization and the reference method. The content hash must change and the same scenario must return to candidate support. This exercise is deterministic and makes no model call. Open exercise.py to change the two timestamps and test the opposite ordering.
Show code · cell 20
from exercise import run_exerciseexercise_result = run_exercise()display(exercise_result)assert exercise_result['before'] == 'contradicted'assert exercise_result['after'] == 'candidate-submitted'{'mode': 'local deterministic exercise; no LLM call', 'scenario_id': 'leaver-access:P0001:support', 'before': 'contradicted', 'after': 'candidate-submitted', 'new_evidence_ids': ['EXERCISE-AUTH-P1', 'EXERCISE-WEB-P1'], 'before_ingestion_id': 'd65020b6c5439dd9', 'after_ingestion_id': '80a7ebb87068a224', 'after_evidence': [{'evidence_id': 'HR-0001', 'source': 'hr', 'observed_fact': 'P0001 has employment_status=terminated at 2026-09-01T08:00:00Z; HR expects identity_uid=00u0001.'}, {'evidence_id': 'OKTA-P1-DISABLE', 'source': 'Okta', 'ocsf_class': 'Account Change', 'observed_fact': 'Okta recorded Disable for user0001@corp.example.test at 1788267660000 with status_id=1. Observed identity_uid=00u0001.'}, {'evidence_id': 'EXERCISE-AUTH-P1', 'source': 'Okta', 'ocsf_class': 'Authentication', 'observed_fact': 'Okta recorded Logon for user0001@corp.example.test to support at 1788268500000 with status_id=1. Observed identity_uid=00u0001.'}, {'evidence_id': 'EXERCISE-WEB-P1', 'source': 'Zscaler', 'ocsf_class': 'HTTP Activity', 'observed_fact': 'Zscaler recorded Get by user0001@corp.example.test to support.apps.example.test at 1788268560000 with source_action=allowed.'}], 'lesson': 'A disable event does not erase subsequent access. Changed source bytes invalidate the warehouse cache.'}Inspect the risk-identification contracts
OCSF describes events. Our experimental RiskEvidenceBundle links those events to scope, derived conditions, business context, and limitations before the LLM sees them. CandidateRiskScenario validates the resulting hypothesis, citations, assumptions, and review status.
These are versioned lab contracts, not an industry standard. HR is optional in the contract; the current offboarding adapter needs HR to establish termination. The current rule selects this scenario family before model reasoning.
Show code · cell 22
from contracts import evidence_bundle, validate_contractbundle = evidence_bundle(pipelines[0]['clusters'][0])display(bundle)scenario = results[0]['reasoning']['candidates'][0]['risk_scenario']validate_contract('CandidateRiskScenario', scenario)display(scenario){'schema_version': '1.0.0', 'bundle_id': 'leaver-access:P0001:support', 'collection_batch': 1, 'scope': {'identity_ids': ['00u0001'], 'service_ids': ['support'], 'start_ms': 1788264720000, 'end_ms': 1788264840000}, 'evidence': [{'evidence_id': 'HR-0001', 'source': 'hr', 'observed_fact': 'P0001 has employment_status=terminated at 2026-09-01T08:00:00Z; HR expects identity_uid=00u0001.'}, {'evidence_id': 'OKTA-P1', 'source': 'Okta', 'ocsf_class': 'Authentication', 'observed_fact': 'Okta recorded Logon for user0001@corp.example.test to support at 1788264720000 with status_id=1. Observed identity_uid=00u0001.'}, {'evidence_id': 'ZIA-P1', 'source': 'Zscaler', 'ocsf_class': 'HTTP Activity', 'observed_fact': 'Zscaler recorded Get by user0001@corp.example.test to support.apps.example.test at 1788264840000 with source_action=allowed.'}], 'derived_conditions': [], 'business_context': {'objective_id': 'OBJ-001', 'objective': 'customer-information', 'service': 'Customer support platform', 'owner': 'Business owner 000', 'data_classification': 'customer-confidential', 'target_profile_outcomes': ['PR.AA-05'], 'origin': 'fictional HR and business-service enrichment'}, 'limitations': ['Activity does not establish authorization, malicious intent, or business loss.', 'Identity IDs are observed in the current window; an empty list means unresolved, even when HR supplies an expected ID.', 'Web activity is joined by login and service; this is not proof of a shared authenticated session.', 'Source availability and event absence are not proof of complete collection or globally revoked access.', 'The cluster is a bounded investigation lead, not a risk determination.']}{'schema_version': '1.0.0', 'scenario_id': 'leaver-access:P0001:support', 'bundle_id': 'leaver-access:P0001:support', 'collection_batch': 1, 'observed_condition': 'Terminated user P0001 authenticated and accessed support service post-termination with exactly one matching observed identity.', 'threat_event': 'Terminated employee authentication and access', 'potential_consequence': 'Unauthorized access to sensitive customer data', 'risk_statement': 'If a terminated user P0001 accesses the support service with valid authentication after termination, then unauthorized access to customer information may occur, affecting customer-information protection objectives.', 'business_context': {'objective_id': 'OBJ-001', 'objective': 'customer-information', 'service': 'Customer support platform', 'owner': 'Business owner 000', 'data_classification': 'customer-confidential', 'target_profile_outcomes': ['PR.AA-05'], 'origin': 'fictional HR and business-service enrichment'}, 'evidence_ids': ['HR-0001', 'OKTA-P1', 'ZIA-P1'], 'assumptions': ['Access was not authorized post-termination', 'Observed identity matches HR record', 'Activity timestamps are accurate'], 'open_questions': ['Was the access intentional or accidental?', 'Were there multiple device sessions?', 'Was data exfiltrated or modified?'], 'review_status': 'candidate-human-review-required', 'origin': 'model-generated'}What you built
You built the first step of a risk engineering lifecycle: continuous identification from changing system evidence. The separation of responsibilities is deliberate:
- schemas make records interoperable;
- deterministic code validates, joins, reduces, and abstains;
- the LLM expresses a bounded scenario from cited evidence and business context;
- a human decides whether the candidate is useful enough to enter a governed risk process.
References
- OCSF schema — vendor-neutral security-event structure.
- Okta System Log — vendor source shape and polling model.
- NIST CSF 2.0 — target-profile outcome context.
- NIST SP 800-30 Rev. 1 — threat-event and predisposing-condition concepts; this lab is not a full assessment.
Cleanup: stop Jupyter, or run docker compose down. Add -v only if you intentionally want to delete generated data and history. Never place an API key in this notebook or repository.