Skip to content

feat(kinetic): the clutch — no couple, no move - #92

Open
mayoalexander wants to merge 3 commits into
mainfrom
kinetic-clutch
Open

mayoalexander wants to merge 3 commits into
mainfrom
kinetic-clutch

Conversation

@mayoalexander

@mayoalexander mayoalexander commented Sep 20, 2026

Copy link
Copy Markdown

KINETICS — THE CLUTCH (#184906), build order steps 1, 3 and 5. Epic for this build: Atlas item #186315.

The problem, as it stands on main

iris camera, iris obs and iris device drive real hardware for whoever runs them, and iris hive run will dispatch a shell command to any node. There is no identity anywhere in that path. The epic's own words: arbitrary shell is raw kinetic energy with no mass identity — dangerous and unauditable.

What this adds

A body moves for an agent only when a couple on that node says it may. A couple binds four things, plus the terms:

agent a sealed hash of the agent's definition
node the machine holding the body — a couple does not travel
body camera:obsbot-tiny, obs:studio, or camera:* for a class
verbs the allowlist; anything else is refused
terms per-act ceiling · human confirmation · expiry

Editing the agent changes its hash, so the couple stops matching by itself. That is the audit property minting (#184905) is for, working before Mint issues hashes — and when it does, the couple carries Mint's hash instead with no change to the record.

Files

  • kinetic-couple.ts — pure. The record, matching, and decide(). Fails closed: a malformed body, an unreadable expiry, an unsealed caller, a corrupt couples file and a couple for another node are all refusals, never fall-throughs.
  • kinetic-store.ts — couples live on the node that holds the body. Forced by the epic's offline rule ("sealed hash + couple + device on the same node"): a cloud copy is a report, not permission. Node key is the hostname stem, because macOS rewrites the hostname on mDNS collisions and a couple keyed to a mutating string would silently stop matching.
  • kinetic-guard.ts — one await on the act path. Exit 5 on refusal, and the message names the exact couple add that would fix it.
  • platform-kinetic.tsiris kinetic seal | couple add/list/revoke | check | lock | log.
  • camera + obs — every acting command guarded. list, pos and status left open: looking is not moving.
  • scaffold/how-to/kinetics.md — build order step 6. Capability index rebuilt, so agents can discover all 9 entries.

An operator is not an agent act

Your own iris camera left keeps working and is recorded as unbound — never booked to an agent that did not do it. iris kinetic lock makes a node require a couple from everyone, for a studio or an unattended machine.

Verification

23 tests, and every one mutation-checked — each assertion was proven to fail when the line it guards is broken. That caught a real hole in the first draft: a couples file carrying agent: "" matched a caller with no identity, the malformed record and the unsealed caller cancelling into an allow. Both sides are checked now.

End to end against the real CLI on a real node key:

uncoupled agent, kinetic check … move REFUSE, exit 5
uncoupled agent, iris obs record start REFUSE, exit 5, before OBS is contacted
after couple add ALLOW
verb not on the allowlist REFUSE, naming what is blessed
different body REFUSE
couple revoke refused on the next act
operator, unlocked node / locked node ALLOW (unbound) / REFUSE
act log carries allows and refusals both

Not in this build

Booking acts to the Mint ledger as expense:device:* (step 4), and the motion→mass return (#185317). Acts are recorded to ~/.iris/kinetic/acts.jsonl on the node meanwhile.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K9LZmfMCptv6tUqxryDrTx


Update — the four gaps are closed (commit 70c2c960e)

The first commit guarded two act paths by hand, which left iris device, iris hive run and anything written next month unguarded. Enforcement was opt-in. Now:

1 · One choke point, not two call sites

kinetic-bodies.ts holds the route table; one middleware consults it before any handler runs. A new act path is guarded by adding a row, not by remembering to call a function.

  • iris hive run — the epic's named danger, arbitrary shell on another machine — is refused without a couple, and nothing in hive was touched to make that true.
  • Reads route to nothing (camera list, obs status, hive nodes, and a device clean dry run).
  • The command name comes from yargs' parsed path (a flag's value looks like a word: --log-level DEBUG camera left would otherwise read as the command "debug"), and the target from raw argv (yargs eats named positionals, so hive run studio-mac arrives as ["hive","run"] — without the machine it drives). Both are pinned by tests; a global flag can no longer hide an act.
  • iris device turned out to be this machine's disk, not phones — its row was rewritten against the command's real subcommands rather than the verbs I had assumed.

2 · Signed couples, so a fleet is coupled centrally without losing offline

An issuer signs; every node verifies locally, with no network call. iris kinetic issuer new | trust | list | untrust, couple export | import.

Every field that grants anything is signed — agent, node, body, verbs, budgets, expiry — and the test tampers with each one individually. The label is not signed, because renaming it grants nothing. The signature must verify against the issuer it names, not merely some key the node trusts (that one took two attempts to pin: the obvious test passed against a broken implementation).

Three ways to withdraw, increasing in size: couple revoke (one couple, and that "no" survives a re-import — a revoked couple is still correctly signed, so the signature cannot be what withdraws it), issuer untrust (everything that issuer ever signed), and a short --expires (an unreachable node forgets by itself).

3 · Budgets that bite

Per-act, per-run, per-UTC-day, and acts-per-day — read from the act log. Costs are declared (IRIS_ACT_CENTS, or --cents where a command defines it), never guessed, and only allowed acts count, so being refused all day cannot exhaust a budget. iris kinetic spend <couple> shows each window against its ceiling.

Found by running it, not by reasoning: three 60c acts under a $1.50 day cap booked $0.00, because the choke point recorded refusals only and those paths have no act-path call site — the ceilings were unreachable for exactly the commands only the middleware covers. Allows are recorded now, and both gates share an act_id so one act is never counted twice.

4 · Verbs checked at coupling time

unknownVerbs refuses a verb the class does not declare, so a typo is a refusal now instead of a couple that silently never matches. A class IRIS does not model yet is left alone rather than second-guessed. iris kinetic bodies lists what exists.

Verification

47 pure tests. 13 mutations applied to the guard's own lines: 12 caught; the one that survived is redundant with another check and is now marked in the code as not load bearing, rather than left looking guarded. Full src/cli/cmd suite: 1592 pass, 0 fail.

End to end across two node identities: issue on the studio machine → laptop refuses it (untrusted) → trust the issuer → import → act allowed → revoke → re-import refused → untrust kills it. Plus per-node enforcement (node:studio-mac allows that machine and refuses another) and the day ceiling stopping the third act of a run.

Also caught by running it: kinetic check never passed the signature verifier, so it reported a perfectly good issued couple as unverifiable — a check must ask exactly what the act path asks, or it answers about a different system.

mayoalexander and others added 3 commits September 19, 2026 23:42
KINETICS — THE CLUTCH (#184906) build order steps 1, 3 and 5. Until now anything that could
reach `iris camera` / `iris obs` could drive the hardware: raw kinetic energy with no mass
identity. Now a body moves for an agent only when a couple on THAT node says it may.

A couple binds four things — a sealed agent hash, the node, the body (`camera:obsbot-tiny`,
or `camera:*`), and the verbs blessed — plus the terms: a per-act ceiling, human confirmation,
an expiry. Editing the agent changes its hash, so the couple stops matching by itself; that is
the audit property minting (#184905) exists for, available before Mint issues hashes.

- kinetic-couple.ts — pure: the record, matching, and `decide()`, which fails CLOSED. A
  malformed body, an unreadable expiry, a corrupt couples file and an unsealed caller are all
  refusals, never fall-throughs. 23 tests, every one mutation-checked (the first draft let a
  couples file carrying `agent: """ match a caller with no identity — both sides now checked).
- kinetic-store.ts — couples live on the node that holds the body, because #184906's offline
  rule requires it: a cloud copy is a report, not permission.
- kinetic-guard.ts — one await on the act path; exits 5 on a refusal and names the fix.
- platform-kinetic.ts — iris kinetic seal | couple add/list/revoke | check | lock | log.
- camera and obs act paths guarded; reads (list, pos, status) left open — looking is not moving.

An operator at a terminal is not an agent act: still allowed, recorded as unbound, never booked
to an agent that did not do it. `iris kinetic lock` makes a node strict about that too.

Verified end to end on a real node key: uncoupled agent refused (exit 5, including through
`iris obs record start` before OBS is touched), couple added, same act allowed, unblessed verb
refused, wrong body refused, revoke takes effect immediately, act log carries both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9LZmfMCptv6tUqxryDrTx
The first cut guarded two act paths by hand. That left `iris device`, `iris hive run` and
anything written next month unguarded — enforcement was opt-in, which is the failure Genesis
invariant 4 names. Four gaps, closed:

1 ONE CHOKE POINT. kinetic-bodies.ts holds the route table and one middleware consults it before
  any handler runs, so a new act path is covered by adding a row. `iris hive run` — the epic's
  named danger, arbitrary shell on another machine — is now refused without a couple, and nothing
  in hive was touched to do it. Reads route to nothing. The command name comes from yargs' parsed
  path (a flag's VALUE looks like a word) and the target from raw argv (yargs eats positionals;
  `hive run studio-mac` arrives as [hive, run], without the machine it drives).
2 SIGNED COUPLES. An issuer signs; every node verifies locally with no network call, so a fleet
  is coupled centrally without giving up the offline rule. Every granting field is signed — agent,
  node, body, verbs, budgets, expiry — and the test tampers with each one. A node's own revoke
  outlives a re-import, because a revoked couple is still correctly signed.
3 BUDGETS THAT BITE. Per-act, per-run, per-UTC-day and acts-per-day, read from the act log. The
  per-act cap alone never fires on a night of cheap acts. Costs are DECLARED (IRIS_ACT_CENTS or
  --cents), never guessed, and only allowed acts count — being refused all day cannot exhaust a
  budget. Both gates share an act_id so one act is never counted twice.
4 VERBS ARE CHECKED AT COUPLING TIME against what the class declares, so a typo is a refusal now
  rather than a couple that silently never matches. A class IRIS does not model is left alone.

Measured, not theorised: three 60c acts under a $1.50 day cap booked $0.00, because the choke
point recorded refusals only and those paths have no act-path call site. Allows are recorded now.
Also found by running it: `kinetic check` never passed the signature verifier, so it called a
perfectly good issued couple unverifiable — the check must ask what the act path asks.

47 pure tests, 13 mutations applied to the guard's own lines; 12 were caught, and the one that was
not is marked in the code as not load bearing rather than left to look guarded. Full cmd suite
1592 pass. Verified end to end across two node identities: issue on one machine, trust, import,
act, revoke, re-import refused, untrust kills it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9LZmfMCptv6tUqxryDrTx
# Conflicts:
#	packages/opencode/capabilities.json
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant