zynk 1.1.0

Portable protocol and helper CLI for multi-agent collaboration.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
use crate::db_dashboard::{escape_url_component, percent_decode};
use crate::{CliError, CliResult};
use rand::Rng;
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;

/// A parsed HTTP request: request line + lower-cased header map + raw body.
/// ADR 031: the write path needs the headers (Host/Origin/X-Zynk-CSRF) and the
/// POST body, which the read-only server never parsed.
#[derive(Debug)]
pub struct HttpRequest {
    pub method: String,
    pub route: String,
    pub query: String,
    pub headers: HashMap<String, String>,
    pub body: Vec<u8>,
    /// R1 P5: the `headers` map collapses duplicate names (last value wins), which
    /// would let a request carry both `Host: <served>` and `Host: evil.test` and
    /// still pass the exact-Host guard. We count the raw `host:` header lines so the
    /// guard can fail closed unless exactly one Host is present.
    pub host_count: usize,
}

impl HttpRequest {
    pub fn header(&self, name: &str) -> Option<&str> {
        self.headers.get(name).map(String::as_str)
    }
}

/// Parse a request from the raw header text and the already-read body bytes.
pub fn parse_request(head: &str, body: Vec<u8>) -> HttpRequest {
    let mut lines = head.split("\r\n");
    let request_line = lines.next().unwrap_or("GET / HTTP/1.1");
    let mut parts = request_line.split_whitespace();
    let method = parts.next().unwrap_or("GET").to_string();
    let target = parts.next().unwrap_or("/");
    let (route, query) = target.split_once('?').unwrap_or((target, ""));
    let mut headers = HashMap::new();
    let mut host_count = 0usize;
    for line in lines {
        if let Some((name, value)) = line.split_once(':') {
            let name = name.trim().to_ascii_lowercase();
            if name == "host" {
                host_count += 1;
            }
            headers.insert(name, value.trim().to_string());
        }
    }
    HttpRequest {
        method,
        route: route.to_string(),
        query: query.to_string(),
        headers,
        body,
        host_count,
    }
}

/// A high-entropy per-serve CSRF token (hex of 32 random bytes).
pub fn mint_csrf_token() -> String {
    let bytes: [u8; 32] = rand::thread_rng().gen();
    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}

/// ADR 031 D4: authorize a browser write — exact Host (anti-DNS-rebind), exact
/// same-origin, the per-serve CSRF token in the `X-Zynk-CSRF` header, POST method.
/// (OPTIONS / non-POST are also rejected by the 405 arm; no CORS headers are ever
/// emitted, so a cross-origin request cannot preflight the custom header.)
pub fn authorize_write(request: &HttpRequest, authority: &str, csrf_token: &str) -> CliResult<()> {
    if request.method != "POST" {
        return Err(CliError::usage("writes require POST"));
    }
    if request.header("host") != Some(authority) {
        return Err(CliError::usage("Host mismatch"));
    }
    let expected_origin = format!("http://{authority}");
    if request.header("origin") != Some(expected_origin.as_str()) {
        return Err(CliError::usage("Origin mismatch"));
    }
    let presented = request.header("x-zynk-csrf").unwrap_or("");
    // Length-checked, constant-time-ish compare (tokens are fixed length).
    if presented.len() != csrf_token.len()
        || presented
            .bytes()
            .zip(csrf_token.bytes())
            .fold(0u8, |acc, (a, b)| acc | (a ^ b))
            != 0
    {
        return Err(CliError::usage("CSRF token mismatch"));
    }
    Ok(())
}

/// Parse an `application/x-www-form-urlencoded` body into a field map.
pub fn parse_form(body: &[u8]) -> HashMap<String, String> {
    let text = String::from_utf8_lossy(body);
    let mut fields = HashMap::new();
    for pair in text.split('&') {
        if let Some((key, value)) = pair.split_once('=') {
            fields.insert(
                percent_decode(&key.replace('+', " ")),
                percent_decode(&value.replace('+', " ")),
            );
        }
    }
    fields
}

/// A short unique message id for an operator-originated record (`op-<hex>`):
/// a dashboard-originated send (`--mid` is required by the audited send,
/// send_herdr.rs) and the `zynk decide` decision audit (M2a T4) both mint one.
pub(crate) fn mint_mid() -> String {
    let bytes: [u8; 8] = rand::thread_rng().gen();
    let hex: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
    format!("op-{hex}")
}

/// ADR 031 D2/D3: the typed argv for the composer send. The browser supplies only
/// values (session/to/type/mid/body); every flag is chosen here. source =
/// `operator:dashboard` (D1), `command_origin=operator`, and the served
/// `--db`/`--root` plus the generated `--mid` are pinned. The audited send
/// requires `target_address == --pane`, so `--pane` is the address half of `--to`.
// The args are intentionally explicit (server-pinned db/root/herdr_bin + the
// validated form values + the generated mid); a struct would only add ceremony.
#[allow(clippy::too_many_arguments)]
pub fn send_argv(
    db: &Path,
    root: &Path,
    herdr_bin: &str,
    session: &str,
    to: &str,
    message_type: &str,
    mid: &str,
    body: &str,
) -> Vec<String> {
    let target_address = to.split_once(':').map(|(_, addr)| addr).unwrap_or(to);
    vec![
        "send".into(),
        "herdr".into(),
        "--herdr-bin".into(),
        herdr_bin.into(),
        "--pane".into(),
        target_address.into(),
        "--db".into(),
        db.display().to_string(),
        "--root".into(),
        root.display().to_string(),
        "--session-id".into(),
        session.into(),
        "--from".into(),
        "operator:dashboard".into(),
        "--to".into(),
        to.into(),
        "--command-origin".into(),
        "operator".into(),
        "--mid".into(),
        mid.into(),
        "--type".into(),
        message_type.into(),
        "--body".into(),
        body.into(),
    ]
}

/// The result of a browser write attempt.
pub enum WriteOutcome {
    /// PRG: redirect to this GET location after a successful write.
    Redirect(String),
    /// M3b/ADR 035 D1: the decrypted reveal plaintext to render INLINE (the caller
    /// HTML-escapes it + sends Cache-Control: no-store). NEVER a redirect.
    RevealPlaintext(String),
    /// A failure to render (escaped by the caller) with this status + message.
    Error {
        status: &'static str,
        message: String,
    },
}

/// ADR 031 D2: build the typed argv from the validated form, spawn `current_exe()`
/// `zynk` (argv array, never a shell), capture stdout/stderr. On success, PRG-
/// redirect back to the session feed; on failure, return an Error whose message
/// includes the child output — which the caller HTML-escapes before rendering
/// (ADR 031 C1: the body can be hostile, so surfaced output is escaped/text-only).
/// Validation happens BEFORE the child spawns (D6).
pub fn handle_send(request: &HttpRequest, db: &Path, root: &Path, herdr_bin: &str) -> WriteOutcome {
    let form = parse_form(&request.body);
    let (session, to, body) = match (form.get("session"), form.get("to"), form.get("body")) {
        (Some(s), Some(t), Some(b)) if !s.is_empty() && !t.is_empty() && !b.is_empty() => (s, t, b),
        _ => {
            return WriteOutcome::Error {
                status: "400 Bad Request",
                message: "session, to, and body are required".to_string(),
            };
        }
    };
    let message_type = form
        .get("type")
        .map(String::as_str)
        .filter(|value| !value.is_empty())
        .unwrap_or("status-update");
    // ADR 031 D1: validate against the served DB BEFORE spawning — the session must
    // already exist (no browser-originated session creation) and the target must be
    // a known agent/address row (not free-typed). The read connection is dropped
    // inside the helper, before the child runs (the child owns the write).
    if let Err(outcome) = validate_send_target(db, session, to) {
        return outcome;
    }
    let exe = match std::env::current_exe() {
        Ok(exe) => exe,
        Err(error) => {
            return WriteOutcome::Error {
                status: "500 Internal Server Error",
                message: format!("cannot resolve zynk binary: {error}"),
            };
        }
    };
    let argv = send_argv(
        db,
        root,
        herdr_bin,
        session,
        to,
        message_type,
        &mint_mid(),
        body,
    );
    match Command::new(exe).args(&argv).output() {
        Ok(out) if out.status.success() => {
            WriteOutcome::Redirect(format!("/?session={}", escape_url_component(session)))
        }
        Ok(out) => WriteOutcome::Error {
            status: "502 Bad Gateway",
            message: format!(
                "send failed:\n{}\n{}",
                String::from_utf8_lossy(&out.stdout),
                String::from_utf8_lossy(&out.stderr)
            ),
        },
        Err(error) => WriteOutcome::Error {
            status: "500 Internal Server Error",
            message: format!("failed to run zynk: {error}"),
        },
    }
}

/// ADR 031 D1: confirm the posted session exists in the served DB and the target
/// is one of its known agent/address rows, before any child spawns. Returns the
/// rejection outcome on failure. The read connection is dropped before returning,
/// so it is never held while the child write runs.
fn validate_send_target(db: &Path, session: &str, to: &str) -> Result<(), WriteOutcome> {
    let connection = crate::db::open_read_database(db).map_err(|error| WriteOutcome::Error {
        status: "500 Internal Server Error",
        message: format!("cannot open dashboard db: {}", error.message),
    })?;
    match crate::db_dashboard::session_exists(&connection, session) {
        Ok(true) => {}
        Ok(false) => {
            return Err(WriteOutcome::Error {
                status: "404 Not Found",
                message: "unknown session — browser writes target an existing session".to_string(),
            });
        }
        Err(error) => {
            return Err(WriteOutcome::Error {
                status: "500 Internal Server Error",
                message: format!("session check failed: {}", error.message),
            });
        }
    }
    let targets = crate::db_dashboard::sendable_targets(&connection, session).map_err(|error| {
        WriteOutcome::Error {
            status: "500 Internal Server Error",
            message: format!("target check failed: {}", error.message),
        }
    })?;
    if !targets.iter().any(|known| known == to) {
        return Err(WriteOutcome::Error {
            status: "400 Bad Request",
            message: "unknown target — choose a known agent:address row".to_string(),
        });
    }
    Ok(())
}

/// ADR 035 D4 / D1: build the typed `zynk reveal <audit_id>` argv from the validated
/// form, spawn `current_exe()` (argv array, NEVER a shell), capture output. Mirrors
/// `handle_send`/`handle_decide`: the route AUTHORIZES + VALIDATES, then shells out to
/// the audited `zynk reveal` — it NEVER reads the vault or writes the proof itself
/// (the M3a CLI owns proof-before-disclosure). The read DB handle used for validation
/// is dropped (inside `validate_reveal`) BEFORE the child runs. On a SUCCESSFUL child
/// exit, the child's stdout (the plaintext) is returned to render INLINE under
/// `no-store` (D1) — NEVER a redirect. On a NONZERO child exit (a proof-write failure,
/// a hash mismatch, a not-revealable race), the route surfaces ONLY the escaped stderr
/// (D4) — the child's stdout is NEVER surfaced as plaintext, so a failed/unaudited
/// reveal never discloses.
pub fn handle_reveal(request: &HttpRequest, db: &Path, root: &Path) -> WriteOutcome {
    let form = parse_form(&request.body);
    let (session, audit_id) = match (form.get("session"), form.get("audit_id")) {
        (Some(s), Some(a)) if !s.is_empty() && !a.is_empty() => (s, a),
        _ => {
            return WriteOutcome::Error {
                status: "400 Bad Request",
                message: "session and audit_id are required".to_string(),
            };
        }
    };
    // ADR 035 D3: session-scoped reveal authorization — the audit_id must EXIST,
    // BELONG to the served session, and have a custody_vault row, ALL checked BEFORE
    // any spawn (the read handle drops inside the helper before the child runs).
    if let Err(outcome) = validate_reveal(db, session, audit_id) {
        return outcome;
    }
    let exe = match std::env::current_exe() {
        Ok(exe) => exe,
        Err(error) => {
            return WriteOutcome::Error {
                status: "500 Internal Server Error",
                message: format!("cannot resolve zynk binary: {error}"),
            };
        }
    };
    // v1 M3b checkpoint P1: OPTIONS BEFORE the positional + a `--` delimiter. Clap
    // stops option parsing at `--`, so a VALIDATED but dash-prefixed `audit_id` (e.g.
    // `-h`) can NEVER be parsed by the child as an OPTION (`--help`). The argv array
    // already blocked SHELL injection; `--` closes the child's own OPTION parsing.
    let argv = [
        "reveal".to_string(),
        "--db".to_string(),
        db.to_string_lossy().into_owned(),
        "--root".to_string(),
        root.to_string_lossy().into_owned(),
        "--".to_string(),
        audit_id.to_string(),
    ];
    match Command::new(exe).args(&argv).output() {
        Ok(out) if out.status.success() => {
            WriteOutcome::RevealPlaintext(String::from_utf8_lossy(&out.stdout).into_owned())
        }
        // D4: on a nonzero child, surface ONLY stderr — NEVER stdout-as-plaintext.
        Ok(out) => WriteOutcome::Error {
            status: "502 Bad Gateway",
            message: format!("reveal failed:\n{}", String::from_utf8_lossy(&out.stderr)),
        },
        Err(error) => WriteOutcome::Error {
            status: "500 Internal Server Error",
            message: format!("failed to run zynk: {error}"),
        },
    }
}

/// ADR 035 D3: the audit_id must EXIST, BELONG to the served session, have a
/// custody_vault row, AND carry a redaction policy that is NOT `full`. Rejects
/// cross-session / missing / non-retained BEFORE any spawn. The `payload_redaction_policy
/// <> 'full'` clause MATCHES the C2 affordance's "revealable" gate (read_model.rs): a
/// `full`-redaction record is already shown plainly, so per D5 there is nothing to reveal.
/// The read connection is dropped before returning, so it is never held while the
/// child reveal runs.
fn validate_reveal(db: &Path, session: &str, audit_id: &str) -> Result<(), WriteOutcome> {
    let connection = crate::db::open_read_database(db).map_err(|error| WriteOutcome::Error {
        status: "500 Internal Server Error",
        message: format!("cannot open dashboard db: {}", error.message),
    })?;
    let revealable: bool = connection
        .query_row(
            "SELECT EXISTS(
                 SELECT 1 FROM audit_records a
                 JOIN custody_vault v ON v.audit_id = a.audit_id
                 WHERE a.audit_id = ?1 AND a.session_id = ?2
                   AND a.payload_redaction_policy <> 'full')",
            rusqlite::params![audit_id, session],
            |row| row.get(0),
        )
        .map_err(|error| WriteOutcome::Error {
            status: "500 Internal Server Error",
            message: format!("reveal validation failed: {error}"),
        })?;
    if !revealable {
        return Err(WriteOutcome::Error {
            status: "404 Not Found",
            message: format!("{audit_id} is not revealable in session {session}"),
        });
    }
    Ok(())
}

/// ADR 033 M2b T4 / ADR 031 D2: the typed argv for a `zynk decide <type>` shell-out
/// (mirrors `send_argv`). The browser supplies only values (session + the per-type
/// fields); every flag is chosen here. The served `--db`/`--root`/`herdr_bin` are
/// pinned and the session is passed through `--session-id`; `decide` itself hardcodes
/// `command_origin=operator` (an operator-authored proof, ADR 033 D4), so the route
/// never passes it. An unknown `<type>` or a missing REQUIRED field for the chosen
/// type is a usage error (rejected here, before any spawn). A notify target (if the
/// form carries one) is passed as `--notify-pane <address> --notify-to <agent:address>
/// --herdr-bin <herdr_bin>` — only after `handle_decide` has confirmed the target is
/// in the known-target allow-list. The served `herdr_bin` is forwarded ONLY on the
/// notify leg (mirroring how `send_argv` always pins it): `decide`'s `--herdr-bin`
/// is inert without a notify target, so the non-notify decide paths are unaffected.
pub fn decide_argv(
    db: &Path,
    root: &Path,
    herdr_bin: &str,
    decision_type: &str,
    form: &HashMap<String, String>,
) -> CliResult<Vec<String>> {
    // A required form field: present AND non-empty, else a usage error.
    let required = |field: &str| -> CliResult<&str> {
        match form.get(field) {
            Some(value) if !value.is_empty() => Ok(value.as_str()),
            _ => Err(CliError::usage(format!("{field} is required"))),
        }
    };
    // An optional form field: only forwarded when present AND non-empty.
    let optional = |field: &str| -> Option<&str> {
        form.get(field)
            .map(String::as_str)
            .filter(|value| !value.is_empty())
    };

    let session = required("session")?;
    let mut argv: Vec<String> = vec![
        "decide".into(),
        decision_type.into(),
        "--db".into(),
        db.display().to_string(),
        "--root".into(),
        root.display().to_string(),
        "--session-id".into(),
        session.into(),
    ];
    // Per-type flags. The validity of the VALUES (verdict/resolution/mode enum,
    // ref kind) is enforced by `decide` itself + the C7 check in `handle_decide`;
    // here we only assemble the typed argv and reject an unknown type / missing
    // required field.
    match decision_type {
        "gate" => {
            argv.push("--ref".into());
            argv.push(required("ref")?.into());
            argv.push("--verdict".into());
            argv.push(required("verdict")?.into());
            if let Some(note) = optional("note") {
                argv.push("--note".into());
                argv.push(note.into());
            }
        }
        "conflict" => {
            argv.push("--ref".into());
            argv.push(required("ref")?.into());
            argv.push("--resolution".into());
            argv.push(required("resolution")?.into());
            if let Some(note) = optional("note") {
                argv.push("--note".into());
                argv.push(note.into());
            }
        }
        "mode" => {
            argv.push("--to".into());
            argv.push(required("to")?.into());
        }
        "interrupt" => {
            if let Some(reason) = optional("reason") {
                argv.push("--reason".into());
                argv.push(reason.into());
            }
        }
        "redirect" => {
            argv.push("--to".into());
            argv.push(required("to")?.into());
            if let Some(reason) = optional("reason") {
                argv.push("--reason".into());
                argv.push(reason.into());
            }
        }
        other => {
            return Err(CliError::usage(format!("unknown decision type {other:?}")));
        }
    }
    // Optional notification (ADR 033 D4 / C4=b): the form carries a single
    // `notify` value as `agent:address` (a known-target select, NEVER free-typed —
    // `handle_decide` allow-lists it). `decide` wants `--notify-pane <address>` +
    // `--notify-to <agent:address>`; the address half is the part after the colon.
    if let Some(notify) = optional("notify") {
        let address = notify.split_once(':').map(|(_, a)| a).unwrap_or(notify);
        argv.push("--notify-pane".into());
        argv.push(address.into());
        argv.push("--notify-to".into());
        argv.push(notify.into());
        // Pin the served herdr binary for the notify send (parity with `send_argv`,
        // which always pins it). Without a notify target `decide`'s `--herdr-bin` is
        // inert, so this stays inside the notify branch and the non-notify decide
        // paths get no extra flag.
        argv.push("--herdr-bin".into());
        argv.push(herdr_bin.into());
    }
    Ok(argv)
}

/// ADR 033 M2b T4 / ADR 031 D2: build the typed `zynk decide <type>` argv from the
/// validated form, spawn `current_exe()` (argv array, NEVER a shell), capture
/// output. Mirrors `handle_send`: the route AUTHORIZES + VALIDATES, then shells out
/// to the audited `zynk decide` — it NEVER writes the DB directly (the decision
/// proof + typed row + C4=b notify all come from the M2a path). The served
/// `herdr_bin` is threaded through `decide_argv` so a C4=b notify send uses the
/// served binary (parity with `handle_send`/`send_argv`). C7 validation runs
/// against the served DB BEFORE the child spawns, and any validation DB connection
/// is opened+closed before `current_exe()` runs (no DB handle held across the
/// child write). On success: 303-PRG back to the session feed; on a nonzero child:
/// an Error carrying the child output, which the caller HTML-escapes (ADR 031 C1).
pub fn handle_decide(
    request: &HttpRequest,
    decision_type: &str,
    db: &Path,
    root: &Path,
    herdr_bin: &str,
) -> WriteOutcome {
    let form = parse_form(&request.body);

    // C7 validation against the served DB BEFORE spawning. The connection(s) are
    // opened+dropped inside this helper, so none is held while the child runs.
    if let Err(outcome) = validate_decide(db, decision_type, &form) {
        return outcome;
    }

    // Assemble the typed argv (rejects unknown type / missing required field).
    let argv = match decide_argv(db, root, herdr_bin, decision_type, &form) {
        Ok(argv) => argv,
        Err(error) => {
            return WriteOutcome::Error {
                status: "400 Bad Request",
                message: error.message,
            };
        }
    };
    let exe = match std::env::current_exe() {
        Ok(exe) => exe,
        Err(error) => {
            return WriteOutcome::Error {
                status: "500 Internal Server Error",
                message: format!("cannot resolve zynk binary: {error}"),
            };
        }
    };
    match Command::new(exe).args(&argv).output() {
        Ok(out) if out.status.success() => {
            // PRG back to the decided session's feed. `session` is required by
            // `decide_argv` (which already succeeded), so it is present here.
            let session = form.get("session").map(String::as_str).unwrap_or("");
            WriteOutcome::Redirect(format!("/?session={}", escape_url_component(session)))
        }
        Ok(out) => WriteOutcome::Error {
            status: "502 Bad Gateway",
            message: format!(
                "decide failed:\n{}\n{}",
                String::from_utf8_lossy(&out.stdout),
                String::from_utf8_lossy(&out.stderr)
            ),
        },
        Err(error) => WriteOutcome::Error {
            status: "500 Internal Server Error",
            message: format!("failed to run zynk: {error}"),
        },
    }
}

/// ADR 033 M2b T4 (C7): validate a decide POST against the served DB BEFORE the
/// child spawns. Checks: (a) the session exists; (b) for gate/conflict, the `--ref`
/// work_event belongs to the session AND has the required kind (gate→"gate",
/// conflict→"conflict"); (c) a `mode` decision's target is in the ADR 020 canonical
/// set; (d) a notify target (if the form carries one) is in the `/send` known-target
/// allow-list (no free-typed pane/address); (e) a `redirect` decision's `to` target
/// is in the SAME known-target allow-list (M2b R1 P1 — the redirect target is a
/// herdr target the work is handed to; a forged POST must not free-type it). Returns
/// the rejection outcome on failure. EVERY DB connection it opens is dropped before it returns, so the child
/// write never overlaps a held read handle (ADR 031 C7). `decide` re-validates the
/// ref kind + the enums itself; this is the fail-fast pre-spawn gate so a bad POST
/// never shells out.
fn validate_decide(
    db: &Path,
    decision_type: &str,
    form: &HashMap<String, String>,
) -> Result<(), WriteOutcome> {
    let session = match form.get("session") {
        Some(session) if !session.is_empty() => session.as_str(),
        _ => {
            return Err(WriteOutcome::Error {
                status: "400 Bad Request",
                message: "session is required".to_string(),
            });
        }
    };

    // (a) the session must exist in the served DB (no browser-originated session
    // creation), and (d) any notify target must be a known agent:address row. Both
    // need the read connection; open it, use it, drop it BEFORE the child spawns.
    {
        let connection =
            crate::db::open_read_database(db).map_err(|error| WriteOutcome::Error {
                status: "500 Internal Server Error",
                message: format!("cannot open dashboard db: {}", error.message),
            })?;
        match crate::db_dashboard::session_exists(&connection, session) {
            Ok(true) => {}
            Ok(false) => {
                return Err(WriteOutcome::Error {
                    status: "404 Not Found",
                    message: "unknown session — browser writes target an existing session"
                        .to_string(),
                });
            }
            Err(error) => {
                return Err(WriteOutcome::Error {
                    status: "500 Internal Server Error",
                    message: format!("session check failed: {}", error.message),
                });
            }
        }
        // (d) a notify target (if present) must be in the SENDABLE-target allow-list —
        // reuse the `/send` allow-list; NO free-typed pane/address. (e) a `redirect`
        // decision's `to` is ALSO a herdr target the work is handed to, so it must be
        // allow-listed by the SAME served-session sendable-target list — a CSRF/Host/
        // Origin-valid forged POST must not be able to free-type an arbitrary target
        // (M2b R1 P1). Both checks reuse one `sendable_targets` read (the STRICT
        // write-validation allow-list, NOT the broad ADR 032 display roster — v1 M3b
        // A2 / Codex C6; a non-sendable sentinel like `none:none`/`operator:cli` is a
        // display-only participant, never a real send/redirect/notify target),
        // computed lazily, only when there is a target to validate, inside this
        // pre-spawn connection scope, so no DB handle is held during `current_exe`.
        let notify_target = form.get("notify").filter(|value| !value.is_empty());
        let redirect_target = if decision_type == "redirect" {
            form.get("to").filter(|value| !value.is_empty())
        } else {
            None
        };
        if notify_target.is_some() || redirect_target.is_some() {
            let targets =
                crate::db_dashboard::sendable_targets(&connection, session).map_err(|error| {
                    WriteOutcome::Error {
                        status: "500 Internal Server Error",
                        message: format!("target check failed: {}", error.message),
                    }
                })?;
            if let Some(notify) = notify_target {
                if !targets.iter().any(|known| known == notify) {
                    return Err(WriteOutcome::Error {
                        status: "400 Bad Request",
                        message: "unknown notify target — choose a known agent:address row"
                            .to_string(),
                    });
                }
            }
            if let Some(to) = redirect_target {
                if !targets.iter().any(|known| known == to) {
                    return Err(WriteOutcome::Error {
                        status: "400 Bad Request",
                        message: "unknown redirect target — choose a known agent:address row"
                            .to_string(),
                    });
                }
            }
        }
    }

    // (b) gate/conflict ref-kind: the `--ref` work_event must belong to the session
    // AND be the expected kind. `work_event_kind` opens its own short-lived
    // connection (the served DB is committed), dropped before we return.
    let required_kind = match decision_type {
        "gate" => Some("gate"),
        "conflict" => Some("conflict"),
        _ => None,
    };
    if let Some(expected) = required_kind {
        let raw = match form.get("ref").filter(|value| !value.is_empty()) {
            Some(raw) => raw.as_str(),
            None => {
                return Err(WriteOutcome::Error {
                    status: "400 Bad Request",
                    message: "ref is required".to_string(),
                });
            }
        };
        let wid: i64 = match raw.parse() {
            Ok(wid) => wid,
            Err(_) => {
                return Err(WriteOutcome::Error {
                    status: "400 Bad Request",
                    message: format!("ref must be a work_event id (got {raw:?})"),
                });
            }
        };
        match crate::db::work_event_kind(db, session, wid) {
            Ok(Some(actual)) if actual == expected => {}
            Ok(Some(actual)) => {
                return Err(WriteOutcome::Error {
                    status: "400 Bad Request",
                    message: format!("ref {wid} is a {actual} work-event, not a {expected}"),
                });
            }
            Ok(None) => {
                return Err(WriteOutcome::Error {
                    status: "404 Not Found",
                    message: format!("ref {wid} not found in session {session}"),
                });
            }
            Err(error) => {
                return Err(WriteOutcome::Error {
                    status: "500 Internal Server Error",
                    message: format!("ref check failed: {}", error.message),
                });
            }
        }
    }

    // (c) mode-switch target must be in the ADR 020 canonical set.
    if decision_type == "mode" {
        let to = match form.get("to").filter(|value| !value.is_empty()) {
            Some(to) => to.as_str(),
            None => {
                return Err(WriteOutcome::Error {
                    status: "400 Bad Request",
                    message: "to is required".to_string(),
                });
            }
        };
        if !crate::decision::CANONICAL_MODES.contains(&to) {
            return Err(WriteOutcome::Error {
                status: "400 Bad Request",
                message: format!(
                    "mode must be one of {} (got {to:?})",
                    crate::decision::CANONICAL_MODES.join("/")
                ),
            });
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn req(method: &str, host: &str, origin: Option<&str>, token: Option<&str>) -> HttpRequest {
        let mut h = format!("Host: {host}");
        if let Some(o) = origin {
            h.push_str(&format!("\r\nOrigin: {o}"));
        }
        if let Some(t) = token {
            h.push_str(&format!("\r\nX-Zynk-CSRF: {t}"));
        }
        parse_request(&format!("{method} /send HTTP/1.1\r\n{h}"), Vec::new())
    }

    #[test]
    fn authorize_write_accepts_exact_host_origin_token_post() {
        let authority = "127.0.0.1:8787";
        let r = req(
            "POST",
            authority,
            Some("http://127.0.0.1:8787"),
            Some("secret"),
        );
        assert!(authorize_write(&r, authority, "secret").is_ok());
    }

    #[test]
    fn authorize_write_rejects_bad_token_origin_host_method() {
        let a = "127.0.0.1:8787";
        let ok_origin = Some("http://127.0.0.1:8787");
        assert!(authorize_write(&req("POST", a, ok_origin, Some("nope")), a, "secret").is_err());
        assert!(authorize_write(
            &req("POST", a, Some("http://evil.test"), Some("secret")),
            a,
            "secret"
        )
        .is_err());
        assert!(authorize_write(
            &req("POST", "evil.test", ok_origin, Some("secret")),
            a,
            "secret"
        )
        .is_err());
        assert!(authorize_write(&req("GET", a, ok_origin, Some("secret")), a, "secret").is_err());
        assert!(authorize_write(&req("POST", a, None, Some("secret")), a, "secret").is_err());
    }

    #[test]
    fn mint_csrf_token_is_long_and_varies() {
        let a = mint_csrf_token();
        let b = mint_csrf_token();
        assert!(
            a.len() >= 32 && a.chars().all(|c| c.is_ascii_alphanumeric()),
            "{a}"
        );
        assert_ne!(a, b);
    }

    #[test]
    fn parse_form_decodes_fields() {
        let f = parse_form(b"session=s1&to=codex%3Aw1-1&type=status-update&body=hello+world");
        assert_eq!(f.get("session").map(String::as_str), Some("s1"));
        assert_eq!(f.get("to").map(String::as_str), Some("codex:w1-1"));
        assert_eq!(f.get("body").map(String::as_str), Some("hello world"));
    }

    #[test]
    fn send_argv_is_typed_and_unflaggable() {
        let argv = send_argv(
            Path::new("/db"),
            Path::new("/root"),
            "herdr",
            "s1",
            "codex:w1-1",
            "status-update",
            "m9",
            "--oops --no-audit",
        );
        // A body that looks like a flag must be a single positional --body value.
        let body_idx = argv.iter().position(|a| a == "--body").unwrap();
        assert_eq!(argv[body_idx + 1], "--oops --no-audit");
        assert!(argv.contains(&"--db".to_string()) && argv.contains(&"/db".to_string()));
        let origin_idx = argv.iter().position(|a| a == "--command-origin").unwrap();
        assert_eq!(argv[origin_idx + 1], "operator");
        let mid_idx = argv.iter().position(|a| a == "--mid").unwrap();
        assert_eq!(argv[mid_idx + 1], "m9");
    }

    #[test]
    fn decide_argv_threads_herdr_bin_only_on_notify() {
        // No notify target: the typed gate argv carries the pinned db/root + verdict,
        // and — since `decide`'s `--herdr-bin` is inert without a notify — NO
        // `--herdr-bin` flag is added (the non-notify decide paths are unaffected).
        let mut form = HashMap::new();
        form.insert("session".to_string(), "s1".to_string());
        form.insert("ref".to_string(), "7".to_string());
        form.insert("verdict".to_string(), "approve".to_string());
        let argv = decide_argv(
            Path::new("/db"),
            Path::new("/root"),
            "/custom",
            "gate",
            &form,
        )
        .unwrap();
        assert!(
            !argv.contains(&"--herdr-bin".to_string()),
            "no notify target ⇒ no --herdr-bin flag: {argv:?}"
        );
        let ref_idx = argv.iter().position(|a| a == "--ref").unwrap();
        assert_eq!(argv[ref_idx + 1], "7");

        // With a notify target, the served `herdr_bin` is pinned on the notify leg
        // (parity with `send_argv`), right alongside the derived notify pane/to.
        form.insert("notify".to_string(), "codex:w1-1".to_string());
        let argv = decide_argv(
            Path::new("/db"),
            Path::new("/root"),
            "/custom",
            "gate",
            &form,
        )
        .unwrap();
        let bin_idx = argv
            .iter()
            .position(|a| a == "--herdr-bin")
            .expect("notify target ⇒ --herdr-bin pinned");
        assert_eq!(argv[bin_idx + 1], "/custom");
        let pane_idx = argv.iter().position(|a| a == "--notify-pane").unwrap();
        assert_eq!(argv[pane_idx + 1], "w1-1");
        let to_idx = argv.iter().position(|a| a == "--notify-to").unwrap();
        assert_eq!(argv[to_idx + 1], "codex:w1-1");
    }

    #[test]
    fn parse_request_splits_line_headers_body() {
        let head = "POST /send?session=s HTTP/1.1\r\nHost: 127.0.0.1:8787\r\nX-Zynk-CSRF: abc";
        let req = parse_request(head, b"a=1&b=2".to_vec());
        assert_eq!(req.method, "POST");
        assert_eq!(req.route, "/send");
        assert_eq!(req.query, "session=s");
        assert_eq!(req.header("host"), Some("127.0.0.1:8787"));
        assert_eq!(req.header("x-zynk-csrf"), Some("abc"));
        assert_eq!(req.body, b"a=1&b=2");
    }

    #[test]
    fn parse_request_counts_host_headers() {
        // R1 P5: the HashMap collapses duplicate Host headers (last wins), so the
        // exact-Host guard must additionally require host_count == 1. Preserve the
        // multiplicity here so the guard can fail closed on 0 or 2+ Host lines.
        let zero = parse_request("GET / HTTP/1.1\r\nX-Zynk-CSRF: abc", Vec::new());
        assert_eq!(zero.host_count, 0);
        let one = parse_request("GET / HTTP/1.1\r\nHost: 127.0.0.1:8787", Vec::new());
        assert_eq!(one.host_count, 1);
        // Two Host lines: the HashMap keeps only the last value, but host_count is 2.
        let two = parse_request(
            "GET / HTTP/1.1\r\nHost: evil.test\r\nHost: 127.0.0.1:8787",
            Vec::new(),
        );
        assert_eq!(two.host_count, 2);
        assert_eq!(two.header("host"), Some("127.0.0.1:8787"));
    }
}