# 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.
| 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.