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
# AGENT.md — Instructions for AI coding agents

## Language & Tooling
- **Language**: Rust, 2021 edition
- **Build**: `cargo build`
- **Test**: `cargo test`
- **Lint**: `cargo clippy --all-targets -- -D warnings`
- **Format**: `cargo fmt`
- **Coverage**: `cargo tarpaulin --out xml`

## Windows portability gate

Development and the whole PR pipeline run on Linux, so Windows breakage used to
surface only at release time — `publish.yml` was the first thing in the repo to
compile for MSVC, and its `verify-publish` job ran the binary on Windows *after*
the npm publish. Two gates close that window; run both before pushing anything
that touches a path, a permission, a process spawn or a `cfg`.

- **Compiles for Windows**`cargo xwin clippy --target x86_64-pc-windows-msvc
  --all-features --all-targets -- -D warnings` (needs `cargo install cargo-xwin`
  and `clang`; downloads the MSVC CRT + SDK once). Same command as
  `pr-checks.yml::windows-cross`, which blocks every PR — 9m31s cold, and it
  runs beside `test` rather than ahead of it, so it costs no critical path. This covers the 73
  `cfg(unix|windows|target_os)` sites and the unix-only APIs behind them.
- **Behaves on Windows**`python3 ci/check-portability.py .` fails on POSIX
  assumptions that compile for MSVC anyway: absolute `/tmp`-style literals,
  separators inside a `join()` segment, raw `HOME`/`APPDATA` reads that bypass
  the resolver. It ignores `cfg(test)`, POSIX-only `cfg` items, and lines
  carrying a `// portability-ok: <reason>` waiver — the waiver needs a reason,
  it is a documented exception rather than a mute button. Also wired into
  pre-commit and the `quality` job.

Two helpers exist so the platform difference lives in one place instead of at
every call site. Use them rather than re-deriving the branch:

- `core::fs_secure::restrict_to_owner(path)` — owner-only protection for a file
  holding a secret. `0600` on Unix, an explicit owner-only DACL on Windows.
  Every credential / key / token write goes through it.
- `core::path_compat::{dedup_key, display_path}``dedup_key` answers "same
  file?" for in-process bookkeeping (cache keys, dedup sets, prefix filters):
  case-folded on Windows, identity on Unix. `display_path` strips the `\\?\`
  verbatim prefix `canonicalize` returns. Never hash `dedup_key` onto the wire
  `configpathhash` is the platform's correlation key.

Neither gate executes anything on Windows. `.github/workflows/windows-checks.yml`
does, on `windows-latest`, post-merge (or via `workflow_dispatch` for a branch
you know is platform-sensitive) — a private-repo Windows runner bills at 2x, so
it stays off the per-PR path. Anything only a real Windows box can settle —
`dirs::home_dir()` reading `FOLDERID_RoamingAppData` rather than `%APPDATA%`,
`\\?\` verbatim paths out of `canonicalize`, case-insensitive path comparison,
rename-over-open-file — is that job's business, not the cross-check's.

## macOS code signing

- **Dev setup (once)**: `ci/setup-dev-codesign-macos.sh`, then
  `export OPENLATCH_CODESIGN_IDENTITY="OpenLatch Dev Signing"`
- **After each build**: `ci/codesign-macos.sh target/release/openlatch target/release/openlatch-hook`
- **Why it matters**: cargo's linker ad-hoc-signs Mach-O output, and an ad-hoc
  signature's designated requirement *is* the CDHash — which changes on every
  compile. macOS keys keychain ACLs to that identity, so each rebuild makes the
  OS treat the binary as a new program and re-prompt for keychain access;
  "Always Allow" is void by the next build. Signing with a stable identity makes
  the DR `identifier "…" and certificate leaf = H"…"`, which survives rebuilds.
  Verify with `codesign -d -r- <bin>` before and after a rebuild — it must be
  byte-identical.
- Identifiers are **pinned** in `ci/codesign-macos.sh` (`ai.openlatch.client`,
  `ai.openlatch.hook`). codesign otherwise derives them from the filename, so
  renaming a packaged artifact would silently mint a new identity. Adding a
  third binary requires adding it to `identifier_for()`.
- Releases sign with the Developer ID cert in `publish.yml` (`MACOS_CODESIGN_P12`
  / `_P12_PASSWORD` / `_IDENTITY` secrets), between the strip step and the size
  gate. Missing secrets → warning + ad-hoc binaries, so forks stay green.

## Credential lookup order

- `OPENLATCH_API_KEY` env → OS keychain → `credentials.enc`
  (`core/auth/mod.rs::retrieve_credential`). The env var is checked **first** so
  an explicit credential can actually override a stale keychain entry, and so
  headless callers (launchd/systemd units, containers, CI) never trigger a macOS
  keychain dialog. This reverses the original D-06 ordering.
- `KeyringCredentialStore::retrieve` memoizes the keychain read per process —
  failures included, so a denied dialog does not re-prompt. `store` / `delete`
  invalidate the memo; `retrieve_async` delegates to the sync path so both share
  one memo.
- The hook's HMAC key (`core/hook_state/key.rs`) reads `~/.openlatch/hmac.key`
  **before** the keychain. `openlatch-hook` resolves it once per hook event in a
  fresh process, so a keychain round-trip there is paid per event and blows the
  <3ms budget. The keychain remains the store of record (written on creation,
  read when the file is absent).

## Architecture
- Runtime enforcement node — the on-host node of the OpenLatch runtime control plane; wraps raw hook events in envelopes and forwards to openlatch-platform
- **Local-authoritative enforcement** — the daemon evaluates a resident policy
  bundle in-process (`src/core/policy/`). The verdict path never touches the
  network: it reads an `ArcSwap` handle in memory, decides, and returns. The
  cloud authors and serves the rules; it does not decide. Everything beyond
  deterministic rule matching (correlation, cross-host analysis, scanning)
  remains cloud-side.
- The client does NOT normalize agent-specific formats; it wraps raw events as-is

## Performance
- Latency target: <200ms round-trip for hook events
- Binary size target: `openlatch` <30MB, `openlatch-hook` <20MB, minimal dependencies

## Key Constraints
- Hook event processing is envelope wrapping (add agent_type, version, session_id)
  plus local policy evaluation — the envelope `data` itself is never rewritten
  by the evaluator
- Privacy filtering is regex-based credential redaction, applied before forwarding
- Local logging to `~/.openlatch/logs/` for offline sync
- HTTP server runs on localhost:7443

## Policy Engine
- Module: `src/core/policy/` — a **leaf**: no `daemon/`, `cli/` or sibling
  `core/` imports, with exactly two documented exceptions —
  `crate::core::error` for the `ERR_BUNDLE_*` constants, and `store.rs` taking
  its base directory as a `PathBuf` parameter instead of calling
  `config::openlatch_dir()`. `core/cloud/` and `core/policy/` must not import
  each other in either direction.
- Declared `#[cfg(feature = "full-cli")] pub mod policy;` in `src/core/mod.rs`.
  It depends on `sha2` and `arc-swap`, both `full-cli`-gated; an ungated
  declaration breaks
  `cargo build --release --no-default-features --bin openlatch-hook`.
- Files: `matcher.rs` (fully-anchored glob, exactly two metacharacters `*` and
  `?`, no regex/classes/escapes), `evaluate.rs` (trigger, normalization,
  most-restrictive-wins resolution), `store.rs` (`~/.openlatch/policy/`  `bundle.json` written first, `bundle.meta.json` second as the commit marker;
  the body digest is re-verified on **every** load, not just after a fetch).
- Poller: `src/daemon/policy_poller.rs``GET /api/v1/policy/bundle` with
  `If-None-Match`, ±10% jitter on every interval, and a boot fetch before the
  first timer tick. Every poll also carries `X-OpenLatch-Agent-Id: <agent_id>`
  so the platform can compose `client_config.agent_context` for THIS agent;
  the id is threaded into `run_policy_poller` as its own parameter (never a
  `PolicyConfig` field — `[policy]` is user-settable TOML, the id is
  provisioned). `None` (pre-`openlatch init`) **omits** the header rather than
  sending it empty; the platform then serves the org bundle without a context
  and every scoped rule matches nothing — the designed degrade.
  **The cached `ETag` is bound to the `agent_id` it was fetched under.**
  `bundle.meta.json` carries that id beside the tag, and `If-None-Match` is sent
  only when the two agree (absent counts as a difference). The body is composed
  per agent, so revalidating with a tag minted under another identity — a fleet
  upgrading onto this build, whose meta files predate the header — would earn a
  `304` for a bundle it has never held and leave the install context-less until
  the org's rules next changed. The mismatch costs exactly one full download.
- Verdict path: `src/daemon/handlers.rs::process_envelope` reads
  `AppState::policy`. Evaluation runs on the raw command **before** the privacy
  filter and **before** the dedup early-return.
- **No expiry.** A resident bundle keeps enforcing offline forever. A failed
  refresh is **fail-static** — the last-known-good bundle stays active. Genuine
  fail-open exists in only two places: no bundle was ever fetched, and the hook
  binary being unable to reach the daemon.
- Wire contract: `schemas/policy-bundle.schema.json` (typify-generated into
  `src/generated/types.rs`). No floats, `signature` required-and-nullable,
  `kind`/`action` open strings so an unknown rule is skipped rather than taking
  the document down.
- **Agent-scoped rules** (`conditions` + `client_config.agent_context`): a
  command rule may carry `conditions: [{field: "agent.function", op: "in",
  value: [<AgentFunction>…]}]` and then applies only when this install's
  `client_config.agent_context.function` is in the set — decided locally in
  `evaluate.rs` against the resident bundle, exactly like `match_pattern`; the
  network is never on the verdict path. Absent/empty `conditions`  unconditional. Several conditions AND together. **Absent context ⇒ scoped
  rules match nothing**`None` is not `unknown`; `"unknown"` is an ordinary
  platform-assigned value, never a wildcard. v1 is command-plane only (a
  request rule carrying `conditions` is gate-rejected per rule).
  Two deliberately different types for the same 14-value vocabulary:
  `conditions[].value` items `$ref` the **closed** `AgentFunction` enum
  (`schemas/enums.schema.json#/$defs/AgentFunction`, cross-file like
  `ChurnLayer` — both validators slice `rules.items` out of the document, so a
  bundle-local `#/$defs` would not resolve there), and an out-of-vocabulary
  value fails **that one rule** at deserialization (`unrecognized_field`, the
  bundle stays active); `agent_context.function` is an **open string** with
  `x-known-values`, parsed to `AgentFunction` at `ResidentBundle::from_bundle`
  (a *string* value this build cannot parse ⇒ `None`, logged), because
  `client_config` is deserialized as one typed object *outside* the per-rule
  tolerance — an enum there would fail the whole bundle, fleet-wide
  fail-static, on a single new value (the `params.mechanism` reasoning). The
  tolerance is over the vocabulary, not the shape: `"function": 42` or a
  non-object `agent_context` still fails the whole document (`OL-1212`,
  last-known-good keeps enforcing) — the same exposure `capture_identity_signals`
  has carried since I-1. Adding a function value is a client release either way
  (PRD D9).
  End-to-end proof: `tools/e2e` `openlatch-e2e blocking` (disk-seeded,
  cloud-off, one daemon per `agent_context` posture) — the client half of the
  offline-deny recipe; it runs in `all` on the release matrix, PR CI runs
  `smoke` only, so the PR-time gate is the Rust suite in
  `src/core/policy/{mod,evaluate,validate}.rs`.
- v1 evaluates Claude Code `Bash` only (`SHELL_TOOL_NAMES`) — the other agents
  have no deny translator in `src/hook_output/`, so a deny would be silently
  discarded before the developer saw it.
- Config section `[policy]`: `enabled` (ships **true** — secure-by-default;
  `false` is a complete off switch, not observe mode), `poll_interval_secs`
  (300), `stale_warn_after_secs` (86400). Env: `OPENLATCH_POLICY_ENABLED`,
  `OPENLATCH_POLICY_POLL_INTERVAL_SECS`, `OPENLATCH_POLICY_STALE_WARN_SECS`.
- Config section `[boundary]`: `enabled` (ships **true** — secure-by-default),
  `port` (default 7600). Env: `OPENLATCH_BOUNDARY_ENABLED`,
  `OPENLATCH_BOUNDARY_PORT`; CLI `openlatch start --boundary-port <P>`. Opt out
  per-install with `openlatch init --no-boundary`.
- **A non-default boundary port means an isolated instance**: it binds the port
  but does NOT write or clear `~/.claude/settings.json` — that file is
  machine-global and belongs to the daemon on the default port. Route sessions to
  an isolated instance per-session with `ANTHROPIC_BASE_URL=http://127.0.0.1:<P>`.
  `BoundaryConfig::owns_agent_wiring()` is the single predicate; daemon wire,
  daemon unwire, `openlatch stop`'s net and the `doctor` check all gate on it.
- **The daemon owns `ANTHROPIC_BASE_URL`, and nothing else writes it.** It is
  written after the pinned-port bind succeeds, removed on teardown, and cleared
  at startup when the boundary is off — so the agent config names a listener iff
  one exists. A first bind that fails is a startup error (OL-BND-PORT, exit
  non-zero, config untouched); a serve error after a successful first bind stays
  on the supervisor's infinite retry. `openlatch stop` is the way back to a
  direct provider connection; there is no `boundary enable` / `disable`.
- **A bind is necessary but not sufficient — the write is gated on a round
  trip.** `boundary::preflight::probe` sends a credential-less `POST
  /v1/messages` through our own listener; an upstream answer (401 included) opens
  the gate, our synthetic 502 (`x-openlatch-upstream: unreachable`) keeps it
  shut. The `boundary-wiring` supervised task owns this for the daemon's whole
  life: its first tick is the install-time gate, later ticks re-probe only when
  `pass_through_failures()` grows (wired) or on a 60 s→300 s backoff (unwired),
  so the wiring follows provider reachability with no restart. A shut gate is
  degraded-but-working: sessions go direct, nothing is captured, `init` exits
  non-zero (OL-BND-PREFLIGHT) with hooks/daemon/supervision intact, and the
  verdict is readable from `GET /admin/boundary/status` (`wired`, `preflight`,
  `preflight_error`). The probe carries `x-openlatch-preflight`, which suppresses
  its economics event and is stripped before the request leaves for the provider.
  `[boundary] upstream` / `OPENLATCH_BOUNDARY_UPSTREAM` aims the forwarder (and
  therefore the gate) somewhere else — it exists so the harness can prove this
  without the public internet, not as a gateway setting.
- **Two boundary env vars are test seams, not knobs.** `OPENLATCH_BOUNDARY_DEFAULT_PORT`
  moves what counts as *the default* port — `boundary::default_boundary_port()`,
  read by the bind, the write and `owns_agent_wiring()` alike, so there is still
  exactly one number and D-25 holds. `OPENLATCH_BOUNDARY_WIRING_TICK_MS` shortens
  the wiring loop's 60 s cadence (floored at 50 ms). `tests/boundary_wiring.rs`
  sets both so each test owns an ephemeral default port and observes the
  unwire/re-wire transitions; before that seam existed those tests skipped
  themselves on any box already running a daemon — i.e. exactly the boxes where
  the invariant mattered. Neither belongs in a shipped environment.
- Error code range: OL-1210–1213 (fetch failed / rejected / invalid / stale).
- `/metrics`: `policy_enabled`, `policy_has_bundle`, `policy_revision`,
  `policy_bundle_age_seconds`, `policy_last_poll_ok_secs`,
  `policy_agent_function` (the resident `agent_context.function`, or `null`  which covers "no context arrived" and "a value this build cannot parse"
  alike, because both mean scoped rules match nothing).
- Six outbound CloudEvents extensions: `olverdict`, `olverdictshadow`,
  `olpolicyruleid`, `olpolicybundlerev`, `olpolicybundleage`, `olpolicyoffline`
  — see `.claude/rules/envelope-format.md`.

## Cloud Event Batching
- Module: `src/core/cloud/worker.rs` — events accumulate in the worker and are
  POSTed to `/api/v1/events/ingest` as one multi-event CloudEvents batch,
  flushed on **size or time, whichever comes first**. The wire format did not
  change (always `application/cloudevents-batch+json`, always a JSON array);
  the array simply stopped being single-element.
- Config section `[cloud]`: `batch_max_events` (50 — **clamped to `1..=100` at
  load time**; the platform hard-rejects larger batches and 0 would leave the
  accumulator with no reachable size trigger; `1` restores the historical one
  POST per event), `batch_max_wait_ms` (5000 — the deadline is anchored to the
  **first** buffered event and is never reset by later ones, so it bounds
  forwarding latency). Env: `OPENLATCH_CLOUD_BATCH_MAX_EVENTS`,
  `OPENLATCH_CLOUD_BATCH_MAX_WAIT_MS`.
- Non-configurable wire caps: `MAX_BATCH_EVENTS` = 100 elements,
  `MAX_BATCH_BYTES` = 262_144 serialized bytes. A single event exceeding the
  byte cap is dropped with `OL-1002` rather than wedging the queue.
- Durability is unchanged: a failed batch retries once, then spools **every**
  event individually to `outbox.jsonl` — one line per event, same file format.
  The drain re-groups contiguous entries under the same caps.
- Counters stay per-event: `CloudState::record_successful_forwards(n)` /
  `record_drops(n)` keep `cloud_forwarded_count` on `/metrics` and in
  `openlatch status` honest.
- Shutdown: `src/daemon/mod.rs` fires an explicit `watch` signal into
  `run_cloud_worker` and waits up to 5s for the in-flight flush — channel
  closure alone is not a reliable trigger because `cloud_tx` is cloned onto
  other tasks.
- See `.claude/rules/envelope-format.md` § *Outbound batching*.

## Configuration Monitoring
- Module: `src/daemon/config_monitor/` — captures AI agent config-file
  changes, hashes them, forwards `ai.openlatch.config.*` CloudEvents
  via the existing cloud rail. No persistent trust state on the client.
- Manifest: `manifests/agent_configs.toml` — single source of truth for
  watched paths. Embedded into the binary via `include_str!`.
- Hash pipeline: NFC → JCS (`serde_json_canonicalizer`) → SHA-256 for
  JSON, NFC → EOL+trim → SHA-256 for Markdown / plain-text.
- Severity is hardcoded in `monitor::severity_for` (manifest does not
  declare severity).
- Error code range: OL-2000–2099. Telemetry: 8 aggregate-only events
  (`config_change_detected`, `config_initial_scan_*`, etc.).
- CLI surface (P1): `openlatch inventory list/log/rescan/status`.

## Subsystem Supervision
- Module: `src/core/supervision/task.rs` — restarts the long-lived tasks
  **inside** the daemon, sitting beside `launchd.rs` / `systemd.rs` /
  `task_scheduler.rs`, which restart the process. Gated to `full-cli` by
  virtue of the whole `core` module being `#[cfg(feature = "full-cli")]`
  in `src/lib.rs`, so `openlatch-hook` never links a byte of it (verify with
  `cargo build --release --no-default-features --bin openlatch-hook`).
- Panics are caught by observing the inner task's `JoinHandle`
  (`JoinError::is_panic`), NOT `catch_unwind``futures-util` is a
  dev-dependency and is unavailable in production code.
- `RestartPolicy::Always` for subsystems meant to run forever (boundary
  listener, cloud worker, alerts long-poll, policy poller, reconciler, dedup
  evictor, log cleanup). `OnFailure` where a clean completion is the expected
  end (log writers draining a closed channel, startup update check, sentinel
  pickup, fallback replay, auto-update worker) — `Always` there would spin.
- Backoff: 1 s doubling to a 60 s ceiling, ±20 % jitter, reset after a 60 s
  healthy run. It never gives up; `RESTART_LIMIT_WARN_AT` only decides when
  OL-1511 is logged.
- **A supervised factory must produce a fresh future on every call.** A task
  that owns an `mpsc::Receiver` therefore parks it in an
  `Arc<tokio::sync::Mutex<_>>` and re-locks per run (`run_cloud_worker_on`,
  `run_event_writer`, `run_tamper_writer`, `Reconciler::run(&mut self)`), so a
  panic does not take the queued messages with it.
- Shutdown is ONE daemon-wide `watch<bool>`: it stops the supervisors and is
  the same signal the boundary's graceful drain and the cloud worker's final
  flush listen on. The two log writers deliberately get a separate, never-sent
  channel — their terminator is the channel closing after `Arc::try_unwrap`.
- Observability: `/health` returns `status: "ok" | "degraded"` plus a
  `subsystems` map (200 either way — it is a liveness probe and all three
  callers only test `is_success()`); `/metrics` adds
  `subsystem_restarts_total` + `subsystem_degraded_count`; `openlatch status`
  prints one line per degraded subsystem.
- Error code range: OL-1510–1513 (a distinct block in the Daemon decade;
  1508/1509 vacant).
- Generated supervisor units carry `openlatch-unit-version: N`
  (`supervision::UNIT_VERSION`). Bump it when restart semantics change;
  `openlatch doctor` reports drift and points at
  `openlatch supervision install` rather than rewriting an OS-registered unit.

## Releasing — two independent version lines

This repo cuts **two** release lines, each from its own release-please
invocation — `release-please-config.client.json` and
`release-please-config.schemas.json`, with a matching manifest each. Which tag
you get depends only on which paths your commits touched. The one-invocation-per-
line split is load-bearing; see the warning below before collapsing it.

| Line | Tag | Version file | Publishes | Workflow |
| --- | --- | --- | --- | --- |
| Client binary | `v1.2.3` | `Cargo.toml` | 5-target binaries + GitHub Release + `@openlatch/client*` npm + crates.io | `publish.yml` |
| Wire schemas | `schemas-v1.2.3` | `schemas/version.txt` | `@openlatch/client-schemas` (npm) + `openlatch-client-schemas` (PyPI) | `publish.yml` |

Both lines run from the **same workflow file**, gated by its `resolve` job on the
tag prefix. That is deliberate and load-bearing — see "Trusted Publisher" below.

Both lines announce into Slack `#notify-eng-build-info` through
`.github/actions/slack-notify`, this repo's single writer of CI Slack messages
(call sites: `notify`, `notify-schemas` and `on-failure` in `publish.yml`).
openlatch-platform posts its **deploys** into that same channel from an action
of the same name, so the client card is deliberately styled to be told apart at
a glance — 📦 / 🧩 and a violet rail, against the platform's ✅ and green. Keep
that divergence when you touch either action.

- `packages.schemas.component: "schemas"` is **load-bearing, not decorative**.
  The `simple` release type has no manifest to read a package name from
  (`getDefaultPackageName()` returns `''`), so without it the component is
  empty and `include-component-in-tag` produces the bare tag `v1.0.0` — a
  head-on collision with the client line, which would run `publish.yml`'s
  binary jobs instead of its schema jobs. release-please reads the key
  (`manifest.js` `config['component']`) even though it is missing from the
  published config JSON Schema, so a schema validator will not catch its
  removal. The dry run will: it prints the compare link, and a correct setup
  reads `schemas-v0.1.14...schemas-v1.0.0`.
- The `schemas` package is **path-scoped to `schemas/**`**, so its semver
  describes wire-format compatibility and nothing else. That is what
  openlatch-platform pins and gates on: its `schemas-sync` workflow labels a
  MAJOR bump `breaking` and blocks auto-merge, so a breaking wire change must
  land as a major. (The line starts at **1.0.0** for exactly this reason —
  under `0.x` a breaking change bumps `0.1.14 → 0.2.0`, numerically a MINOR,
  which the platform would silently auto-merge.)
- The root `.` package is **not** path-scoped, so a schemas-only commit
  releases the client too. That is correct: `build.rs` regenerates
  `src/generated/types.rs` from `schemas/*.schema.json` at compile time, so the
  binary really did change. The client carries no schema pin — it always
  compiles the working tree's schemas.
- Because of that compile-time coupling, schema changes normally arrive in the
  same PR as the Rust that consumes them. Each line then gets its own **release
  PR**`chore: release <version>` for the client and `chore: release schemas
  <version>` for the wire schemas — each with its own branch, changelog and tag.
  Merge them independently, in any order: a schema fix can publish to npm + PyPI
  without dragging a 5-target binary release along, and vice versa.
- **⚠️ One release-please invocation per line — do not merge the two configs
  back together.** `version-bump.yml` runs the action twice, against
  `release-please-config.client.json` + `.release-please-manifest.client.json`
  and `release-please-config.schemas.json` +
  `.release-please-manifest.schemas.json`. It used to be one
  `release-please-config.json` holding both packages with
  `separate-pull-requests: true`, and that **silently corrupted every schemas
  release note**: release-please walks main until it has found the release SHA
  of *every* package in its config, so the walk is bounded by the **oldest**
  release across them, and it splits those commits by path without
  re-filtering each bucket against that package's own release. The client's
  v0.1.14 (2026-04-23) therefore set the boundary, and the schemas 1.1.0 PR
  (#137) re-listed eight `schemas/`-touching changes going back to April when
  exactly one commit had landed since `schemas-v1.0.0`. It recurred on every
  schemas release. Those notes are what openlatch-platform reads to judge
  whether a bump is breaking, so this was never cosmetic. `exclude-paths` on
  the root package does **not** fix it — it re-buckets commits and leaves the
  walk boundary alone. The PR branch name comes from the component, not the
  config filename, so both invocations keep updating the same two release PRs.
- **Breaking a schema**: use a `feat(schemas)!:` / `BREAKING CHANGE:` commit —
  the `schemas-breaking` PR label that `pr-checks.yml` enforces is a *review*
  gate and does not by itself bump the major.
- **Forcing a version**: use the **per-package** `release-as` key in that
  line's config — `release-please-config.schemas.json` or
  `release-please-config.client.json` — not a `Release-As:` commit footer. The
  footer is scoped to the packages the commit belongs to, and the root `.`
  package receives *every* commit in the repo — so a footer intended for
  `schemas` also force-versions the client binary. The config key is scoped to
  one package but is **sticky**: it pins every subsequent release of that
  package to the same version, so it must be removed in the very next PR.

  >**Cleared.** `packages.schemas.release-as` was set to `"1.0.0"` to land
  > the first independent schemas release; `schemas-v1.0.0` was cut on
  > 2026-07-28 and the key has been removed. It had to go before the next
  > schemas change could ship at all — a spent `release-as` pins every
  > subsequent release to the same version, so `schemas-v1.1.0` would never
  > have been cut and openlatch-platform could never have re-pinned. If you
  > set it again, delete it in the very next PR.
- **Rehearsing a schema publish**: run `publish.yml` via `workflow_dispatch` with
  `dry_run: true`. A manual dispatch always resolves to the **schemas** line (the
  binary line has no dry-run mode — it would still cut a GitHub Release). It
  stamps, bundles, packs and runs the npm/PyPI/repo parity check, then stops
  before publishing or dispatching. Registry versions are permanent — never
  rehearse with a throwaway real tag. Note this rehearsal **cannot** validate
  registry auth: it stops before the publish, which is the only step that
  exercises it.
- **Both Trusted Publisher registrations pin the workflow filename**
  (`publish.yml`), and neither pins the tag/ref. **This is why both version lines
  share one workflow file — do not split them again.** PR #129 moved the schema
  jobs into a separate `publish-schemas.yml` without re-pointing the two
  registrations, and the first real tag (`schemas-v1.0.0`) failed on both
  registries: npm with a bare `ENEEDAUTH` (it silently falls through from OIDC to
  token auth, and there is no `NPM_TOKEN` by design), PyPI with an explicit
  `invalid-publisher`. npm further "does not verify your trusted publisher
  configuration when you save it", so a mismatch stays invisible until a real tag
  push. If the jobs are ever moved or renamed anyway, re-point **both** consoles
  in the same change — and note npm allows exactly **one** trusted publisher per
  package, so `publish.yml` and a second workflow cannot both publish
  `@openlatch/client-schemas` over OIDC.
- `schemas/vendor/**` sits inside the release-please package path but is *not*
  packaged and *not* diffed by `schemas-check` (both use a non-recursive
  `schemas/*.schema.json` glob). A vendor-only change therefore cuts a schemas
  release whose package content is byte-identical. Harmless, low-frequency —
  prefer landing vendor updates alongside a real schema change.