Skip to content

Commit 634c163

Browse files
hyperpolymathclaude
andcommitted
feat(local-coord-mcp): role rename (Task #32) + coord_transfer_master (Task #35)
Task #32 — supervisor/executor/supervised → master/journeyman/apprentice. Enum ordinals preserved (0/1/2) so replay still decodes old logs. Old string names accepted as register role aliases for one release per the DD-32 migration plan. BOJ_MASTER_TOKEN is the canonical env var; BOJ_SUPERVISOR_TOKEN read as back-compat fallback. Task #35 — live master handoff without process restart. coord_transfer_master(current_token, new_peer_id, secret) -> c_int 0 transferred (caller demoted to journeyman, target promoted) -1 caller not master / bad token -2 target not found / same as caller -3 secret mismatch (BOJ_MASTER_TOKEN env var) -4 target is apprentice — rejected, must be journeyman+ Secret gated by BOJ_MASTER_TOKEN (BOJ_SUPERVISOR_TOKEN fallback). Target role-gated before secret check so apprentice targets are rejected regardless of secret validity (prevents hostile-target handoff). Audit breadcrumb logged (kind=2 MASTER_HANDOFF, detail=from=i|to=j) + two PEER_ROLE_SET events, so replay reconstructs the transfer. Adapter endpoint + cartridge.json tool + mcp-bridge dispatch wired. Three FFI tests: rejects apprentice target, rejects non-master caller, rejects bad target index. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ed85ca2 commit 634c163

7 files changed

Lines changed: 367 additions & 159 deletions

File tree

cartridges/local-coord-mcp/adapter/local_coord_adapter.zig

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -83,21 +83,23 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
8383
break :blk ctx_val.string;
8484
};
8585

86-
// Optional role hint. Server rejects role=supervisor here; callers
87-
// must promote via coord_promote_to_supervisor with the env secret.
86+
// Optional role hint. Server rejects role=master here; callers
87+
// must promote via coord_promote_to_master with the env secret.
88+
// Old names (supervisor/executor/supervised) accepted as aliases
89+
// for one release per DD-32.
8890
const role_hint: i32 = blk: {
8991
const rv = parsed.value.object.get("role") orelse break :blk -1;
9092
const rs = rv.string;
91-
if (std.mem.eql(u8, rs, "supervisor")) break :blk 0;
92-
if (std.mem.eql(u8, rs, "executor")) break :blk 1;
93-
if (std.mem.eql(u8, rs, "supervised")) break :blk 2;
93+
if (std.mem.eql(u8, rs, "master") or std.mem.eql(u8, rs, "supervisor")) break :blk 0;
94+
if (std.mem.eql(u8, rs, "journeyman") or std.mem.eql(u8, rs, "executor")) break :blk 1;
95+
if (std.mem.eql(u8, rs, "apprentice") or std.mem.eql(u8, rs, "supervised")) break :blk 2;
9496
break :blk -1;
9597
};
9698

9799
var token: [16]u8 = undefined;
98100
var suffix: [4]u8 = undefined;
99101
const idx = ffi.coord_register(kind, role_hint, &token, &suffix);
100-
if (idx == -3) return .{ .status = 400, .body = errJson(resp, "supervisor role must be obtained via coord_promote_to_supervisor") };
102+
if (idx == -3) return .{ .status = 400, .body = errJson(resp, "master role must be obtained via coord_promote_to_master") };
101103
if (idx < 0) return .{ .status = 500, .body = errJson(resp, "registry full") };
102104

103105
if (ctx_str.len > 0) {
@@ -309,7 +311,10 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
309311
return .{ .status = 500, .body = errJson(resp, "claim failed") };
310312
}
311313

312-
if (std.mem.eql(u8, tool, "coord_promote_to_supervisor")) {
314+
// Old name accepted as alias per DD-32 backward-compat (one release).
315+
if (std.mem.eql(u8, tool, "coord_promote_to_master") or
316+
std.mem.eql(u8, tool, "coord_promote_to_supervisor"))
317+
{
313318
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") };
314319
defer parsed.deinit();
315320
const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") };
@@ -318,15 +323,40 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
318323
if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") };
319324

320325
const secret = secret_val.string;
321-
const rc = ffi.coord_promote_to_supervisor(&token, 16, secret.ptr, @intCast(secret.len));
326+
const rc = ffi.coord_promote_to_master(&token, 16, secret.ptr, @intCast(secret.len));
322327
if (rc == 0) return .{ .status = 200, .body = okJson(resp, "promoted") };
323328
if (rc == -1) return .{ .status = 401, .body = errJson(resp, "unauthenticated") };
324-
if (rc == -2) return .{ .status = 409, .body = errJson(resp, "supervisor already exists") };
325-
if (rc == -3) return .{ .status = 403, .body = errJson(resp, "supervisor role not configured on this server") };
329+
if (rc == -2) return .{ .status = 409, .body = errJson(resp, "master already exists") };
330+
if (rc == -3) return .{ .status = 403, .body = errJson(resp, "master role not configured on this server") };
326331
if (rc == -4) return .{ .status = 403, .body = errJson(resp, "secret does not match") };
327332
return .{ .status = 500, .body = errJson(resp, "promotion failed") };
328333
}
329334

335+
if (std.mem.eql(u8, tool, "coord_transfer_master")) {
336+
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") };
337+
defer parsed.deinit();
338+
const token_val = parsed.value.object.get("token") orelse return .{ .status = 400, .body = errJson(resp, "missing token") };
339+
const target_val = parsed.value.object.get("new_peer_id") orelse return .{ .status = 400, .body = errJson(resp, "missing new_peer_id") };
340+
const secret_val = parsed.value.object.get("secret") orelse return .{ .status = 400, .body = errJson(resp, "missing secret") };
341+
342+
var token: [16]u8 = undefined;
343+
if (!parseToken(token_val.string, &token)) return .{ .status = 400, .body = errJson(resp, "invalid token hex") };
344+
345+
const target_str = target_val.string;
346+
const suffix = extractSuffix(target_str) orelse return .{ .status = 400, .body = errJson(resp, "invalid new_peer_id format — expected <kind>-<4hex>[@<context>]") };
347+
const target_idx = ffi.coord_find_peer_by_suffix(suffix.ptr);
348+
if (target_idx < 0) return .{ .status = 404, .body = errJson(resp, "target peer not found") };
349+
350+
const secret = secret_val.string;
351+
const rc = ffi.coord_transfer_master(&token, 16, target_idx, secret.ptr, @intCast(secret.len));
352+
if (rc == 0) return .{ .status = 200, .body = okJson(resp, "transferred") };
353+
if (rc == -1) return .{ .status = 401, .body = errJson(resp, "caller is not the current master") };
354+
if (rc == -2) return .{ .status = 404, .body = errJson(resp, "target peer not found or same as caller") };
355+
if (rc == -3) return .{ .status = 403, .body = errJson(resp, "secret does not match BOJ_MASTER_TOKEN") };
356+
if (rc == -4) return .{ .status = 403, .body = errJson(resp, "target is an apprentice — must be journeyman or master") };
357+
return .{ .status = 500, .body = errJson(resp, "transfer failed") };
358+
}
359+
330360
if (std.mem.eql(u8, tool, "coord_send_gated")) {
331361
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch return .{ .status = 400, .body = errJson(resp, "invalid json") };
332362
defer parsed.deinit();
@@ -363,7 +393,7 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
363393
if (rc == -2) return .{ .status = 404, .body = errJson(resp, "target peer not found") };
364394
if (rc == -3) return .{ .status = 503, .body = errJson(resp, "target inbox full") };
365395
if (rc == -4) return .{ .status = 503, .body = errJson(resp, "quarantine queue full — spill to VeriSimDB not yet wired") };
366-
if (rc == -5) return .{ .status = 428, .body = errJson(resp, "no supervisor registered — Tier 2+ from supervised requires a supervisor") };
396+
if (rc == -5) return .{ .status = 428, .body = errJson(resp, "no master registered — Tier 2+ from apprentice requires a master") };
367397
return .{ .status = 500, .body = errJson(resp, "gated send failed") };
368398
}
369399

@@ -377,7 +407,7 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
377407
// 32 entries max * 16 bytes per record = 512 bytes raw.
378408
var raw: [512]u8 = undefined;
379409
const count = ffi.coord_review(&token, 16, &raw, @intCast(raw.len));
380-
if (count < 0) return .{ .status = 403, .body = errJson(resp, "supervisor role required") };
410+
if (count < 0) return .{ .status = 403, .body = errJson(resp, "master role required") };
381411

382412
var stream = std.io.fixedBufferStream(resp);
383413
const w = stream.writer();
@@ -419,7 +449,7 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
419449

420450
var msg_buf: [512]u8 = undefined;
421451
const rc = ffi.coord_review_entry(&token, 16, @intCast(rid_val.integer), &msg_buf, @intCast(msg_buf.len));
422-
if (rc == -1) return .{ .status = 403, .body = errJson(resp, "supervisor role required") };
452+
if (rc == -1) return .{ .status = 403, .body = errJson(resp, "master role required") };
423453
if (rc == -2) return .{ .status = 404, .body = errJson(resp, "request_id not found") };
424454
if (rc < 0) return .{ .status = 500, .body = errJson(resp, "review failed") };
425455

@@ -438,7 +468,7 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
438468

439469
const rc = ffi.coord_approve(&token, 16, @intCast(rid_val.integer));
440470
if (rc == 0) return .{ .status = 200, .body = okJson(resp, "approved") };
441-
if (rc == -1) return .{ .status = 403, .body = errJson(resp, "supervisor role required") };
471+
if (rc == -1) return .{ .status = 403, .body = errJson(resp, "master role required") };
442472
if (rc == -2) return .{ .status = 404, .body = errJson(resp, "request_id not found") };
443473
if (rc == -3) return .{ .status = 503, .body = errJson(resp, "target inbox full — retry") };
444474
return .{ .status = 500, .body = errJson(resp, "approve failed") };
@@ -596,7 +626,7 @@ fn dispatch(tool: []const u8, body: []const u8, resp: []u8, allocator: std.mem.A
596626
const reason = reason_val.string;
597627
const rc = ffi.coord_reject(&token, 16, @intCast(rid_val.integer), reason.ptr, @intCast(reason.len));
598628
if (rc == 0) return .{ .status = 200, .body = okJson(resp, "rejected") };
599-
if (rc == -1) return .{ .status = 403, .body = errJson(resp, "supervisor role required") };
629+
if (rc == -1) return .{ .status = 403, .body = errJson(resp, "master role required") };
600630
if (rc == -2) return .{ .status = 404, .body = errJson(resp, "request_id not found") };
601631
return .{ .status = 500, .body = errJson(resp, "reject failed") };
602632
}

cartridges/local-coord-mcp/cartridge.json

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,33 @@
435435
"type": "object"
436436
},
437437
"name": "coord_scan_suggestions"
438+
},
439+
{
440+
"description": "Live master handoff (Task #35). Outgoing master passes the role to a named successor without a process restart. Secret-gated by BOJ_MASTER_TOKEN (BOJ_SUPERVISOR_TOKEN fallback). Target must be journeyman or already master — apprentices rejected. Audit-logged so replay reconstructs the transfer.",
441+
"inputSchema": {
442+
"properties": {
443+
"new_peer_id": {
444+
"description": "Peer id of the successor (kind-4hex[@context])",
445+
"type": "string"
446+
},
447+
"secret": {
448+
"description": "Must match BOJ_MASTER_TOKEN env var on the server",
449+
"type": "string"
450+
},
451+
"token": {
452+
"description": "Current master's session token",
453+
"type": "string"
454+
}
455+
},
456+
"required": [
457+
"token",
458+
"new_peer_id",
459+
"secret"
460+
],
461+
"type": "object"
462+
},
463+
"name": "coord_transfer_master"
438464
}
439465
],
440-
"version": "0.5.0"
466+
"version": "0.6.0"
441467
}

0 commit comments

Comments
 (0)