mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
feat(identity): A3 (P22) — auto-record dispatched sends into the outbox at the transport route
Identity/audit family atom 3 (design dc3a0719, A3 section). The outbox was explicit-verb-only (`rig queue outbox record`), so a derived send was not recorded unless someone remembered — the specimen-5 window, where a send's attribution survived only via the ghost's provider JSONL. A3 auto-records at dispatch so a derived-but-unrecorded send cannot accept-and-drop at the audit layer. - routes/transport.ts (/send handler): after a SUCCESSFUL dispatch, record one outbox_entries row with sender_session = the DERIVED actor (the x-openrig-session header, never a body claim) and identity_provenance = "transport:v1". STRICTLY DOWNSTREAM of the certified header-derivation (line 65 unchanged) — it only consumes the already-derived actor. Best-effort: the send is already committed, so a rare audit-write failure is logged, never a false-negative on delivery. - Records only a DERIVED send; a null-actor send has no sender to attribute (no fabricated row); a REFUSED send returns before dispatch (no row); a cross-host send records the ORIGIN triple (the derived header IS the origin after A2/A4), never the relay. Implemented at the ROUTE level (not session-transport.ts) — derivedActor + outboxHandler are route-scoped. Pins: derived→1 row(transport:v1), cross-host→origin triple, refused→0, headerless→0. RED 2-at-base with 2 controls. daemon tsc 0; transport-routes + outbox-handler suites green (38). DELTA REVIEW: the certified property (identity derives from the header, never the body) is UNCHANGED and depended upon; A3 adds only a durable write downstream of it. Scope note for review: /send only — whether /broadcast fan-out should also auto-record is a follow-on question, flagged. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
f48151f5a3
commit
7ac6bc2c0a
2 files changed
+99
-1
No files matched your search
@@ -2,6 +2,7 @@ import { Hono } from "hono";
|
||||
import type { SessionTransport, TargetSpec } from "../domain/session-transport.js";
|
||||
import { authBearerTokenMiddleware } from "../middleware/auth-bearer-token.js";
|
||||
import { requireSenderIdentity } from "./require-sender-identity.js";
|
||||
import type { OutboxHandler } from "../domain/outbox-handler.js";
|
||||
|
||||
export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
|
||||
const router = new Hono();
|
||||
@@ -112,6 +113,31 @@ export function transportRoutes(opts?: { bearerToken?: string | null }): Hono {
|
||||
return c.json(result, status);
|
||||
}
|
||||
|
||||
// A3 (P22): auto-record the DISPATCHED send into the sender-side outbox, so a derived send cannot
|
||||
// accept-and-drop at the audit layer (the specimen-5 window: no outbox row, attribution survived only
|
||||
// via provider JSONL). STRICTLY DOWNSTREAM of the certified header-derivation (line 65): it consumes
|
||||
// the already-derived `derivedActor`, never re-derives or alters it. Records only a DERIVED send —
|
||||
// `derivedActor` present — with the era-stamp `transport:v1` (the sole mode this header-derived route
|
||||
// produces; a cross-host relay's header IS the origin triple, so the recorded sender is the ORIGIN,
|
||||
// never the relay). A null-actor send has no derived sender to attribute (no fabricated row). The
|
||||
// send is already committed, so a rare audit-write failure is LOGGED, never a false-negative on a
|
||||
// delivered send.
|
||||
if (derivedActor) {
|
||||
const outbox = c.get("outboxHandler" as never) as OutboxHandler | undefined;
|
||||
if (outbox) {
|
||||
try {
|
||||
outbox.record({
|
||||
senderSession: derivedActor,
|
||||
destinationSession: body.session,
|
||||
body: body.text,
|
||||
identityProvenance: "transport:v1",
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[transport/send] outbox auto-record failed (send already delivered): ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { SessionTransport } from "../src/domain/session-transport.js";
|
||||
import type { TmuxAdapter, TmuxResult } from "../src/adapters/tmux.js";
|
||||
import { transportRoutes } from "../src/routes/transport.js";
|
||||
import { OutboxHandler } from "../src/domain/outbox-handler.js";
|
||||
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
|
||||
function setupDb(): Database.Database {
|
||||
@@ -49,10 +51,11 @@ function mockTmux(overrides?: Partial<{
|
||||
} as unknown as TmuxAdapter;
|
||||
}
|
||||
|
||||
function createApp(deps: { sessionTransport: SessionTransport }): Hono {
|
||||
function createApp(deps: { sessionTransport: SessionTransport; outboxHandler?: OutboxHandler }): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("sessionTransport" as never, deps.sessionTransport);
|
||||
if (deps.outboxHandler) c.set("outboxHandler" as never, deps.outboxHandler);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/transport", transportRoutes());
|
||||
@@ -116,6 +119,75 @@ describe("transport routes", () => {
|
||||
expect(body.sessionName).toBe("dev-impl@my-rig");
|
||||
});
|
||||
|
||||
// ── A3 (P22) — auto-record dispatched sends into the sender-side outbox, so a DERIVED send cannot
|
||||
// accept-and-drop at the audit layer (the specimen-5 window: no outbox row, attribution survived
|
||||
// only via provider JSONL). Strictly DOWNSTREAM of the certified header-derivation (uses the
|
||||
// already-derived actor, never re-derives). ──
|
||||
function outboxRows(): Array<Record<string, unknown>> {
|
||||
return db.prepare("SELECT * FROM outbox_entries ORDER BY rowid").all() as Array<Record<string, unknown>>;
|
||||
}
|
||||
function sendApp() {
|
||||
// createFullTestDb excludes 027 (outbox is not on the shared core edge — P24); migrate it inline,
|
||||
// plus the 067 identity_provenance column (outbox part), so the auto-record can stamp the era.
|
||||
migrate(db, [outboxEntriesSchema]);
|
||||
const hasProv = (db.prepare("PRAGMA table_info(outbox_entries)").all() as Array<{ name: string }>)
|
||||
.some((c) => c.name === "identity_provenance");
|
||||
if (!hasProv) db.exec("ALTER TABLE outbox_entries ADD COLUMN identity_provenance TEXT");
|
||||
const transport = new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter: mockTmux() });
|
||||
return createApp({ sessionTransport: transport, outboxHandler: new OutboxHandler(db) });
|
||||
}
|
||||
|
||||
it("A3 — a DERIVED send auto-records exactly ONE outbox row (sender=derived actor, provenance=transport:v1)", async () => {
|
||||
seedRig();
|
||||
const res = await sendApp().request("/api/transport/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-OpenRig-Session": "orch@my-rig" },
|
||||
body: JSON.stringify({ session: "dev-impl@my-rig", text: "hello" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const rows = outboxRows();
|
||||
expect(rows).toHaveLength(1); // exactly one, not zero, not two
|
||||
expect(rows[0]!["sender_session"]).toBe("orch@my-rig"); // the DERIVED actor, never a body claim
|
||||
expect(rows[0]!["destination_session"]).toBe("dev-impl@my-rig");
|
||||
expect(rows[0]!["body"]).toBe("hello");
|
||||
expect(rows[0]!["identity_provenance"]).toBe("transport:v1"); // era-stamp from the run, not hardcoded-lying
|
||||
});
|
||||
|
||||
it("A3 — a cross-host send records the ORIGIN triple (the derived header), never the relay's seat", async () => {
|
||||
seedRig();
|
||||
const res = await sendApp().request("/api/transport/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-OpenRig-Session": "orch@rig-a@origin-host" }, // 3-part origin (A2/A4 carry)
|
||||
body: JSON.stringify({ session: "dev-impl@my-rig", text: "coordinate" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const rows = outboxRows();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!["sender_session"]).toBe("orch@rig-a@origin-host"); // ORIGIN triple, not the relay
|
||||
});
|
||||
|
||||
it("A3 — a REFUSED send (body actor != header ⇒ 409 identity_mismatch) writes NO outbox row", async () => {
|
||||
seedRig();
|
||||
const res = await sendApp().request("/api/transport/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-OpenRig-Session": "orch@my-rig" },
|
||||
body: JSON.stringify({ session: "dev-impl@my-rig", text: "hi", actorSession: "mallory@my-rig" }),
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
expect(outboxRows()).toHaveLength(0); // refused before dispatch → nothing recorded
|
||||
});
|
||||
|
||||
it("A3 — a header-LESS send (no derived actor) auto-records NO row (nothing to attribute; no fabricated sender)", async () => {
|
||||
seedRig();
|
||||
const res = await sendApp().request("/api/transport/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" }, // no X-OpenRig-Session
|
||||
body: JSON.stringify({ session: "dev-impl@my-rig", text: "hi" }),
|
||||
});
|
||||
expect(res.status).toBe(200); // delivers (non-interactive send tolerates a null actor)
|
||||
expect(outboxRows()).toHaveLength(0); // but no derived sender ⇒ no auto-record, never a null-sender row
|
||||
});
|
||||
|
||||
// OPR.99.0.6.3 — the additive outcome field auto-surfaces through the
|
||||
// c.json(result) passthrough; failure HTTP mapping unchanged.
|
||||
it("POST /send with verify surfaces the outcome field in the JSON response (passthrough)", async () => {
|
||||
|
||||
Reference in new issue
Block a user