slice 10B: signed-op job completion (DELETE clear-job) (hub v0.10.0)
Add DELETE /hosts/{id}/jobs/{job_id} (per-host self-scoped, idempotent) so the
agent clears a job after executing or terminally rejecting it. The hub stores
the operator-signed blobs opaquely (no signing key — cannot forge or open);
the agent verifies + executes. Doc 03 §4/§6/§9 updated (operator-signed path
live; 8C wipe completes; 10B done).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,51 +4,41 @@
|
||||
|
||||
---
|
||||
|
||||
# REPORT — Slice 10A (hub half): desired-state serving — the "Down" channel (hub v0.9.0) (2026-06-10)
|
||||
# REPORT — Slice 10B (hub half): signed-op job completion (hub v0.10.0) (2026-06-10)
|
||||
|
||||
## Type
|
||||
|
||||
TASK (CC-implemented). The hub half of slice 10A. Pairs with `felhom-agent` v0.15.0.
|
||||
TASK (CC-implemented). The hub half of slice 10B. Pairs with `felhom-agent` v0.16.0 (the signing CLI
|
||||
+ verify-and-execute machinery + the storage-wipe consumer).
|
||||
|
||||
## What changed (hub)
|
||||
|
||||
The hub now **serves operator intent** down to already-authenticated hosts; the control envelope stops
|
||||
returning placeholders and carries the host's real generation + signed-jobs flag.
|
||||
Small by design — the hub stores + serves the operator-signed blobs **opaquely** (it holds no signing
|
||||
key, can neither forge nor open them; the agent verifies + executes). 10B adds the **completion** path.
|
||||
|
||||
### Store (`internal/store`)
|
||||
- New `signed_jobs` table (per-host **opaque** signed-op blob queue). New methods: `SetHostDesired`
|
||||
(set desired-state + **atomically bump `desired_generation`**), `EnqueueSignedJob` / `GetSignedJobs`
|
||||
/ `CountSignedJobs`. The `hosts` table's previously-inert `desired_json` / `desired_generation`
|
||||
columns are now live.
|
||||
|
||||
### API (`internal/api`)
|
||||
- **`PUT /api/v1/admin/hosts/{id}/desired-state`** (global key) — set + bump generation; body stored +
|
||||
served **opaquely** (validated only as well-formed JSON — the agent owns the schema).
|
||||
- **`GET /api/v1/hosts/{id}/desired-state`** (per-host key, **self-scoped**) — `{generation,
|
||||
desired_state}`; host A's key cannot read host B (403); global key may read any.
|
||||
- **`GET /api/v1/hosts/{id}/jobs`** (per-host key, self-scoped) — serves the host's pending opaque
|
||||
signed-op blobs, oldest first (verify+execute is 10B).
|
||||
- **`POST /api/v1/admin/hosts/{id}/jobs`** (global key) — enqueue a pre-signed opaque blob (the hub
|
||||
holds no signing key).
|
||||
- The host-report **control envelope** now reports the real `desired_generation` + `has_signed_ops`,
|
||||
degrading safely to defaults on a store error.
|
||||
### Store + API
|
||||
- **`DELETE /api/v1/hosts/{host_id}/jobs/{job_id}`** (per-host key, **self-scoped**; global key may
|
||||
clear any) — the agent calls it after executing OR terminally rejecting a job. Idempotent. Store:
|
||||
`DeleteSignedJob`.
|
||||
- Reused unchanged from 10A: `POST /admin/hosts/{id}/jobs` (operator enqueue), `GET /hosts/{id}/jobs`
|
||||
(agent fetch), `has_signed_ops` envelope flag. The signed blob stays opaque on the wire (a base64
|
||||
`{op_blob_b64, sig_armored}` envelope) — **no jobs-wire golden change**.
|
||||
|
||||
## Tests (green)
|
||||
- admin-set bumps the generation + serves the latest body; global-key-only (per-host 403, malformed
|
||||
400, unknown host 404); `GET /desired-state` self-scoped (A→B 403, global any, no-token 401);
|
||||
envelope carries generation + `has_signed_ops` flips on enqueue; `GET /jobs` self-scoped oldest-first;
|
||||
cross-repo golden round-trip (set → fetched back unchanged), **byte-identical** with felhom-agent.
|
||||
- `DELETE …/jobs/{id}` self-scoped (host A cannot clear host B's job → 403) + idempotent.
|
||||
|
||||
## Docs
|
||||
- Doc 03 §4 (control loop live: heartbeat → envelope generation/jobs → fetch-on-change → reconcile
|
||||
benign / gate destructive) + §9 slice table (**10A done**; 10B signed-op execution / 10C escrow
|
||||
consumption / 10D DR capstone pending; the `restore_directive` field exists now, consumed in 10D).
|
||||
- Doc 03 §4 (the operator-signed path is LIVE: gate → pending op → offline signature → verify
|
||||
(pinned key / nonce-burn / expiry / host + durable-id anti-retarget) → execute; key floor: not in
|
||||
the hub, not in the agent), §6 (the 8C data-bearing wipe now completes via 10B), §9 slice table
|
||||
(**10B done**; 10C escrow-consumption spike-validated, 10D DR capstone pending).
|
||||
|
||||
## Deferred / out of scope
|
||||
- Signed-op **execution** + signature verification → **10B** (10A only serves the queue + flag).
|
||||
- **Restore-mode / re-enroll** consumption (a new box's first directive) → **10D**; 10A serves
|
||||
already-authenticated hosts only. Rich desired-state editing UX → doc-05 (10A's admin-set is minimal).
|
||||
## Security framing (why the hub stays minimal)
|
||||
The hub is deliberately a dumb queue here: it cannot forge a signed op (no key) and the agent never
|
||||
trusts a queued blob until the pinned-key verify passes. A **compromised hub queuing a forged blob is
|
||||
rejected** by the agent (tested in felhom-agent). That is the whole point of the offline-key design.
|
||||
|
||||
## Pending
|
||||
- Build + deploy hub v0.9.0 (+ agent v0.15.0) and live-validate against the demo host (admin-set
|
||||
benign+destructive → generation bump → agent fetch → reconcile/gate; self-scope refusal).
|
||||
- Build + deploy hub v0.10.0 (+ agent v0.16.0) and live-validate the full loop on the demo: a
|
||||
data-bearing wipe → `pending_signature` → offline-signed → queued → agent verifies + wipes the
|
||||
device; replay + non-pinned-key rejected.
|
||||
|
||||
Reference in New Issue
Block a user