@@ -58,8 +58,6 @@ pub fn scaffold_repo(manifest: &Manifest, output_dir: &Path) -> ScaffoldResult {
5858 files. push ( generate_ci_workflow ( & model, & iser_name) ) ;
5959 // Regeneration trigger (boj-server cartridge pattern, standards#89 sub-issue 1)
6060 files. push ( generate_regen_workflow ( & iser_name) ) ;
61- // Unified transaction-gated adapter (single loopback port, SSE, standards#89 sub-issue 1)
62- files. push ( generate_unified_adapter ( & model, & iser_name) ) ;
6361
6462 // Documentation and governance
6563 files. push ( generate_readme ( manifest, & model, & iser_name) ) ;
@@ -792,8 +790,13 @@ fn test_codegen_output() {{
792790// ---------------------------------------------------------------------------
793791
794792// ---------------------------------------------------------------------------
795- // File generators — CI/CD (regeneration trigger + unified adapter )
793+ // File generators — CI/CD (regeneration trigger)
796794// ---------------------------------------------------------------------------
795+ //
796+ // The unified transaction-gated adapter belongs to the boj-server cartridge
797+ // for this -iser (e.g. boj-server/cartridges/<name>-mcp/adapter/), NOT to the
798+ // -iser repo itself. Scaffolding the cartridge skeleton is tracked
799+ // separately under standards#89 Phase 2b — see iseriser ROADMAP.
797800
798801/// Generate `.github/workflows/<iser_name>-regen.yml` — the central-trigger
799802/// workflow that fires the boj-server cartridge to regenerate `generated/*`
@@ -852,220 +855,6 @@ jobs:
852855 }
853856}
854857
855- /// Generate `adapter/<iser_name>_adapter.zig` — the unified transaction-gated
856- /// BoJ adapter (single loopback listener, SSE + REST + GraphQL + gRPC-compat,
857- /// one gated dispatch → one Zig ABI).
858- ///
859- /// Replaces the ssg-era 3-parallel-port adapter estate-wide as of standards#89
860- /// sub-issue 1 (pilot: k9iser-mcp, boj-server#73). Internal-only per ADR-0004:
861- /// the public surface is the http-capability-gateway (tier-2).
862- fn generate_unified_adapter ( model : & LanguageModel , iser_name : & str ) -> GeneratedFile {
863- let content = format ! (
864- r#"// SPDX-License-Identifier: PMPL-1.0-or-later
865- // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
866- //
867- // {iser_name}/adapter/{iser_name}_adapter.zig
868- //
869- // INTERNAL-ONLY unified transaction-gated adapter. Per ADR-0004 the only
870- // governed public surface is the http-capability-gateway (tier-2) in front;
871- // cartridge adapters bind loopback and sit behind it.
872- //
873- // One listener, one port, protocol-routed:
874- // POST /invoke → REST (JSON in/out)
875- // POST /sse → SSE (text/event-stream)
876- // POST /graphql → GraphQL (op parsed from body)
877- // POST /grpc/<Svc>/<Mthd> → gRPC-compat (tool = method)
878- //
879- // Every request passes the transaction gate (exposureGate, mirroring the
880- // Idris2 `exposureSatisfied` contract) BEFORE dispatch. No request reaches
881- // the ABI ungated — estate interface-safety policy.
882- //
883- // Generated by iseriser for {lang_name} (standards#89 sub-issue 1).
884- // Pattern source: k9iser-mcp, boj-server#73.
885-
886- const std = @import("std");
887- const ffi = @import("{iser_name}_ffi");
888-
889- // Loopback-only by construction: fronted by http-capability-gateway (ADR-0004).
890- const BIND_IP = [4]u8{{ 127, 0, 0, 1 }};
891- const PORT: u16 = 9390; // Override in cartridge manifest if port conflicts.
892-
893- // ── Transaction gate (mirrors Idris2 {module_name}.exposureSatisfied) ──────
894- //
895- // Encoding: 0=Public 1=Authenticated 2=Internal.
896- const Exposure = enum(u8) {{ public = 0, authenticated = 1, internal = 2 }};
897-
898- // Default: require Public (for unauthenticated cartridges).
899- // Set to .authenticated for credential-bearing cartridges.
900- const REQUIRED_EXPOSURE: Exposure = .public;
901-
902- /// Zig mirror of Idris2 `exposureSatisfied`. Cross-checked by the truth-table
903- /// test below; the Idris2 module is the source-of-truth contract.
904- fn exposureSatisfied(required: Exposure, presented: Exposure, is_local: bool) bool {{
905- if (is_local) return true;
906- return switch (required) {{
907- .public => true,
908- .authenticated => presented == .authenticated or presented == .internal,
909- .internal => presented == .internal,
910- }};
911- }}
912-
913- /// Parse the `X-Trust-Level` header the gateway/sidecar injects.
914- fn presentedExposure(req: []const u8) Exposure {{
915- const val = headerValue(req, "x-trust-level") orelse return .public;
916- if (eqIgnoreCase(val, "internal")) return .internal;
917- if (eqIgnoreCase(val, "authenticated")) return .authenticated;
918- return .public;
919- }}
920-
921- // ── Request routing ──────────────────────────────────────────────────────────
922-
923- fn dispatchTool(tool: []const u8, body: []const u8, resp: []u8) struct {{ status: u16, body: []u8 }} {{
924- _ = body;
925- // Route to the {iser_name}_ffi ABI symbol. Extend as tools are added.
926- _ = tool;
927- const msg = std.fmt.bufPrint(resp, "{{{{\"tool\":\"{iser_name}\",\"status\":\"dispatched\"}}}}", .{{}}) catch resp[0..0];
928- return .{{ .status = 200, .body = msg }};
929- }}
930-
931- fn dispatchRest(path: []const u8, body: []const u8, is_local: bool, req: []const u8, resp: []u8) struct {{ status: u16, body: []u8 }} {{
932- const presented = presentedExposure(req);
933- if (!exposureSatisfied(REQUIRED_EXPOSURE, presented, is_local)) {{
934- const msg = std.fmt.bufPrint(resp, "{{{{\"error\":\"forbidden\"}}}}", .{{}}) catch resp[0..0];
935- return .{{ .status = 403, .body = msg }};
936- }}
937- const prefix = "/invoke";
938- if (std.mem.startsWith(u8, path, prefix)) {{
939- // Extract tool name from JSON body field "tool".
940- const tool = jsonField(body, "tool") orelse "unknown";
941- return dispatchTool(tool, body, resp);
942- }}
943- const msg = std.fmt.bufPrint(resp, "{{{{\"error\":\"not found\"}}}}", .{{}}) catch resp[0..0];
944- return .{{ .status = 404, .body = msg }};
945- }}
946-
947- fn dispatchSse(body: []const u8, is_local: bool, req: []const u8, stream: std.net.Stream) void {{
948- const presented = presentedExposure(req);
949- if (!exposureSatisfied(REQUIRED_EXPOSURE, presented, is_local)) {{
950- _ = stream.write("HTTP/1.1 403 Forbidden\r\n\r\n") catch {{}};
951- return;
952- }}
953- const tool = jsonField(body, "tool") orelse "unknown";
954- _ = stream.write("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\n\r\n") catch {{}};
955- var buf: [256]u8 = undefined;
956- const event = std.fmt.bufPrint(&buf, "data: {{{{\"tool\":\"{iser_name}\",\"dispatched\":\"{{}}\"}}}}\n\n", .{{tool}}) catch &buf;
957- _ = stream.write(event) catch {{}};
958- }}
959-
960- // ── TCP listener ─────────────────────────────────────────────────────────────
961-
962- fn handleConnection(stream: std.net.Stream) void {{
963- defer stream.close();
964- var buf: [4096]u8 = undefined;
965- const n = stream.read(&buf) catch return;
966- const req = buf[0..n];
967-
968- const is_local = true; // All connections via loopback are locally trusted.
969-
970- const path_start = std.mem.indexOf(u8, req, " ") orelse return;
971- const path_end = std.mem.indexOf(u8, req[path_start + 1 ..], " ") orelse return;
972- const path = req[path_start + 1 .. path_start + 1 + path_end];
973-
974- var resp_buf: [2048]u8 = undefined;
975-
976- if (std.mem.startsWith(u8, path, "/sse")) {{
977- const body = extractBody(req);
978- dispatchSse(body, is_local, req, stream);
979- }} else {{
980- const body = extractBody(req);
981- const result = dispatchRest(path, body, is_local, req, &resp_buf);
982- const http = std.fmt.bufPrint(&resp_buf, "HTTP/1.1 {{}} OK\r\nContent-Type: application/json\r\n\r\n{{s}}", .{{ result.status, result.body }}) catch &resp_buf;
983- _ = stream.write(http) catch {{}};
984- }}
985- }}
986-
987- pub fn main() !void {{
988- const addr = std.net.Address.initIp4(BIND_IP, PORT);
989- var server = try addr.listen(.{{ .reuse_address = true }});
990- defer server.deinit();
991- std.log.info("{iser_name} unified adapter listening on 127.0.0.1:{{}} (loopback-only, ADR-0004)", .{{PORT}});
992- while (true) {{
993- const conn = server.accept() catch continue;
994- const t = std.Thread.spawn(.{{}}, handleConnection, .{{conn.stream}}) catch continue;
995- t.detach();
996- }}
997- }}
998-
999- // ── Helpers ──────────────────────────────────────────────────────────────────
1000-
1001- fn headerValue(req: []const u8, name: []const u8) ?[]const u8 {{
1002- var lines = std.mem.splitScalar(u8, req, '\n');
1003- while (lines.next()) |line| {{
1004- if (eqIgnoreCase(line[0..@min(name.len, line.len)], name)) {{
1005- const col = std.mem.indexOf(u8, line, ":") orelse continue;
1006- return std.mem.trim(u8, line[col + 1 ..], &std.ascii.whitespace);
1007- }}
1008- }}
1009- return null;
1010- }}
1011-
1012- fn eqIgnoreCase(a: []const u8, b: []const u8) bool {{
1013- if (a.len != b.len) return false;
1014- for (a, b) |ca, cb| {{
1015- if (std.ascii.toLower(ca) != std.ascii.toLower(cb)) return false;
1016- }}
1017- return true;
1018- }}
1019-
1020- fn jsonField(body: []const u8, key: []const u8) ?[]const u8 {{
1021- var search_buf: [64]u8 = undefined;
1022- const needle = std.fmt.bufPrint(&search_buf, "\"{{}}\": \"", .{{key}}) catch return null;
1023- const start = std.mem.indexOf(u8, body, needle) orelse return null;
1024- const val_start = start + needle.len;
1025- const val_end = std.mem.indexOf(u8, body[val_start..], "\"") orelse return null;
1026- return body[val_start .. val_start + val_end];
1027- }}
1028-
1029- fn extractBody(req: []const u8) []const u8 {{
1030- const sep = "\r\n\r\n";
1031- const pos = std.mem.indexOf(u8, req, sep) orelse return req;
1032- return req[pos + sep.len ..];
1033- }}
1034-
1035- // ── Tests ────────────────────────────────────────────────────────────────────
1036-
1037- test "exposureSatisfied: loopback always passes" {{
1038- try std.testing.expect(exposureSatisfied(.internal, .public, true));
1039- try std.testing.expect(exposureSatisfied(.authenticated, .public, true));
1040- }}
1041-
1042- test "exposureSatisfied: public gate passes all" {{
1043- try std.testing.expect(exposureSatisfied(.public, .public, false));
1044- try std.testing.expect(exposureSatisfied(.public, .authenticated, false));
1045- try std.testing.expect(exposureSatisfied(.public, .internal, false));
1046- }}
1047-
1048- test "exposureSatisfied: authenticated gate rejects public" {{
1049- try std.testing.expect(!exposureSatisfied(.authenticated, .public, false));
1050- try std.testing.expect(exposureSatisfied(.authenticated, .authenticated, false));
1051- try std.testing.expect(exposureSatisfied(.authenticated, .internal, false));
1052- }}
1053-
1054- test "exposureSatisfied: internal gate rejects public and authenticated" {{
1055- try std.testing.expect(!exposureSatisfied(.internal, .public, false));
1056- try std.testing.expect(!exposureSatisfied(.internal, .authenticated, false));
1057- try std.testing.expect(exposureSatisfied(.internal, .internal, false));
1058- }}
1059- "# ,
1060- iser_name = iser_name,
1061- lang_name = model. name,
1062- module_name = idris2_module_name( model) ,
1063- ) ;
1064- GeneratedFile {
1065- path : PathBuf :: from ( format ! ( "adapter/{iser_name}_adapter.zig" ) ) ,
1066- content,
1067- }
1068- }
1069858
1070859/// Generate `.github/workflows/ci.yml` for the new -iser.
1071860fn generate_ci_workflow ( _model : & LanguageModel , iser_name : & str ) -> GeneratedFile {
@@ -1380,10 +1169,11 @@ description = "Chapel distributed computing -iser"
13801169 ) ;
13811170 let repo = result. repo ( ) . unwrap ( ) ;
13821171 assert_eq ! ( repo. name, "chapeliser" ) ;
1383- // 22+ files: 20 base + regen workflow + unified adapter (standards#89 sub-issue 1)
1172+ // 21+ files: 20 base + regen workflow (standards#89 sub-issue 1).
1173+ // The unified adapter belongs to the boj-server cartridge, not this repo.
13841174 assert ! (
1385- repo. file_count( ) >= 22 ,
1386- "expected 22 + files, got {}" ,
1175+ repo. file_count( ) >= 21 ,
1176+ "expected 21 + files, got {}" ,
13871177 repo. file_count( )
13881178 ) ;
13891179 }
@@ -1429,9 +1219,9 @@ description = "Chapel distributed computing -iser"
14291219 assert ! ( repo_root. join( "src/interface/abi/Types.idr" ) . exists( ) ) ;
14301220 assert ! ( repo_root. join( "ffi/zig/src/main.zig" ) . exists( ) ) ;
14311221 assert ! ( repo_root. join( ".github/workflows/ci.yml" ) . exists( ) ) ;
1432- // standards#89 sub-issue 1: regen trigger + unified adapter
1222+ // standards#89 sub-issue 1: regen trigger only.
1223+ // The unified adapter belongs to the boj-server cartridge, not this repo.
14331224 assert ! ( repo_root. join( ".github/workflows/chapeliser-regen.yml" ) . exists( ) ) ;
1434- assert ! ( repo_root. join( "adapter/chapeliser_adapter.zig" ) . exists( ) ) ;
14351225 assert ! ( repo_root. join( "README.adoc" ) . exists( ) ) ;
14361226 assert ! ( repo_root. join( "LICENSE" ) . exists( ) ) ;
14371227 }
0 commit comments