etch-record 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """etch-record: CLI helper for signing arbitrary events into your Etch chain."""
2
+
3
+ __version__ = "0.1.0"
etch_record/cli.py ADDED
@@ -0,0 +1,619 @@
1
+ """CLI entry point for etch-record.
2
+
3
+ Usage:
4
+ etch-record "description of the event" [OPTIONS]
5
+
6
+ Only one event_type value is accepted by the Etch server enum today for
7
+ arbitrary user-recorded events: "tool_call". Anything else silently
8
+ drops server-side (this is the exact bug the /docs/quickstart Common
9
+ Gotchas section warns about). So we hardcode "tool_call" and let
10
+ callers differentiate via --tags + --evidence-json.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ import click
22
+
23
+ from . import __version__
24
+ from .config import ConfigError, load
25
+ from .governance_client import GovernanceError, record_governance
26
+ from .mcp_client import McpError, extract_event_id, record_event
27
+ from .rate_limit import check_and_record
28
+
29
+
30
+ _LARGE_ARG_THRESHOLD_CHARS = 4096
31
+
32
+
33
+ def _default_session_id() -> str:
34
+ """Group events emitted on the same UTC calendar day into one session."""
35
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
36
+ return f"etch-record-{today}"
37
+
38
+
39
+ def _parse_tags(raw: str | None) -> list[str]:
40
+ if not raw:
41
+ return []
42
+ return [t.strip() for t in raw.split(",") if t.strip()]
43
+
44
+
45
+ def _load_evidence(
46
+ evidence_json: str | None,
47
+ evidence_file: Path | None,
48
+ ) -> dict:
49
+ if evidence_json and evidence_file:
50
+ raise click.UsageError(
51
+ "Pass only one of --evidence-json or --evidence-file, not both.",
52
+ )
53
+ if evidence_json:
54
+ try:
55
+ parsed = json.loads(evidence_json)
56
+ except json.JSONDecodeError as exc:
57
+ raise click.UsageError(
58
+ f"--evidence-json is not valid JSON: {exc}",
59
+ ) from exc
60
+ if not isinstance(parsed, dict):
61
+ raise click.UsageError("--evidence-json must be a JSON object.")
62
+ return parsed
63
+ if evidence_file:
64
+ try:
65
+ parsed = json.loads(evidence_file.read_text())
66
+ except (OSError, json.JSONDecodeError) as exc:
67
+ raise click.UsageError(
68
+ f"--evidence-file could not be loaded: {exc}",
69
+ ) from exc
70
+ if not isinstance(parsed, dict):
71
+ raise click.UsageError("--evidence-file must contain a JSON object.")
72
+ return parsed
73
+ return {}
74
+
75
+
76
+ def _resolve_text_source(
77
+ inline_value: str | None,
78
+ file_path: Path | None,
79
+ field_name: str,
80
+ ) -> str:
81
+ """Load a text field from either an inline CLI arg or a file.
82
+
83
+ Symmetric with _load_evidence: exactly one may be set.
84
+ File contents are stripped of ONE trailing newline (a common editor
85
+ artifact) and otherwise passed through verbatim.
86
+ """
87
+ if inline_value and file_path:
88
+ raise click.UsageError(
89
+ f"Pass only one of --{field_name} or --{field_name}-file, not both.",
90
+ )
91
+ if file_path is not None:
92
+ try:
93
+ text = file_path.read_text()
94
+ except OSError as exc:
95
+ raise click.UsageError(
96
+ f"--{field_name}-file could not be loaded: {exc}",
97
+ ) from exc
98
+ if text.endswith("\n"):
99
+ text = text[:-1]
100
+ return text
101
+ return inline_value or ""
102
+
103
+
104
+ def _resolve_description(positional: str, file_path: Path | None) -> str:
105
+ """Description resolution with click's required-positional in mind.
106
+
107
+ - No file: positional is the description (legacy behavior).
108
+ - With file: file content becomes the description; positional stays
109
+ as a short human label captured in the Layer A Bash-hook trace
110
+ but is NOT the recorded payload.
111
+ """
112
+ if file_path is None:
113
+ return positional
114
+ try:
115
+ text = file_path.read_text()
116
+ except OSError as exc:
117
+ raise click.UsageError(
118
+ f"--description-file could not be loaded: {exc}",
119
+ ) from exc
120
+ if text.endswith("\n"):
121
+ text = text[:-1]
122
+ return text
123
+
124
+
125
+ def _resolve_action_time(
126
+ action_time: str | None,
127
+ action_time_now: bool,
128
+ ) -> str | None:
129
+ """Normalize the real-world action timestamp for the evidence dict.
130
+
131
+ Returns an ISO-8601 UTC string ("YYYY-MM-DDTHH:MM:SSZ") or None
132
+ when neither flag is set. Rejects passing both at once.
133
+
134
+ Accepts any ISO-8601 form recognized by datetime.fromisoformat
135
+ (Python 3.10+ compatible - Z suffix is normalized to +00:00 first).
136
+ A timezone MUST be present; a naive datetime is rejected because
137
+ "action_time_utc without a timezone" is ambiguous and worse than
138
+ no action_time at all.
139
+ """
140
+ if action_time and action_time_now:
141
+ raise click.UsageError(
142
+ "Pass only one of --action-time or --action-time-now, not both.",
143
+ )
144
+ if action_time_now:
145
+ now_utc = datetime.now(timezone.utc)
146
+ return now_utc.strftime("%Y-%m-%dT%H:%M:%SZ")
147
+ if action_time is None:
148
+ return None
149
+ raw = action_time.strip()
150
+ if not raw:
151
+ raise click.UsageError("--action-time was empty.")
152
+ # Python 3.10 fromisoformat does NOT recognize the Z suffix. Normalize.
153
+ normalized = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
154
+ try:
155
+ dt = datetime.fromisoformat(normalized)
156
+ except ValueError as exc:
157
+ raise click.UsageError(
158
+ f"--action-time is not a valid ISO-8601 timestamp: {exc}. "
159
+ "Examples: 2026-07-28T14:03:00Z, 2026-07-28T19:33:00+05:30.",
160
+ ) from exc
161
+ if dt.tzinfo is None:
162
+ raise click.UsageError(
163
+ "--action-time must include a timezone (e.g. 'Z' suffix or "
164
+ "+HH:MM offset). Naive timestamps are ambiguous.",
165
+ )
166
+ utc_dt = dt.astimezone(timezone.utc)
167
+ return utc_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
168
+
169
+
170
+ def _maybe_warn_large_arg(name: str, value: str) -> None:
171
+ """Print a stderr warning when a text arg passed via CLI is large.
172
+
173
+ Large CLI args are a footgun: Claude Code's Bash-hook records the
174
+ invocation with its arguments hashed into an audit event, which
175
+ doubles the payload on the chain and (in multi-tenant deploys)
176
+ can cross tenant boundaries. --reasoning-file / --description-file
177
+ bypass this. This warning nudges users toward those flags for
178
+ sensitive content without blocking legitimate use.
179
+ """
180
+ if len(value) > _LARGE_ARG_THRESHOLD_CHARS:
181
+ click.echo(
182
+ f"etch-record: warning: --{name} is {len(value)} chars via CLI "
183
+ f"argv. Consider --{name}-file for large or sensitive content; "
184
+ f"CLI args are captured verbatim by shell-hook auditors.",
185
+ err=True,
186
+ )
187
+
188
+
189
+ @click.command(
190
+ context_settings={"help_option_names": ["-h", "--help"]},
191
+ help=(
192
+ "Sign one event into your Etch chain from the command line.\n\n"
193
+ "Every call becomes a signed record_event on your Etch project."
194
+ " Requires ETCH_PROJECT_ID and ETCH_APP_TOKEN in the environment."
195
+ ),
196
+ )
197
+ @click.argument("description", type=str)
198
+ @click.option(
199
+ "--tags",
200
+ "tags_raw",
201
+ type=str,
202
+ default=None,
203
+ help="Comma-separated tags (e.g. research,x,launch). Become the event's entities.",
204
+ )
205
+ @click.option(
206
+ "--evidence-json",
207
+ type=str,
208
+ default=None,
209
+ help="Inline JSON object with extra structured evidence.",
210
+ )
211
+ @click.option(
212
+ "--evidence-file",
213
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
214
+ default=None,
215
+ help="Path to a JSON file containing an evidence object.",
216
+ )
217
+ @click.option(
218
+ "--session-id",
219
+ type=str,
220
+ default=None,
221
+ help=(
222
+ "Group this event with others under the same session id. "
223
+ "Default: etch-record-YYYY-MM-DD (current UTC date)."
224
+ ),
225
+ )
226
+ @click.option(
227
+ "--reasoning",
228
+ type=str,
229
+ default=None,
230
+ help="Optional freeform reasoning string.",
231
+ )
232
+ @click.option(
233
+ "--reasoning-file",
234
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
235
+ default=None,
236
+ help=(
237
+ "Path to a text file whose contents become the reasoning field. "
238
+ "Use this instead of --reasoning for large or sensitive content: "
239
+ "CLI args are captured verbatim by shell-hook auditors."
240
+ ),
241
+ )
242
+ @click.option(
243
+ "--description-file",
244
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
245
+ default=None,
246
+ help=(
247
+ "Path to a text file whose contents OVERRIDE the description "
248
+ "positional argument. When set, the description positional "
249
+ "argument may be a short label like 'from-file'; the file "
250
+ "contents become the actual description in the event. Same "
251
+ "safety motivation as --reasoning-file."
252
+ ),
253
+ )
254
+ @click.option(
255
+ "--from-hook",
256
+ "from_hook",
257
+ type=str,
258
+ default=None,
259
+ help=(
260
+ "Reference id of the Claude Code hook (Layer A) event this "
261
+ "semantic event corresponds to. Adds `layer_a_ref` to evidence "
262
+ "and the `hook_bound` tag for explicit two-layer bind. Env "
263
+ "fallback: ETCH_HOOK_TOOL_USE_ID."
264
+ ),
265
+ )
266
+ @click.option(
267
+ "--action-time",
268
+ "action_time",
269
+ type=str,
270
+ default=None,
271
+ help=(
272
+ "Real-world timestamp of the action being recorded, ISO-8601 "
273
+ "with timezone (e.g. 2026-07-28T14:03:00Z). Adds "
274
+ "`action_time_utc` to evidence so downstream audit reconstructs "
275
+ "when the action HAPPENED, not just when it was signed. Use for "
276
+ "after-the-fact signing where signing-time and action-time differ."
277
+ ),
278
+ )
279
+ @click.option(
280
+ "--action-time-now",
281
+ is_flag=True,
282
+ default=False,
283
+ help=(
284
+ "Shortcut for --action-time set to the current UTC. Useful when "
285
+ "signing immediately after the action."
286
+ ),
287
+ )
288
+ @click.option(
289
+ "--force-no-rate-limit",
290
+ is_flag=True,
291
+ default=False,
292
+ help=(
293
+ "Bypass the client-side sliding-window rate limit for this "
294
+ "invocation. Use only for legitimate batch or backfill flows."
295
+ ),
296
+ )
297
+ @click.option(
298
+ "--failed",
299
+ is_flag=True,
300
+ default=False,
301
+ help="Mark the event as unsuccessful (success=false).",
302
+ )
303
+ @click.option(
304
+ "--dry-run",
305
+ is_flag=True,
306
+ default=False,
307
+ help="Print the payload that would be sent, do not call Etch.",
308
+ )
309
+ # ---------------------------------------------------------------------------
310
+ # Wave 1 #1 (2026-08-01) — extended governance schema flags.
311
+ # When any of these is set, the CLI makes TWO calls:
312
+ # 1. mcp_client.record_event → creates the base OSS event (unchanged)
313
+ # 2. governance_client.record_governance → attaches a signed
314
+ # governance sub-record to the Etch parallel chain, cross-
315
+ # referencing the OSS event by id
316
+ # When none are set, the CLI behaves exactly as before (single
317
+ # record_event call). Zero behavior change for callers that don't
318
+ # opt in.
319
+ # ---------------------------------------------------------------------------
320
+ @click.option(
321
+ "--policy-hash",
322
+ "policy_hash",
323
+ type=str,
324
+ default=None,
325
+ help=(
326
+ "Wave 1 #1 governance: SHA-256 hash of the governance policy "
327
+ "under which this decision was evaluated. Must be 'sha256:<64-hex>' "
328
+ "shape. Triggers a second call to /v1/etch-chain/governance-record "
329
+ "on the Etch chain."
330
+ ),
331
+ )
332
+ @click.option(
333
+ "--authority-file",
334
+ "authority_file",
335
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
336
+ default=None,
337
+ help=(
338
+ "Wave 1 #1 governance: path to a JSON file describing the "
339
+ "approving authority: {identity, scope, expires_at}. See "
340
+ "docs/wave-1-1.md for the schema."
341
+ ),
342
+ )
343
+ @click.option(
344
+ "--assumptions-file",
345
+ "assumptions_file",
346
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
347
+ default=None,
348
+ help=(
349
+ "Wave 1 #1 governance: path to a JSON file listing assumptions "
350
+ "accepted when reaching the decision. Each entry is "
351
+ "{claim, source_ref}."
352
+ ),
353
+ )
354
+ @click.option(
355
+ "--uncertainty",
356
+ "uncertainty",
357
+ type=str,
358
+ default=None,
359
+ help=(
360
+ "Wave 1 #1 governance: uncertainty attached to the decision, "
361
+ "given as 'confidence:basis' (e.g. "
362
+ "'0.87:evidence-hash-lookup-match-rate'). Confidence must be "
363
+ "[0.0, 1.0]. For richer shapes use --uncertainty-file."
364
+ ),
365
+ )
366
+ @click.option(
367
+ "--uncertainty-file",
368
+ "uncertainty_file",
369
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
370
+ default=None,
371
+ help=(
372
+ "Wave 1 #1 governance: path to a JSON file with the full "
373
+ "uncertainty object: {confidence, basis}."
374
+ ),
375
+ )
376
+ @click.option(
377
+ "--invalidation-file",
378
+ "invalidation_file",
379
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
380
+ default=None,
381
+ help=(
382
+ "Wave 1 #1 governance: path to a JSON file listing conditions "
383
+ "under which the approval would have been invalidated. Each "
384
+ "entry is {\"if\": ..., \"then\": ...}."
385
+ ),
386
+ )
387
+ @click.version_option(__version__, prog_name="etch-record")
388
+ def main(
389
+ description: str,
390
+ tags_raw: str | None,
391
+ evidence_json: str | None,
392
+ evidence_file: Path | None,
393
+ session_id: str | None,
394
+ reasoning: str | None,
395
+ reasoning_file: Path | None,
396
+ description_file: Path | None,
397
+ from_hook: str | None,
398
+ action_time: str | None,
399
+ action_time_now: bool,
400
+ force_no_rate_limit: bool,
401
+ failed: bool,
402
+ dry_run: bool,
403
+ policy_hash: str | None,
404
+ authority_file: Path | None,
405
+ assumptions_file: Path | None,
406
+ uncertainty: str | None,
407
+ uncertainty_file: Path | None,
408
+ invalidation_file: Path | None,
409
+ ) -> None:
410
+ tags = _parse_tags(tags_raw)
411
+ evidence = _load_evidence(evidence_json, evidence_file)
412
+ session = session_id or _default_session_id()
413
+
414
+ # Resolve reasoning from either inline arg or file (symmetric with
415
+ # --evidence-json / --evidence-file split; both flags are optional).
416
+ resolved_reasoning = _resolve_text_source(
417
+ reasoning, reasoning_file, "reasoning",
418
+ )
419
+ # Description is special: the positional CLI arg is REQUIRED by click,
420
+ # so users cannot omit it. When --description-file is passed, the file
421
+ # content becomes the actual event description; the positional serves
422
+ # as a short human label the user sees in shell history + the Layer A
423
+ # Bash-hook event, keeping the sensitive payload off argv.
424
+ resolved_description = _resolve_description(description, description_file)
425
+
426
+ # Sensitive-arg guard: warn (do not block) on oversized inline
427
+ # text passed via CLI argv. Skipped when the file variant was used.
428
+ if reasoning and not reasoning_file:
429
+ _maybe_warn_large_arg("reasoning", reasoning)
430
+ if description and not description_file:
431
+ _maybe_warn_large_arg("description", description)
432
+
433
+ # Two-layer bind: --from-hook (or env fallback) adds an explicit
434
+ # cross-reference to the Layer A (mechanical) hook event.
435
+ hook_ref = from_hook or os.environ.get("ETCH_HOOK_TOOL_USE_ID", "").strip()
436
+ if hook_ref:
437
+ evidence = {**evidence, "layer_a_ref": hook_ref}
438
+ if "hook_bound" not in tags:
439
+ tags = [*tags, "hook_bound"]
440
+
441
+ # Real-world action timestamp (--action-time / --action-time-now):
442
+ # closes the drift between "when it happened" and "when I signed it".
443
+ resolved_action_time = _resolve_action_time(action_time, action_time_now)
444
+ if resolved_action_time is not None:
445
+ evidence = {**evidence, "action_time_utc": resolved_action_time}
446
+
447
+ arguments = {
448
+ "event_type": "tool_call",
449
+ "session_id": session,
450
+ "entities": tags,
451
+ "description": resolved_description,
452
+ "reasoning": resolved_reasoning,
453
+ "evidence": evidence,
454
+ "success": not failed,
455
+ }
456
+
457
+ if dry_run:
458
+ click.echo(
459
+ json.dumps(
460
+ {"method": "tools/call", "name": "record_event", "arguments": arguments},
461
+ indent=2,
462
+ sort_keys=True,
463
+ ),
464
+ )
465
+ return
466
+
467
+ # Client-side rate limit BEFORE config load: a rate-limited caller
468
+ # should get a clear message, not a config error, when their env
469
+ # is not yet set up. Disabled by --force-no-rate-limit or by
470
+ # ETCH_RATE_LIMIT_PER_MINUTE=0.
471
+ if not force_no_rate_limit:
472
+ allowed, count, limit = check_and_record()
473
+ if not allowed:
474
+ click.echo(
475
+ f"etch-record: rate limit hit ({count}/{limit} events in "
476
+ f"the last 60s). Wait a few seconds and retry, or pass "
477
+ f"--force-no-rate-limit for legitimate batch use. Adjust "
478
+ f"the ceiling with ETCH_RATE_LIMIT_PER_MINUTE.",
479
+ err=True,
480
+ )
481
+ sys.exit(4)
482
+
483
+ try:
484
+ cfg = load()
485
+ except ConfigError as exc:
486
+ click.echo(str(exc), err=True)
487
+ sys.exit(1)
488
+
489
+ try:
490
+ body = record_event(cfg, arguments)
491
+ except McpError as exc:
492
+ click.echo(f"etch-record: {exc}", err=True)
493
+ sys.exit(2)
494
+ except Exception as exc: # noqa: BLE001
495
+ click.echo(f"etch-record: unexpected error: {exc}", err=True)
496
+ sys.exit(3)
497
+
498
+ event_id = extract_event_id(body) or "?"
499
+ click.echo(f"OK session={session} event_id={event_id}")
500
+
501
+ # Wave 1 #1 — if any governance flag was set, assemble the
502
+ # governance object and POST it to the Etch chain endpoint.
503
+ governance = _assemble_governance_from_flags(
504
+ policy_hash=policy_hash,
505
+ authority_file=authority_file,
506
+ assumptions_file=assumptions_file,
507
+ uncertainty=uncertainty,
508
+ uncertainty_file=uncertainty_file,
509
+ invalidation_file=invalidation_file,
510
+ )
511
+ if governance is None:
512
+ return
513
+
514
+ try:
515
+ result = record_governance(cfg, event_id, governance)
516
+ except GovernanceError as exc:
517
+ click.echo(f"etch-record: governance-record: {exc}", err=True)
518
+ # Base event was recorded successfully; the governance sub-record
519
+ # failed. Exit non-zero so shell callers can catch it, but keep
520
+ # the earlier OK line above so the caller has the event_id to
521
+ # retry against.
522
+ sys.exit(5)
523
+
524
+ click.echo(
525
+ f"OK governance_seq={result.etch_chain_seq} "
526
+ f"governance_hash={result.governance_hash}",
527
+ )
528
+
529
+
530
+ def _assemble_governance_from_flags(
531
+ policy_hash: str | None,
532
+ authority_file: Path | None,
533
+ assumptions_file: Path | None,
534
+ uncertainty: str | None,
535
+ uncertainty_file: Path | None,
536
+ invalidation_file: Path | None,
537
+ ) -> dict | None:
538
+ """Compose the governance object from CLI flags, or return None if
539
+ none of them are set (signals: skip the second API call)."""
540
+ if not any((
541
+ policy_hash, authority_file, assumptions_file,
542
+ uncertainty, uncertainty_file, invalidation_file,
543
+ )):
544
+ return None
545
+
546
+ if uncertainty and uncertainty_file:
547
+ raise click.UsageError(
548
+ "Pass only one of --uncertainty or --uncertainty-file, "
549
+ "not both.",
550
+ )
551
+
552
+ gov: dict = {}
553
+ if policy_hash:
554
+ gov["policy_hash"] = policy_hash
555
+ if authority_file is not None:
556
+ gov["authority"] = _read_json_object(authority_file, "--authority-file")
557
+ if assumptions_file is not None:
558
+ gov["assumptions"] = _read_json_list(
559
+ assumptions_file, "--assumptions-file",
560
+ )
561
+ if uncertainty is not None:
562
+ gov["uncertainty"] = _parse_uncertainty_inline(uncertainty)
563
+ elif uncertainty_file is not None:
564
+ gov["uncertainty"] = _read_json_object(
565
+ uncertainty_file, "--uncertainty-file",
566
+ )
567
+ if invalidation_file is not None:
568
+ gov["invalidation_conditions"] = _read_json_list(
569
+ invalidation_file, "--invalidation-file",
570
+ )
571
+ return gov
572
+
573
+
574
+ def _read_json_object(path: Path, flag_name: str) -> dict:
575
+ try:
576
+ parsed = json.loads(path.read_text())
577
+ except (OSError, json.JSONDecodeError) as exc:
578
+ raise click.UsageError(
579
+ f"{flag_name} could not be loaded: {exc}",
580
+ ) from exc
581
+ if not isinstance(parsed, dict):
582
+ raise click.UsageError(f"{flag_name} must be a JSON object.")
583
+ return parsed
584
+
585
+
586
+ def _read_json_list(path: Path, flag_name: str) -> list:
587
+ try:
588
+ parsed = json.loads(path.read_text())
589
+ except (OSError, json.JSONDecodeError) as exc:
590
+ raise click.UsageError(
591
+ f"{flag_name} could not be loaded: {exc}",
592
+ ) from exc
593
+ if not isinstance(parsed, list):
594
+ raise click.UsageError(f"{flag_name} must be a JSON array.")
595
+ return parsed
596
+
597
+
598
+ def _parse_uncertainty_inline(raw: str) -> dict:
599
+ """Parse '<confidence>:<basis>' into {confidence, basis}."""
600
+ if ":" not in raw:
601
+ raise click.UsageError(
602
+ "--uncertainty must be 'confidence:basis' (e.g. "
603
+ "'0.87:evidence-hash-match-rate').",
604
+ )
605
+ conf_str, basis = raw.split(":", 1)
606
+ try:
607
+ confidence = float(conf_str)
608
+ except ValueError as exc:
609
+ raise click.UsageError(
610
+ f"--uncertainty confidence must be a float: {exc}",
611
+ ) from exc
612
+ if not (0.0 <= confidence <= 1.0):
613
+ raise click.UsageError(
614
+ "--uncertainty confidence must be in [0.0, 1.0].",
615
+ )
616
+ basis = basis.strip()
617
+ if not basis:
618
+ raise click.UsageError("--uncertainty basis must be non-empty.")
619
+ return {"confidence": confidence, "basis": basis}
etch_record/config.py ADDED
@@ -0,0 +1,47 @@
1
+ """Config resolution: env vars first, no on-disk config file for MVP.
2
+
3
+ Missing env vars raise a `ConfigError` the CLI catches and turns into
4
+ a friendly stderr message + exit code 1.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass
11
+
12
+
13
+ class ConfigError(RuntimeError):
14
+ pass
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Config:
19
+ project_id: str
20
+ app_token: str
21
+ base_url: str
22
+
23
+
24
+ def load() -> Config:
25
+ project_id = os.environ.get("ETCH_PROJECT_ID", "").strip()
26
+ app_token = os.environ.get("ETCH_APP_TOKEN", "").strip()
27
+ base_url = os.environ.get("ETCH_BASE_URL", "https://etch.systems").rstrip("/")
28
+
29
+ missing = []
30
+ if not project_id:
31
+ missing.append("ETCH_PROJECT_ID")
32
+ if not app_token:
33
+ missing.append("ETCH_APP_TOKEN")
34
+ if missing:
35
+ raise ConfigError(
36
+ "Missing required environment variable(s): "
37
+ + ", ".join(missing)
38
+ + "\nSet in your shell rc (~/.zshrc):\n"
39
+ + " export ETCH_PROJECT_ID=your_project_id\n"
40
+ + " export ETCH_APP_TOKEN=wm_your_app_token\n"
41
+ )
42
+
43
+ return Config(
44
+ project_id=project_id,
45
+ app_token=app_token,
46
+ base_url=base_url,
47
+ )
@@ -0,0 +1,146 @@
1
+ """HTTP client for Wave 1 #1 governance-record endpoint (2026-08-01).
2
+
3
+ Companion to mcp_client.py. Where mcp_client speaks MCP StreamableHTTP
4
+ to invoke tools like record_event, this module speaks plain HTTP to
5
+ Etch's own compliance surface (currently: POST
6
+ /v1/etch-chain/governance-record).
7
+
8
+ Kept as a separate module so the CLI can compose:
9
+ 1. mcp_client.record_event(...) → get OSS event_id
10
+ 2. governance_client.record_governance(cfg, event_id, gov) → attach
11
+ signed compliance metadata to the Etch parallel chain
12
+
13
+ Both calls hit etch.systems with the same Bearer token, so a client
14
+ that can call one can call the other.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from typing import Any, Optional
21
+
22
+ import httpx
23
+
24
+ from .config import Config
25
+
26
+
27
+ _TIMEOUT_S = 30.0
28
+
29
+
30
+ class GovernanceError(RuntimeError):
31
+ """Raised when the governance-record HTTP call fails in a way the
32
+ CLI should surface with a friendly stderr message + exit code."""
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class GovernanceResult:
37
+ """Response payload from a successful POST
38
+ /v1/etch-chain/governance-record. All fields come from the server;
39
+ the client does not compute anything locally."""
40
+
41
+ etch_chain_seq: int
42
+ etch_row_id: str
43
+ governance_hash: str
44
+ oss_event_id_ref: str
45
+ recorded_at: str
46
+
47
+
48
+ def record_governance(
49
+ cfg: Config,
50
+ oss_event_id: str,
51
+ governance: dict,
52
+ client: Optional[httpx.Client] = None,
53
+ ) -> GovernanceResult:
54
+ """POST /v1/etch-chain/governance-record.
55
+
56
+ Args:
57
+ cfg: loaded Config (has base_url + app_token).
58
+ oss_event_id: the event_id returned by mcp_client.record_event.
59
+ governance: assembled governance object (validated server-side
60
+ via Pydantic; local validation would drift and is skipped).
61
+ client: optional httpx.Client for dependency injection. Tests
62
+ pass a MockTransport-backed client. Prod passes None; the
63
+ function creates a default client bounded by _TIMEOUT_S.
64
+
65
+ Returns:
66
+ GovernanceResult on 200.
67
+
68
+ Raises:
69
+ GovernanceError on any non-200 or transport failure. Message
70
+ surfaces the server's error code + hint when the response body
71
+ is a valid JSON error envelope.
72
+ """
73
+ url = f"{cfg.base_url}/v1/etch-chain/governance-record"
74
+ body = {
75
+ "oss_event_id": oss_event_id,
76
+ "governance": governance,
77
+ }
78
+ headers = {
79
+ "Authorization": f"Bearer {cfg.app_token}",
80
+ "Content-Type": "application/json",
81
+ "Accept": "application/json",
82
+ }
83
+
84
+ owned_client = client is None
85
+ if owned_client:
86
+ client = httpx.Client(timeout=_TIMEOUT_S)
87
+
88
+ try:
89
+ try:
90
+ resp = client.post(url, json=body, headers=headers)
91
+ except httpx.HTTPError as exc:
92
+ raise GovernanceError(
93
+ f"transport failed while calling {url}: {exc}",
94
+ ) from exc
95
+ finally:
96
+ if owned_client:
97
+ client.close()
98
+
99
+ if resp.status_code == 200:
100
+ try:
101
+ payload = resp.json()
102
+ except ValueError as exc:
103
+ raise GovernanceError(
104
+ f"200 response was not valid JSON: {exc}",
105
+ ) from exc
106
+ return GovernanceResult(
107
+ etch_chain_seq=int(payload["etch_chain_seq"]),
108
+ etch_row_id=str(payload["etch_row_id"]),
109
+ governance_hash=str(payload["governance_hash"]),
110
+ oss_event_id_ref=str(payload["oss_event_id_ref"]),
111
+ recorded_at=str(payload["recorded_at"]),
112
+ )
113
+
114
+ # Non-200 — try to parse the server's JSON error envelope.
115
+ try:
116
+ err_payload = resp.json()
117
+ except ValueError:
118
+ raise GovernanceError(
119
+ f"HTTP {resp.status_code} with non-JSON body: "
120
+ f"{resp.text[:200]!r}",
121
+ )
122
+
123
+ err_code = err_payload.get("error", "unknown_error")
124
+ if err_code == "insufficient_scope":
125
+ raise GovernanceError(
126
+ f"insufficient_scope: your app token needs "
127
+ f"{err_payload.get('required_scope')!r} but has "
128
+ f"{err_payload.get('token_scopes')}. Mint a new token "
129
+ f"with mcp:write scope.",
130
+ )
131
+ if err_code == "validation_failed":
132
+ details = err_payload.get("details", [])
133
+ summary = "; ".join(
134
+ f"{d.get('loc')}: {d.get('msg')}" for d in details
135
+ )
136
+ raise GovernanceError(
137
+ f"governance object failed validation: {summary}",
138
+ )
139
+ if err_code == "invalid_json_body":
140
+ raise GovernanceError(
141
+ "server rejected the request body as invalid JSON",
142
+ )
143
+
144
+ raise GovernanceError(
145
+ f"HTTP {resp.status_code}: {err_code}",
146
+ )
@@ -0,0 +1,178 @@
1
+ """Minimal MCP client for the single flow we need: post one `record_event`.
2
+
3
+ Handles the three-step MCP dance:
4
+ 1. POST /mcp {method: "initialize"} -> session_id in response header
5
+ 2. POST /mcp {method: "notifications/initialized"}
6
+ 3. POST /mcp {method: "tools/call", name: "record_event", arguments: {...}}
7
+
8
+ Session ID is per-invocation. We do NOT persist it across CLI runs -
9
+ each `etch-record` call is a fresh session. That is O(1) events per
10
+ invocation, which is fine for CLI usage; a daemon-style caller would
11
+ want to keep the session open.
12
+
13
+ Response format: the Etch /mcp endpoint uses the MCP StreamableHTTP
14
+ transport, which serves responses as text/event-stream (SSE) frames
15
+ containing the JSON-RPC body inside a `data:` line. For a single
16
+ tools/call there is exactly one message frame per response, so we
17
+ extract the data payload and parse it as JSON.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ from .config import Config
28
+
29
+
30
+ class McpError(RuntimeError):
31
+ pass
32
+
33
+
34
+ _PROTOCOL_VERSION = "2025-11-05"
35
+ _TIMEOUT_SECONDS = 15.0
36
+
37
+
38
+ def _parse_mcp_body(response: httpx.Response) -> dict:
39
+ """Return the JSON-RPC body from an MCP response.
40
+
41
+ Handles both content types the MCP StreamableHTTP transport spec
42
+ permits:
43
+ - application/json (plain JSON body)
44
+ - text/event-stream (SSE frames; the JSON is inside data: lines)
45
+
46
+ For SSE, we concatenate all `data:` lines in the response (per the
47
+ SSE spec: a single message split across multiple data lines is
48
+ reassembled by joining with newlines) and parse the result.
49
+ Returns the parsed JSON-RPC message dict.
50
+ """
51
+ content_type = (response.headers.get("content-type") or "").lower()
52
+ text = response.text
53
+
54
+ is_sse = (
55
+ "text/event-stream" in content_type
56
+ or text.lstrip().startswith("event:")
57
+ or text.lstrip().startswith("data:")
58
+ )
59
+ if is_sse:
60
+ data_parts: list[str] = []
61
+ for raw in text.splitlines():
62
+ if raw.startswith("data:"):
63
+ # Per SSE spec: strip leading "data:" then a single
64
+ # optional space. Do not strip further whitespace so
65
+ # JSON with intentional leading spaces round-trips.
66
+ chunk = raw[len("data:"):]
67
+ if chunk.startswith(" "):
68
+ chunk = chunk[1:]
69
+ data_parts.append(chunk)
70
+ if not data_parts:
71
+ raise McpError(
72
+ f"SSE response contained no data lines: {text[:200]!r}",
73
+ )
74
+ joined = "\n".join(data_parts)
75
+ try:
76
+ return json.loads(joined)
77
+ except json.JSONDecodeError as exc:
78
+ raise McpError(
79
+ f"SSE data was not valid JSON: {joined[:200]!r}",
80
+ ) from exc
81
+
82
+ # Plain JSON response.
83
+ try:
84
+ return response.json()
85
+ except json.JSONDecodeError as exc:
86
+ raise McpError(
87
+ f"non-JSON response ({content_type or 'no content-type'}): "
88
+ f"{text[:200]!r}",
89
+ ) from exc
90
+
91
+
92
+ def extract_event_id(body: dict) -> str | None:
93
+ """Pull the Etch server's event_id out of a tools/call result body.
94
+
95
+ Etch's record_event tool returns a nested JSON string inside
96
+ result.content[0].text, shaped like:
97
+ {"event_id": "uuid", "status": "recorded"}
98
+ This peels the two layers and returns the inner event_id. Returns
99
+ None if the shape doesn't match (call sites should fall back to
100
+ the JSON-RPC message id or "?").
101
+ """
102
+ try:
103
+ content = body.get("result", {}).get("content") or []
104
+ if not content:
105
+ return None
106
+ first = content[0]
107
+ text = first.get("text") if isinstance(first, dict) else None
108
+ if not text:
109
+ return None
110
+ inner = json.loads(text)
111
+ eid = inner.get("event_id")
112
+ return str(eid) if eid is not None else None
113
+ except (json.JSONDecodeError, AttributeError, TypeError, IndexError):
114
+ return None
115
+
116
+
117
+ def _headers_for(cfg: Config, mcp_session_id: str | None = None) -> dict[str, str]:
118
+ hdrs = {
119
+ "Authorization": f"Bearer {cfg.app_token}",
120
+ "Content-Type": "application/json",
121
+ "Accept": "application/json, text/event-stream",
122
+ }
123
+ if mcp_session_id:
124
+ hdrs["mcp-session-id"] = mcp_session_id
125
+ return hdrs
126
+
127
+
128
+ def _initialize(client: httpx.Client, cfg: Config) -> str:
129
+ payload = {
130
+ "jsonrpc": "2.0",
131
+ "id": 1,
132
+ "method": "initialize",
133
+ "params": {
134
+ "protocolVersion": _PROTOCOL_VERSION,
135
+ "capabilities": {},
136
+ "clientInfo": {"name": "etch-record", "version": "0.1.0"},
137
+ },
138
+ }
139
+ r = client.post("/mcp", headers=_headers_for(cfg), json=payload)
140
+ if r.status_code != 200:
141
+ raise McpError(f"initialize failed: {r.status_code} {r.text[:200]}")
142
+ sid = r.headers.get("mcp-session-id")
143
+ if not sid:
144
+ raise McpError("initialize response missing mcp-session-id header")
145
+ return sid
146
+
147
+
148
+ def _notify_initialized(client: httpx.Client, cfg: Config, sid: str) -> None:
149
+ payload = {"jsonrpc": "2.0", "method": "notifications/initialized"}
150
+ r = client.post("/mcp", headers=_headers_for(cfg, sid), json=payload)
151
+ # 202 is the expected response for notifications; 200 also fine.
152
+ if r.status_code not in (200, 202):
153
+ raise McpError(
154
+ f"notifications/initialized failed: {r.status_code} {r.text[:200]}"
155
+ )
156
+
157
+
158
+ def record_event(cfg: Config, arguments: dict[str, Any]) -> dict[str, Any]:
159
+ """Post one record_event tool call. Returns the parsed JSON result."""
160
+ with httpx.Client(base_url=cfg.base_url, timeout=_TIMEOUT_SECONDS) as client:
161
+ sid = _initialize(client, cfg)
162
+ _notify_initialized(client, cfg, sid)
163
+
164
+ payload = {
165
+ "jsonrpc": "2.0",
166
+ "id": 42,
167
+ "method": "tools/call",
168
+ "params": {"name": "record_event", "arguments": arguments},
169
+ }
170
+ r = client.post("/mcp", headers=_headers_for(cfg, sid), json=payload)
171
+ if r.status_code != 200:
172
+ raise McpError(
173
+ f"tools/call failed: {r.status_code} {r.text[:200]}"
174
+ )
175
+ body = _parse_mcp_body(r)
176
+ if "error" in body:
177
+ raise McpError(f"MCP error: {body['error']}")
178
+ return body
@@ -0,0 +1,111 @@
1
+ """Client-side sliding-window rate limit for etch-record.
2
+
3
+ Guards against tight-loop misuse (dev accidentally emits a hot loop) that
4
+ would double-log through both the Claude Code Bash-invocation hook and
5
+ the etch-record semantic event.
6
+
7
+ Design choices:
8
+ - Sliding window, 60 seconds, event-count based.
9
+ - State persisted at ~/.etch-record/rate_state.json as a list of unix
10
+ timestamps. Prune older-than-window on every read.
11
+ - Fails OPEN: any state-file corruption or FS error is logged to
12
+ stderr and treated as "no prior events". Rate limit exists to catch
13
+ an accidental hot loop, not to be an authoritative security control;
14
+ a broken state file must never block a legitimate event.
15
+ - Default limit is 100 events/minute. Override via env
16
+ ETCH_RATE_LIMIT_PER_MINUTE (int; set to 0 to disable entirely).
17
+ - No inter-process locking. Concurrent invocations may double-count
18
+ slightly; the goal is order-of-magnitude protection, not exactness.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import sys
26
+ import time
27
+ from pathlib import Path
28
+
29
+ _STATE_DIR = Path.home() / ".etch-record"
30
+ _STATE_FILE = _STATE_DIR / "rate_state.json"
31
+ _WINDOW_SECONDS = 60.0
32
+ _DEFAULT_LIMIT_PER_MINUTE = 100
33
+
34
+
35
+ def _resolve_limit() -> int:
36
+ """Return current per-minute limit. 0 or negative disables the check."""
37
+ raw = os.environ.get("ETCH_RATE_LIMIT_PER_MINUTE", "").strip()
38
+ if not raw:
39
+ return _DEFAULT_LIMIT_PER_MINUTE
40
+ try:
41
+ return int(raw)
42
+ except ValueError:
43
+ return _DEFAULT_LIMIT_PER_MINUTE
44
+
45
+
46
+ def _load_timestamps(state_file: Path, now: float) -> list[float]:
47
+ """Load and prune stale timestamps. Fails open on any error."""
48
+ if not state_file.exists():
49
+ return []
50
+ try:
51
+ raw = json.loads(state_file.read_text())
52
+ except (OSError, json.JSONDecodeError):
53
+ # Corrupt or unreadable state file: treat as empty. Rate limit
54
+ # is best-effort; a broken state must never block a caller.
55
+ return []
56
+ if not isinstance(raw, list):
57
+ return []
58
+ cutoff = now - _WINDOW_SECONDS
59
+ result: list[float] = []
60
+ for item in raw:
61
+ try:
62
+ ts = float(item)
63
+ except (TypeError, ValueError):
64
+ continue
65
+ if ts >= cutoff:
66
+ result.append(ts)
67
+ return result
68
+
69
+
70
+ def _write_timestamps(state_file: Path, timestamps: list[float]) -> None:
71
+ """Persist pruned timestamps. Fails silently if the FS refuses."""
72
+ try:
73
+ state_file.parent.mkdir(parents=True, exist_ok=True)
74
+ state_file.write_text(json.dumps(timestamps))
75
+ except OSError as exc:
76
+ # Never crash on rate-limit state persistence failure.
77
+ print(
78
+ f"etch-record: warning: could not persist rate-limit state "
79
+ f"({exc}). Rate limiting may be inaccurate.",
80
+ file=sys.stderr,
81
+ )
82
+
83
+
84
+ def check_and_record(
85
+ now: float | None = None,
86
+ state_file: Path | None = None,
87
+ limit_per_minute: int | None = None,
88
+ ) -> tuple[bool, int, int]:
89
+ """Consume one slot from the sliding window.
90
+
91
+ Returns (allowed, count_in_window, limit). If allowed=False the
92
+ caller should refuse to submit the event. If limit<=0 the check is
93
+ disabled and this returns (True, 0, 0) without touching state.
94
+
95
+ Args exist for tests; production callers pass nothing.
96
+ """
97
+ limit = _resolve_limit() if limit_per_minute is None else limit_per_minute
98
+ if limit <= 0:
99
+ return True, 0, 0
100
+ now = time.time() if now is None else now
101
+ sf = state_file if state_file is not None else _STATE_FILE
102
+
103
+ timestamps = _load_timestamps(sf, now)
104
+ count_before = len(timestamps)
105
+ if count_before >= limit:
106
+ # At or over the limit; refuse. Do NOT write this timestamp,
107
+ # so the caller can retry after the window slides.
108
+ return False, count_before, limit
109
+ timestamps.append(now)
110
+ _write_timestamps(sf, timestamps)
111
+ return True, count_before + 1, limit
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: etch-record
3
+ Version: 0.1.0
4
+ Summary: CLI helper: sign any event into your Etch audit chain from the command line.
5
+ Author: Saravanan Jaichandaran
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: click>=8.1
10
+ Requires-Dist: httpx>=0.27
11
+ Description-Content-Type: text/markdown
12
+
13
+ # etch-record
14
+
15
+ Small CLI helper. Signs any event into your Etch audit chain from the command line.
16
+
17
+ Built for the marketing-agent workflow: every research call, draft generation, review decision, and outreach send emits a signed event. Also usable standalone for any local activity you want notarized.
18
+
19
+ **Latest: v0.2.0** — Wave 1 #1 governance flags (`--policy-hash`, `--authority-file`, `--assumptions-file`, `--uncertainty`, `--uncertainty-file`, `--invalidation-file`) attach a signed governance sub-record on the Etch parallel chain. See [CHANGELOG.md](CHANGELOG.md).
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install etch-record
25
+ ```
26
+
27
+ Or from a local checkout during development:
28
+
29
+ ```bash
30
+ cd ~/etch-marketing/etch-record
31
+ pip install -e .
32
+ ```
33
+
34
+ ## Configure
35
+
36
+ Set three env vars in your shell rc (`~/.zshrc` or `~/.bashrc`):
37
+
38
+ ```bash
39
+ export ETCH_PROJECT_ID="your_project_id"
40
+ export ETCH_APP_TOKEN="wm_your_app_token"
41
+ export ETCH_BASE_URL="https://etch.systems" # default; override for local dev
42
+ ```
43
+
44
+ Get `project_id` + `app_token` from your Etch signup provisioning page. `ETCH_BASE_URL` defaults to `https://etch.systems` if unset.
45
+
46
+ ## Use
47
+
48
+ ```bash
49
+ # Simple event
50
+ etch-record "posted X thread about Etch's audit chain"
51
+
52
+ # With tags + evidence
53
+ etch-record "researched contact via Gemini" \
54
+ --tags research,marketing \
55
+ --evidence-json '{"contact":"...","dossier_lines":247}'
56
+
57
+ # Load evidence from a file
58
+ etch-record "drafted 3 message variants" \
59
+ --tags draft,claude \
60
+ --evidence-file drafts_evidence.json
61
+
62
+ # Group events under a session (default = today's ISO date)
63
+ etch-record "approved draft v2" --session-id outreach-2026-07-26 --tags review,approved
64
+
65
+ # Print what would be sent without hitting the API
66
+ etch-record "dry run test" --dry-run
67
+ ```
68
+
69
+ ## Event shape
70
+
71
+ Every call becomes a signed `record_event` MCP tool call on your Etch chain:
72
+
73
+ - `event_type`: `"tool_call"` (only enum value that works for arbitrary marketing events)
74
+ - `session_id`: `--session-id` OR auto-generated as `etch-record-YYYY-MM-DD`
75
+ - `entities`: derived from `--tags`
76
+ - `description`: your quoted string (positional arg)
77
+ - `evidence`: from `--evidence-json` or `--evidence-file`
78
+ - `success`: `true` unless `--failed`
79
+
80
+ The Etch server appends to the SHA-256 Merkle chain, closes epochs at threshold (default 1024 events), hybrid-signs (Ed25519 + SLH-DSA-SHA2-128f), and optionally anchors to Sigstore Rekor + Bitcoin OpenTimestamps.
81
+
82
+ ## Verify
83
+
84
+ Every event is verifiable offline forever:
85
+
86
+ ```bash
87
+ etch-verify \
88
+ --base-url https://etch.systems \
89
+ --project-id your_project_id
90
+ ```
91
+
92
+ ## Governance metadata (Wave 1 #1, v0.2.0)
93
+
94
+ Attach a signed governance sub-record to any event. Any of the five flags below triggers a second call to `POST /v1/etch-chain/governance-record` on your Etch base URL, which hashes the governance object canonically and signs it into the Etch parallel chain.
95
+
96
+ ```bash
97
+ etch-record "KYC decision on customer ABC" \
98
+ --tags kyc,fintech,decision \
99
+ --policy-hash sha256:9f8c... \
100
+ --authority-file authority.json \
101
+ --uncertainty '0.87:hash-lookup-match-rate' \
102
+ --invalidation-file invalidation_conditions.json
103
+ ```
104
+
105
+ `authority.json`:
106
+
107
+ ```json
108
+ {
109
+ "identity": "compliance-officer@acme.example",
110
+ "scope": ["fintech-kyc-decisions"],
111
+ "expires_at": "2026-12-31T23:59:59Z"
112
+ }
113
+ ```
114
+
115
+ `invalidation_conditions.json`:
116
+
117
+ ```json
118
+ [
119
+ {"if": "SOP hash changes", "then": "re-approve required"}
120
+ ]
121
+ ```
122
+
123
+ Assumptions file uses the same shape:
124
+
125
+ ```json
126
+ [
127
+ {"claim": "SOP v3.2 is current", "source_ref": "doc_hash:xyz"}
128
+ ]
129
+ ```
130
+
131
+ Output when both calls succeed:
132
+
133
+ ```
134
+ OK session=etch-record-2026-08-01 event_id=abc-def-123
135
+ OK governance_seq=1 governance_hash=sha256:xyz...
136
+ ```
137
+
138
+ The base event was recorded via the OSS chain; the governance sub-record was signed into the Etch parallel chain and cross-references the event by ID. Both chains verify offline via `etch-verify` (OSS) and `etch-chain-verify` (Etch).
139
+
140
+ ## Exit codes
141
+
142
+ - `0` success (including two-call success when governance flags were set)
143
+ - `1` config error (missing env vars)
144
+ - `2` MCP `record_event` error
145
+ - `3` unexpected exception
146
+ - `4` rate limit (client-side sliding window)
147
+ - `5` governance sub-record failed — the base event was recorded successfully; use the printed `event_id` to retry the governance call
@@ -0,0 +1,11 @@
1
+ etch_record/__init__.py,sha256=25j-Ap7qWSelGbbrzdw-La0SRLXDWcPQTSSvkcZB8B8,104
2
+ etch_record/cli.py,sha256=eAtYvOiOe7lhhVaD_cV7qcYQ4M3M-MWgHITQOcHI4zo,21204
3
+ etch_record/config.py,sha256=HIVK0T4eptS9P09mCpHuQspygT5pN7WA-56RA-O57JA,1213
4
+ etch_record/governance_client.py,sha256=Y9LRDDospH8xkFE8T6Zo1C_1GlvyVB3hjwKUZfSEPgk,4678
5
+ etch_record/mcp_client.py,sha256=idPLPqFteC1l7LUJuM8uOspA-VHkqIip4QtbAuej-nI,6335
6
+ etch_record/rate_limit.py,sha256=0wqo0_50UKTSaegKXFYc0WXho3SsK41GJUOZCpwasko,3960
7
+ etch_record-0.1.0.dist-info/METADATA,sha256=mzP4IgCAteYTFE7T7xC1URW2zbelTaYiKo50ikXMgWc,4590
8
+ etch_record-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
+ etch_record-0.1.0.dist-info/entry_points.txt,sha256=N1EfY2v1MXaRlLkpNIXwt-jSh4aBKqabqpNtrHa_VHQ,53
10
+ etch_record-0.1.0.dist-info/licenses/LICENSE,sha256=YvYKNXVQm7ccJcHd0CzLJC9Ma37bdLeC03HZ6_K_v3k,1079
11
+ etch_record-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ etch-record = etch_record.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Saravanan Jaichandaran
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.