openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
// Build script: generates Rust types from JSON Schema files in schemas/
//
// Pipeline: schemas/*.schema.json → merge into combined schema → typify → src/generated/types.rs
// Also writes src/generated/known_values.rs containing x-known-values
// vocabularies; src/core/envelope/known_types.rs cross-checks its variant
// list against these at test time (fail-loud on schema drift).

use std::path::Path;

fn main() {
    // Re-run if any schema file changes
    println!("cargo:rerun-if-changed=schemas/");

    // PostHog project key baking. Reads OPENLATCH_POSTHOG_KEY at build time
    // and re-emits it as a compile-time constant via env!(). When unset, the
    // baked constant is empty and the telemetry subsystem refuses to start
    // (preserves invariant I1 — zero network before consent).
    //
    // Production: set in CI via `env: { OPENLATCH_POSTHOG_KEY: secrets.POSTHOG_PROJECT_KEY }`.
    // Developers: leave unset (telemetry off) or export locally for testing.
    println!("cargo:rerun-if-env-changed=OPENLATCH_POSTHOG_KEY");
    let key = std::env::var("OPENLATCH_POSTHOG_KEY").unwrap_or_default();
    println!("cargo:rustc-env=OPENLATCH_POSTHOG_KEY={key}");
    println!("cargo:rerun-if-env-changed=OPENLATCH_POSTHOG_HOST");
    let host = std::env::var("OPENLATCH_POSTHOG_HOST")
        .unwrap_or_else(|_| "https://eu.i.posthog.com".to_string());
    println!("cargo:rustc-env=OPENLATCH_POSTHOG_HOST={host}");

    // Sentry DSN baking (crash reporting). Mirrors the PostHog key pattern —
    // unset at build time → empty string → runtime short-circuits to a no-op
    // guard. See src/core/crash_report/ and
    // .brainstorms/2026-04-13-sentry-integration.md Decision 3.
    println!("cargo:rerun-if-env-changed=OPENLATCH_SENTRY_DSN");
    let dsn = std::env::var("OPENLATCH_SENTRY_DSN").unwrap_or_default();
    println!("cargo:rustc-env=OPENLATCH_SENTRY_DSN={dsn}");

    // Release SHA baking for Sentry `release` field. Priority:
    //   GITHUB_SHA → OPENLATCH_RELEASE_SHA → `git rev-parse HEAD` → semver fallback.
    println!("cargo:rerun-if-env-changed=GITHUB_SHA");
    println!("cargo:rerun-if-env-changed=OPENLATCH_RELEASE_SHA");
    let sha = resolve_release_sha();
    println!("cargo:rustc-env=OPENLATCH_RELEASE_SHA={sha}");

    // The version this build reports as its IDENTITY. Distinct from
    // CARGO_PKG_VERSION, which release-please only moves when a release is cut.
    println!("cargo:rustc-env=OPENLATCH_VERSION={}", resolve_version());

    let schemas_dir = Path::new("schemas");
    let out_path = std::path::PathBuf::from("src/generated/types.rs");
    let known_values_path = std::path::PathBuf::from("src/generated/known_values.rs");

    // Read all schema files
    let enums_raw: serde_json::Value = read_schema(schemas_dir, "enums.schema.json");
    let struct_files = [
        "event-envelope.schema.json",
        "verdict-response.schema.json",
        "cloud-ingestion-request.schema.json",
        "cloud-ingestion-response.schema.json",
        "auth-me-response.schema.json",
        "policy-bundle.schema.json",
    ];

    // Extract x-known-values vocabularies from the enums schema and emit them
    // as Rust constants. This is the schema side of the
    // schema-vs-known_types.rs cross-check enforced in known_types.rs tests.
    write_known_values(&enums_raw, &known_values_path);

    // Build a single combined schema with ALL definitions in $defs.
    // This avoids duplicate type generation when processing multiple schemas.
    let mut combined = build_combined_schema(&enums_raw, schemas_dir, &struct_files);

    // D-U22. typify hard-panics on if/then/else, so the policy-bundle gate must
    // not reach it. Stripping here — after the merge, before add_root_schema — is
    // the one choke point every schema path passes through, including the local
    // $defs merged inside build_combined_schema.
    strip_conditionals(&mut combined);

    // Configure typify
    let mut settings = typify::TypeSpaceSettings::default();
    settings.with_struct_builder(false);
    settings.with_derive("PartialEq".parse().unwrap());

    // Register the hand-written CloudEvents lens enums. Typify emits no code
    // for these $defs; references from event-envelope.schema.json resolve to
    // the hand-written paths. See src/core/envelope/known_types.rs.
    settings.with_replacement(
        "HookEventType",
        "crate::core::envelope::known_types::HookEventType",
        [
            typify::TypeSpaceImpl::FromStr,
            typify::TypeSpaceImpl::Display,
        ]
        .into_iter(),
    );
    settings.with_replacement(
        "AgentType",
        "crate::core::envelope::known_types::AgentType",
        [
            typify::TypeSpaceImpl::FromStr,
            typify::TypeSpaceImpl::Display,
        ]
        .into_iter(),
    );

    let mut type_space = typify::TypeSpace::new(&settings);

    let root_schema: schemars::schema::RootSchema =
        serde_json::from_value(combined).expect("failed to parse combined schema");
    type_space
        .add_root_schema(root_schema)
        .expect("failed to process combined schema");

    // Generate and format the output
    let tokens = type_space.to_stream();
    let ast = syn::parse2::<syn::File>(tokens).expect("failed to parse generated tokens");
    let formatted = prettyplease::unparse(&ast);

    let output = format!(
        "// AUTO-GENERATED by build.rs from schemas/*.schema.json\n\
         // DO NOT EDIT — changes will be overwritten on next build.\n\
         // To modify types, edit the JSON Schema files in schemas/ and rebuild.\n\n\
         {formatted}\n"
    );

    // Write to temp file, run rustfmt, then compare with existing to avoid rebuild loops
    let tmp_path = out_path.with_extension("rs.tmp");
    std::fs::write(&tmp_path, &output).unwrap();
    let _ = std::process::Command::new("rustfmt")
        .arg(tmp_path.as_os_str())
        .status();
    let final_output = std::fs::read_to_string(&tmp_path).unwrap_or(output);
    let _ = std::fs::remove_file(&tmp_path);

    // Only write if content actually changed
    let needs_write = match std::fs::read_to_string(&out_path) {
        Ok(existing) => existing != final_output,
        Err(_) => true,
    };
    if needs_write {
        std::fs::write(&out_path, final_output).unwrap();
    }
}

/// Resolve the release SHA that Sentry will use as the `release` field.
/// Priority: CI-provided `GITHUB_SHA` → manual `OPENLATCH_RELEASE_SHA`
/// override → local `git rev-parse HEAD` → `CARGO_PKG_VERSION` fallback
/// (the last covers `cargo install` from crates.io where there's no .git).
fn resolve_release_sha() -> String {
    if let Ok(sha) = std::env::var("GITHUB_SHA") {
        if !sha.is_empty() {
            return sha;
        }
    }
    if let Ok(sha) = std::env::var("OPENLATCH_RELEASE_SHA") {
        if !sha.is_empty() {
            return sha;
        }
    }
    if let Ok(output) = std::process::Command::new("git")
        .args(["rev-parse", "HEAD"])
        .output()
    {
        if output.status.success() {
            if let Ok(s) = String::from_utf8(output.stdout) {
                let trimmed = s.trim();
                if !trimmed.is_empty() {
                    return trimmed.to_string();
                }
            }
        }
    }
    format!("v{}", env!("CARGO_PKG_VERSION"))
}

/// Resolve the version string this build reports as its own identity.
///
/// `Cargo.toml`'s version belongs to release-please and only moves when a
/// release is cut, so every build from `main` between two tags reports the
/// version of the *previous* release. That makes the string useless for the one
/// question the platform's fleet-readiness check asks — "does this client
/// understand request rules?" — because a `main` build carrying the feature and
/// the released tag that predates it both say `0.1.14`.
///
/// `git describe --tags --long` tells them apart:
///
/// ```text
/// exactly on a tag    v0.1.14-0-g6157e1a   ->  0.1.14
/// 83 commits past it  v0.1.14-83-g6157e1a  ->  0.1.15-dev.83+g6157e1a
/// ```
///
/// **The `-dev.N` prerelease is the load-bearing part**, and the two obvious
/// alternatives do not work:
///
/// * a bare `-snapshot` suffix orders no two `main` builds against each other;
/// * `+build.metadata` alone is **ignored** when comparing precedence
///   (SemVer §10), so a hash-only suffix carries no ordering at all. It rides
///   along here purely as provenance.
///
/// `-dev.N` orders correctly in all three directions that matter:
/// `0.1.14 < 0.1.15-dev.83 < 0.1.15` (SemVer §11 — a prerelease sorts below
/// its own release and above the previous one), and `dev.83 < dev.100` compares
/// numerically because an all-digit prerelease identifier is compared as an
/// integer, not as a string.
///
/// The base is the patch-bumped **tag**, never `CARGO_PKG_VERSION`. On a
/// release-please branch `Cargo.toml` is already bumped to the version being
/// released while the tag still points at the previous one, so bumping the
/// manifest would stamp `0.1.16-dev.N` and claim to be *newer* than the release
/// it precedes. Bumping the tag is correct in every case: a dev build sorts
/// above the last release and below the next one whether that next one turns
/// out to be a patch or a minor.
///
/// Falls back to `CARGO_PKG_VERSION` whenever git cannot answer — a
/// `cargo install` from crates.io has no `.git`, and neither does the container
/// used for cross-compilation. Both then look exactly like a release build,
/// which is the honest answer when there is nothing to distinguish them with.
///
/// Deliberately **not** paired with a `cargo:rerun-if-changed=.git/…`: that
/// would recompile the crate on every commit, and a stamp that lags by a few
/// commits costs nothing (`N` is provenance, and a dev build reads as
/// "not a release" at any `N`). CI builds from fresh checkouts, so the released
/// artifact is always exact. `resolve_release_sha` above has the same property.
fn resolve_version() -> String {
    let pkg = env!("CARGO_PKG_VERSION").to_string();
    // `--match` excludes the `schemas-v*` line, which shares this repo and is
    // otherwise the nearest tag — `git describe` unfiltered answers
    // `schemas-v1.0.0-2-g6157e1a`, whose "version" is the wire schemas', not
    // the client's.
    let Some(described) = git_output(&["describe", "--tags", "--long", "--match", "v[0-9]*"])
    else {
        return pkg;
    };
    // "<tag>-<commits>-g<hash>" — split from the RIGHT, because only the two
    // trailing fields have a fixed shape.
    let mut parts = described.rsplitn(3, '-');
    let (Some(hash), Some(commits), Some(tag)) = (parts.next(), parts.next(), parts.next()) else {
        return pkg;
    };
    let Ok(commits) = commits.parse::<u32>() else {
        return pkg;
    };
    if commits == 0 {
        // Sitting exactly on a release tag: the manifest IS the answer.
        return pkg;
    }
    match bump_patch(tag.trim_start_matches('v')) {
        Some(base) => format!("{base}-dev.{commits}+{hash}"),
        None => pkg,
    }
}

/// `"0.1.14"` -> `"0.1.15"`. `None` for anything that is not a plain `x.y.z`,
/// so an unexpected tag shape degrades to `CARGO_PKG_VERSION` rather than
/// stamping a version string that is not SemVer.
fn bump_patch(version: &str) -> Option<String> {
    let parts: Vec<&str> = version.split('.').collect();
    let [major, minor, patch] = parts[..] else {
        return None;
    };
    let patch: u64 = patch.parse().ok()?;
    if major.parse::<u64>().is_err() || minor.parse::<u64>().is_err() {
        return None;
    }
    Some(format!("{major}.{minor}.{}", patch + 1))
}

/// Run a git command in the package root, returning trimmed stdout on success.
fn git_output(args: &[&str]) -> Option<String> {
    let output = std::process::Command::new("git").args(args).output().ok()?;
    if !output.status.success() {
        return None;
    }
    let text = String::from_utf8(output.stdout).ok()?;
    let trimmed = text.trim();
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed.to_string())
    }
}

fn read_schema(dir: &Path, filename: &str) -> serde_json::Value {
    let path = dir.join(filename);
    let content = std::fs::read_to_string(&path)
        .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
    serde_json::from_str(&content)
        .unwrap_or_else(|e| panic!("failed to parse {}: {e}", path.display()))
}

/// Build a single combined JSON Schema with all types as named $defs.
///
/// Strategy:
/// 1. Collect enum $defs from enums.schema.json
/// 2. For each struct schema, add the struct as a named $def (using its title)
/// 3. Also merge any local $defs from the struct schema (e.g., CloudEventPayload)
/// 4. Rewrite all cross-file $ref paths to local $defs references
/// 5. Return a single schema with all $defs — typify generates one type per named $def
fn build_combined_schema(
    enums: &serde_json::Value,
    schemas_dir: &Path,
    struct_files: &[&str],
) -> serde_json::Value {
    let mut all_defs = serde_json::Map::new();

    // 1. Add enum definitions
    if let Some(defs) = enums.get("$defs").and_then(|d| d.as_object()) {
        for (key, value) in defs {
            all_defs.insert(key.clone(), value.clone());
        }
    }

    // 2. Add struct schemas as named definitions (keyed by title)
    for filename in struct_files {
        let path = schemas_dir.join(filename);
        if !path.exists() {
            continue;
        }

        let mut schema = read_schema(schemas_dir, filename);

        // Extract and merge any local $defs (e.g., CloudEventPayload in cloud-ingestion-request)
        if let Some(local_defs) = schema.as_object_mut().and_then(|obj| obj.remove("$defs")) {
            if let Some(local_defs_map) = local_defs.as_object() {
                for (key, value) in local_defs_map {
                    let mut resolved = value.clone();
                    rewrite_refs(&mut resolved);
                    all_defs.insert(key.clone(), resolved);
                }
            }
        }

        // Get the title to use as the $def name
        let title = schema
            .get("title")
            .and_then(|t| t.as_str())
            .unwrap_or_else(|| panic!("{filename} must have a title"))
            .to_string();

        // Remove metadata fields that don't belong in a $def
        if let Some(obj) = schema.as_object_mut() {
            obj.remove("$schema");
            obj.remove("$id");
            obj.remove("title");
        }

        // Rewrite cross-file $ref to local references
        rewrite_refs(&mut schema);

        all_defs.insert(title, schema);
    }

    // 3. Build the combined root schema
    serde_json::json!({
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "$defs": all_defs
    })
}

/// Strip JSON Schema conditional keywords from the in-memory tree handed to
/// typify (D-U22).
///
/// typify 0.6 does not ignore conditionals — it hard-panics on them
/// (`typify-impl-0.6.2/src/merge.rs:293: not implemented: if/then/else schemas
/// are not supported`), and the `allOf`-wrapped form panics at the same line.
/// `schemas/policy-bundle.schema.json` expresses its per-kind/per-action gate
/// exactly that way, so codegen gets a stripped copy while the artifact on disk
/// keeps the gate: the platform validates against it at authoring time, and
/// `src/core/policy/validate.rs` embeds the raw file to check each rule
/// individually at bundle load. One artifact, two views of it.
///
/// The strip is deliberately narrow. `if`, `then` and `else` go unconditionally,
/// but an `allOf` loses only the members that are conditional wrappers — a plain
/// `allOf` is a merge directive typify supports and uses elsewhere. The key
/// itself is removed only when that empties the array, since typify rejects an
/// empty `allOf`.
fn strip_conditionals(value: &mut serde_json::Value) {
    match value {
        serde_json::Value::Object(map) => {
            map.remove("if");
            map.remove("then");
            map.remove("else");

            if let Some(serde_json::Value::Array(members)) = map.get_mut("allOf") {
                members.retain(|member| !is_conditional_wrapper(member));
                if members.is_empty() {
                    map.remove("allOf");
                }
            }

            for v in map.values_mut() {
                strip_conditionals(v);
            }
        }
        serde_json::Value::Array(arr) => {
            for v in arr {
                strip_conditionals(v);
            }
        }
        _ => {}
    }
}

/// True when an `allOf` member exists only to carry a conditional, and so has
/// nothing left to contribute to the merge once the conditional is gone.
///
/// A member carrying a conditional **and** real schema keywords is deliberately
/// not a wrapper. Dropping it whole would delete those keywords from typify's
/// view and emit a type narrower than the schema describes — and a narrower
/// `deny_unknown_fields` struct rejects the whole bundle at deserialization,
/// which is the failure this strip exists to avoid. Such a member is kept, and
/// the walk in [`strip_conditionals`] removes only its `if`/`then`/`else` keys.
///
/// Annotations are ignored when deciding, since they contribute nothing to the
/// merged type: `{if, then, $comment}` is still a pure wrapper.
fn is_conditional_wrapper(member: &serde_json::Value) -> bool {
    let Some(map) = member.as_object() else {
        return false;
    };
    let carries_conditional =
        map.contains_key("if") || map.contains_key("then") || map.contains_key("else");
    carries_conditional
        && map.keys().all(|key| {
            matches!(
                key.as_str(),
                "if" | "then" | "else" | "$comment" | "description" | "title"
            )
        })
}

/// Rewrite relative $ref paths (e.g., "enums.schema.json#/$defs/AgentType")
/// to local $defs references (e.g., "#/$defs/AgentType").
/// Also rewrites internal $ref like "#/$defs/X" (which are already local).
fn rewrite_refs(value: &mut serde_json::Value) {
    match value {
        serde_json::Value::Object(map) => {
            if let Some(ref_val) = map.get_mut("$ref") {
                if let Some(ref_str) = ref_val.as_str() {
                    // Rewrite "filename.schema.json#/$defs/X" → "#/$defs/X"
                    if let Some(fragment) = ref_str.find("#/") {
                        let local_ref = &ref_str[fragment..];
                        *ref_val = serde_json::Value::String(local_ref.to_string());
                    }
                }
            }
            for v in map.values_mut() {
                rewrite_refs(v);
            }
        }
        serde_json::Value::Array(arr) => {
            for v in arr {
                rewrite_refs(v);
            }
        }
        _ => {}
    }
}

/// Extract `x-known-values` arrays from HookEventType and AgentType in
/// `schemas/enums.schema.json` and emit them as Rust `&[&str]` constants
/// into `src/generated/known_values.rs`. The hand-written
/// `src/core/envelope/known_types.rs` tests compare its canonical variant
/// list against these constants — mismatch fails the tests at CI time
/// rather than letting client and schema drift silently.
fn write_known_values(enums: &serde_json::Value, out_path: &Path) {
    let hook = extract_known_values(enums, "HookEventType");
    let agent = extract_known_values(enums, "AgentType");

    let body = format!(
        "// AUTO-GENERATED by build.rs from schemas/enums.schema.json x-known-values.\n\
         // DO NOT EDIT — changes will be overwritten on next build.\n\n\
         pub const SCHEMA_HOOK_EVENT_TYPES: &[&str] = &[\n{}];\n\n\
         pub const SCHEMA_AGENT_TYPES: &[&str] = &[\n{}];\n",
        hook.iter()
            .map(|v| format!("    {},\n", rust_str_literal(v)))
            .collect::<String>(),
        agent
            .iter()
            .map(|v| format!("    {},\n", rust_str_literal(v)))
            .collect::<String>(),
    );

    let needs_write = match std::fs::read_to_string(out_path) {
        Ok(existing) => existing != body,
        Err(_) => true,
    };
    if needs_write {
        std::fs::write(out_path, body).expect("failed to write known_values.rs");
    }
}

fn extract_known_values(enums: &serde_json::Value, def_name: &str) -> Vec<String> {
    let arr = enums
        .get("$defs")
        .and_then(|d| d.get(def_name))
        .and_then(|d| d.get("x-known-values"))
        .and_then(|v| v.as_array())
        .unwrap_or_else(|| panic!("{def_name} must declare x-known-values as an array"));
    arr.iter()
        .map(|v| {
            v.as_str()
                .unwrap_or_else(|| panic!("{def_name} x-known-values entries must be strings"))
                .to_string()
        })
        .collect()
}

fn rust_str_literal(s: &str) -> String {
    // Simple escape: wire values are ASCII alphanumeric + _ + - so no
    // non-trivial escaping needed. Quote-wrap and let rustfmt handle layout.
    let escaped: String = s
        .chars()
        .flat_map(|c| match c {
            '"' => vec!['\\', '"'],
            '\\' => vec!['\\', '\\'],
            _ => vec![c],
        })
        .collect();
    format!("\"{escaped}\"")
}