Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/harness-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
targets: wasm32-unknown-unknown

- uses: Swatinem/rust-cache@v2
with:
Expand Down Expand Up @@ -187,6 +188,28 @@ jobs:
- name: Frontend↔harness plant contract has not drifted (issue #203 / #206)
run: bash scripts/check-web-api-drift.sh

# B1 (#203 follow-up): the browser host (agentkeys-web-core — a cdylib+rlib
# that compiles to wasm32) shares the cap-mint wire shape with the native
# client through the pure-serde `agentkeys-protocol` crate. This gate fails
# if that shared crate (or web-core) ever pulls a native-only dep (tokio /
# native reqwest / aws-sdk-sts, the last via the provisioner) that breaks
# the browser build — exactly the regression that would occur if web-core
# depended on the native backend-client directly. Default + `wasm` bindings.
- name: web-core compiles to wasm32 (no native transport leaked into the browser)
run: |
cargo check --target wasm32-unknown-unknown -p agentkeys-web-core
cargo check --target wasm32-unknown-unknown -p agentkeys-web-core --features wasm

# B2 (#203 parity ladder, rung 3): the frontend imports the Api* wire types
# GENERATED from ui_bridge.rs via ts-rs (apps/parent-control/lib/generated/).
# `cargo test --workspace` above re-ran the #[ts(export)] tests, rewriting
# those .ts in place; if a daemon-side struct changed without committing the
# regenerated bindings, the working tree now differs from HEAD → fail (same
# discipline as the backend-protocol fixtures). Keeps the generated types and
# the daemon structs from drifting silently.
- name: ts-rs frontend bindings are up to date (issue #203 B2)
run: git diff --exit-code -- apps/parent-control/lib/generated/

detect-changes:
# Issue #101: path-conditional triggers for auto-deploy of the test broker.
# Computes `broker_changed` so deploy-test-broker can skip when a PR only
Expand Down
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,16 +245,17 @@ Verified live:

## Broker/worker request shapes have ONE owner (issue #203)

**The broker/worker client protocol — cap-mint (the six `/v1/cap/*` endpoints: cred/memory/config × store/fetch, #201), the STS relay, worker `/v1/memory/{put,get}` + `/v1/config/{put,get}` body types, audit append, the `memory:<ns>` service builder, and the `0x`-omni normalizer — is owned by ONE crate, [`agentkeys-backend-client`](crates/agentkeys-backend-client/) (request/response field types co-owned with [`agentkeys-types`](crates/agentkeys-types/)). Never re-type a cap/worker body in a second Rust path or in bash.**
**The broker/worker client protocol — cap-mint (the six `/v1/cap/*` endpoints: cred/memory/config × store/fetch, #201), the STS relay, worker `/v1/memory/{put,get}` + `/v1/config/{put,get}` body types, audit append, the `memory:<ns>` service builder, and the `0x`-omni normalizer — has ONE definition, split across two crates by transport-safety: the wire **types** live in [`agentkeys-protocol`](crates/agentkeys-protocol/) — a pure-serde, transport-free crate that compiles to `wasm32` — and the native **client** (cap-mint → STS → worker) in [`agentkeys-backend-client`](crates/agentkeys-backend-client/), which re-exports the types as `agentkeys_backend_client::protocol`. The browser client [`agentkeys-web-core`](crates/agentkeys-web-core/) (wasm) depends on the SAME `agentkeys-protocol`, so the cap-mint body cannot drift across native vs browser — it used to (`ttl_seconds` was a required `u64` in backend-client but `Option<u64>` in web-core's own copy). web-core must NOT depend on `agentkeys-backend-client` directly: that crate pulls `aws-sdk-sts` + `tokio` + native `reqwest` (via `agentkeys-provisioner`) and would break the wasm build; the `wasm32` CI gate in `harness-ci.yml` enforces this. Never re-type a cap/worker body in a second Rust path or in bash.**

This is the structural fix for the drift bug class #200 closed (`evm_address` vs `{address,chain_id}`, bare-vs-`0x` omni, per-namespace field shapes): the same JSON used to be hand-coded in the MCP `HttpBackend`, the daemon `ui_bridge`, and bash `jq -n` bodies. Now:

- **Rust callers share the crate.** The MCP server's [`HttpBackend`](crates/agentkeys-mcp-server/src/backend/http_backend.rs) is a thin delegate over `BackendClient`; the daemon's [`ui_bridge`](crates/agentkeys-daemon/src/ui_bridge.rs) mints every master-self cap (memory AND #201 config) via `BackendClient::cap_mint` (`mint_master_cap`) and builds its worker put/get bodies from the crate's `MemoryPutBody`/`MemoryGetBody`/`ConfigPutBody`/`ConfigGetBody` types. A drifted shape is a **compile error**. (The raw worker POST stays in the daemon to reuse the once-minted STS creds across namespaces; only the body shape is crate-owned.)
- **Rust callers share the crate.** The MCP server's tools call `BackendClient` directly — the `Backend` trait is `impl`'d on `BackendClient` and the old `HttpBackend` delegate was removed in #213 (the trait is kept only as the `MockBackend` test seam). The daemon's [`ui_bridge`](crates/agentkeys-daemon/src/ui_bridge.rs) mints every master-self cap (memory AND #201 config) via `BackendClient::cap_mint` (`mint_master_cap`) and builds its worker put/get bodies from the crate's `MemoryPutBody`/`MemoryGetBody`/`ConfigPutBody`/`ConfigGetBody` types (re-exported from `agentkeys-protocol`). A drifted shape is a **compile error**. (The raw worker POST stays in the daemon to reuse the once-minted STS creds across namespaces; only the body shape is crate-owned.)
- **Bash is gated.** Any hand-rolled cap/worker body in `harness/**` that is meant to mirror the wire shape carries a `# @backend-fixture: <shape>` comment above it. [`scripts/check-backend-fixture-drift.sh`](scripts/check-backend-fixture-drift.sh) diffs each annotated body's key-set against the crate-emitted fixtures in [`harness/fixtures/backend-protocol/`](harness/fixtures/backend-protocol/) (regenerated by `cargo run -p agentkeys-backend-client --bin dump-protocol-fixtures`). A drifted bash body is a **CI failure** (the [`harness-ci.yml`](.github/workflows/harness-ci.yml) `rust-checks` job runs both the fixture `--check` and the bash gate on every PR touching `crates/**`, `harness/**`, or `scripts/**`).
- **The daemon's web-API plant contract is gated too** (the adjacent frontend↔daemon↔harness surface — the #206 parity ladder, rung 2). The route `/v1/master/memory/plant` + the `ApiMemoryEntry` body have ONE source of truth (the daemon's `MASTER_MEMORY_PLANT_ROUTE` const + struct in [`ui_bridge.rs`](crates/agentkeys-daemon/src/ui_bridge.rs)), pinned to [`harness/fixtures/web-api/master_memory_plant.json`](harness/fixtures/web-api/master_memory_plant.json) by a `ui_bridge` unit test, and BOTH non-Rust consumers — the React frontend [`daemon.ts`](apps/parent-control/lib/client/daemon.ts) and [`web-parity-demo.sh`](harness/web-parity-demo.sh) — carry a `@web-fixture: master_memory_plant` annotation and are diffed against it by [`scripts/check-web-api-drift.sh`](scripts/check-web-api-drift.sh) in CI. This closed phase 6's frontend false-green (a `daemon.ts` route/shape change is now CI-red, not a stale green).
- **The frontend's `Api*` wire types are GENERATED, not hand-mirrored (#203 B2, rung 3).** `ts-rs` derives on the `ui_bridge.rs` `Api*` structs emit [`apps/parent-control/lib/generated/*.ts`](apps/parent-control/lib/generated/); `daemon.ts` imports them instead of re-declaring interfaces, so a daemon-side field rename is a frontend **compile error** (and the [`harness-ci.yml`](.github/workflows/harness-ci.yml) `rust-checks` job `git diff --exit-code`s the generated dir after `cargo test` regenerates it). `u64` fields are pinned to `number` (`#[ts(type = "number")]`) and skip-serialize `Option`s to `?:` (`#[ts(optional)]`) to match the wire. Regenerate after a struct change: `cargo test -p agentkeys-daemon export_bindings` (any `cargo test` triggers it), then commit the `.ts`. (Still hand-declared, next codegen batch: the #207 `ApiProposedScope` + classify/credentials response types.)

**Rules when you touch this surface:**
- Adding/changing a wire field → change the serde type in `agentkeys-backend-client::protocol` (the single definition), then `cargo run -p agentkeys-backend-client --bin dump-protocol-fixtures` to regenerate the committed fixtures, and update the frozen key-set test in `fixtures.rs`. The four Rust callers recompile against the new shape automatically.
- Adding/changing a wire field → change the serde type in [`agentkeys-protocol`](crates/agentkeys-protocol/) (the single definition; `agentkeys-backend-client` re-exports it as `::protocol`), then `cargo run -p agentkeys-backend-client --bin dump-protocol-fixtures` to regenerate the committed fixtures, and update the frozen key-set test in `fixtures.rs`. All Rust callers — the MCP server, the daemon, AND the browser host `agentkeys-web-core` (wasm) — recompile against the new shape automatically; the `wasm32` CI gate proves the browser still builds.
- Real-path harness steps should drive the `agentkeys` CLI (which routes through the shared client), NOT hand-roll curls. Keep raw curls only for negative / HTTP-status-assertion tests — and annotate any such body that mirrors a canonical shape with `# @backend-fixture: <shape>` so the gate keeps it honest. Deliberately-malformed negative-test payloads are NOT annotated (they're supposed to be wrong).
- Per the terminology-source-of-truth rule, the field names are the arch.md canonical spellings — don't invent a synonym in a new body.

Expand Down
52 changes: 52 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ members = [
"crates/agentkeys-mcp-server",
"crates/agentkeys-provisioner",
"crates/agentkeys-backend-client",
"crates/agentkeys-protocol",
"crates/agentkeys-broker-server",
"crates/agentkeys-worker-creds",
"crates/agentkeys-worker-memory",
Expand All @@ -30,6 +31,7 @@ agentkeys-memory-engine = { path = "crates/agentkeys-memory-engine" }
agentkeys-memory-openviking = { path = "crates/agentkeys-memory-openviking" }
agentkeys-web-core = { path = "crates/agentkeys-web-core" }
agentkeys-backend-client = { path = "crates/agentkeys-backend-client" }
agentkeys-protocol = { path = "crates/agentkeys-protocol" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
Expand Down
75 changes: 13 additions & 62 deletions apps/parent-control/lib/client/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ import type {
StatusKind,
Worker,
} from '@/app/_components/types';
// Wire types GENERATED from the Rust ui_bridge Api* structs via ts-rs (#203 B2).
// Do not hand-edit @/lib/generated or re-declare these here — a daemon-side field
// rename regenerates the .ts and the mappers below stop compiling (rung-3 drift
// gate; CI also git-diffs the generated dir).
import type { ApiActor } from '@/lib/generated/ApiActor';
import type { ApiAuditEvent } from '@/lib/generated/ApiAuditEvent';
import type { ApiWorker } from '@/lib/generated/ApiWorker';
import type { ApiMemoryEntry } from '@/lib/generated/ApiMemoryEntry';
import type { MemoryCategory as ApiMemoryCategory } from '@/lib/generated/MemoryCategory';

/**
* DaemonBackend — talks to a running agentkeys-daemon over HTTP.
Expand Down Expand Up @@ -486,51 +495,10 @@ interface ApiProposedScope {
confidence: number;
}

// ─── API wire types (snake_case, mirror ui_bridge.rs ApiActor etc.) ────

interface ApiActor {
id: string;
omni: string;
omni_hex: string;
label: string;
role: string;
parent: string | null;
derivation: string;
device: string;
device_pubkey: string;
last_active: string;
status: string;
vendor: string;
k11: boolean;
scope?: Record<string, { read: boolean; write: boolean }>;
payment_cap?: { per_tx: number; daily: number; currency: string };
time_window?: { start: string; end: string; tz: string };
services?: string[];
}

interface ApiAuditEvent {
id: string;
ts: string;
actor_id: string;
actor: string;
kind: string;
detail: string;
chip: string;
sev: string;
}

interface ApiWorker {
id: string;
title: string;
host: string;
desc: string;
calls_today: number;
calls_hour: number;
p50: number;
p95: number;
cap: string;
by_actor: { actor: string; count: number; share: number }[];
}
// ─── Wire types are imported from @/lib/generated (ts-rs, generated from the
// ui_bridge.rs Api* structs — #203 B2). The mappers below convert the
// snake_case wire types to the camelCase UI domain types; a daemon-side
// field rename regenerates the .ts and breaks these mappers (drift gate). ──

function apiToActor(a: ApiActor): Actor {
return {
Expand Down Expand Up @@ -606,23 +574,6 @@ function normalizeChip(c: string): ChipKind {
return (allowed as string[]).includes(c) ? (c as ChipKind) : 'default';
}

interface ApiMemoryEntry {
ns: string;
key: string;
title: string;
bytes: number;
version: string;
updated: string;
preview: string;
body: string;
content_hash?: string;
}

interface ApiMemoryCategory {
ns: string;
label: string;
}

function apiToMemoryEntry(m: ApiMemoryEntry): MasterMemoryEntry {
return {
ns: m.ns, key: m.key, title: m.title, bytes: m.bytes,
Expand Down
6 changes: 6 additions & 0 deletions apps/parent-control/lib/generated/ApiActor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ApiPaymentCap } from "./ApiPaymentCap";
import type { ApiScopeBits } from "./ApiScopeBits";
import type { ApiTimeWindow } from "./ApiTimeWindow";

export type ApiActor = { id: string, omni: string, omni_hex: string, label: string, role: string, parent: string | null, derivation: string, device: string, device_pubkey: string, last_active: string, status: string, vendor: string, k11: boolean, scope?: { [key in string]?: ApiScopeBits }, payment_cap?: ApiPaymentCap, time_window?: ApiTimeWindow, services?: Array<string>, };
3 changes: 3 additions & 0 deletions apps/parent-control/lib/generated/ApiAnchorBatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type ApiAnchorBatch = { ts: string, root: string, count: number, txn: string, conf: number, };
4 changes: 4 additions & 0 deletions apps/parent-control/lib/generated/ApiAnchorStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ApiAnchorBatch } from "./ApiAnchorBatch";

export type ApiAnchorStatus = { last_anchor_at: number, next_anchor_in: number, recent: Array<ApiAnchorBatch>, };
3 changes: 3 additions & 0 deletions apps/parent-control/lib/generated/ApiAuditEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type ApiAuditEvent = { id: string, ts: string, actor_id: string, actor: string, kind: string, detail: string, chip: string, sev: string, };
3 changes: 3 additions & 0 deletions apps/parent-control/lib/generated/ApiCapToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type ApiCapToken = { id: string, cap: string, scope: string, ttl: string, minted: string, danger?: boolean, };
8 changes: 8 additions & 0 deletions apps/parent-control/lib/generated/ApiMemoryEntry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

/**
* A master-actor memory entry. `content_hash` is the dedup key —
* keccak-free sha256 over (ns || key || body) so a re-plant of the same
* content is detected and skipped (the "prevent duplicate plant" gate).
*/
export type ApiMemoryEntry = { ns: string, key: string, title: string, bytes: number, version: string, updated: string, preview: string, body: string, content_hash: string, };
3 changes: 3 additions & 0 deletions apps/parent-control/lib/generated/ApiPaymentCap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type ApiPaymentCap = { per_tx: number, daily: number, currency: string, };
3 changes: 3 additions & 0 deletions apps/parent-control/lib/generated/ApiScopeBits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type ApiScopeBits = { read: boolean, write: boolean, };
3 changes: 3 additions & 0 deletions apps/parent-control/lib/generated/ApiTimeWindow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type ApiTimeWindow = { start: string, end: string, tz: string, };
4 changes: 4 additions & 0 deletions apps/parent-control/lib/generated/ApiWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ApiWorkerActorShare } from "./ApiWorkerActorShare";

export type ApiWorker = { id: string, title: string, host: string, desc: string, calls_today: number, calls_hour: number, p50: number, p95: number, cap: string, by_actor: Array<ApiWorkerActorShare>, };
3 changes: 3 additions & 0 deletions apps/parent-control/lib/generated/ApiWorkerActorShare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type ApiWorkerActorShare = { actor: string, count: number, share: number, };
3 changes: 3 additions & 0 deletions apps/parent-control/lib/generated/MemoryCategory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type MemoryCategory = { ns: string, label: string, };
6 changes: 6 additions & 0 deletions crates/agentkeys-backend-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ path = "src/bin/dump_protocol_fixtures.rs"
# shared piece. The client crate wraps it so cap-mint + STS + worker put/get
# live behind ONE owner (issue #203).
agentkeys-provisioner = { path = "../agentkeys-provisioner" }
# The transport-agnostic wire shapes (cap-mint + worker bodies). Moved out of
# this crate's `protocol` module into a standalone, wasm-safe crate so the
# browser host (agentkeys-web-core) can share them without pulling in this
# crate's native transport (reqwest/tokio + the provisioner's aws-sdk-sts).
# Re-exported below as `agentkeys_backend_client::protocol` for back-compat.
agentkeys-protocol = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
Expand Down
Loading
Loading