ikigai-embedded 0.1.21

In-process transport: composes a kernel directly in the host process.
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
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
//! `ikigai-conformance` over the two kernels this workspace COMPOSES.
//!
//! ## This crate is a host, not a module, and that changes what a clean report means
//!
//! `ikigai-embedded` binds about thirty endpoints of its own and inherits a hundred more
//! from twenty-odd published module crates, each with its own repo and its own adoption of
//! this same suite. No inherited finding is this workspace's to FIX — they are walked,
//! attributed to the crate that owns them, and left; they drop on their own as each module's
//! release lands on crates.io. What IS this workspace's is [`OWN`], and the assertion is
//! that **no finding names an id in it**. [`the_walked_catalog_is_classified`] holds the two
//! tables to the actual catalog, so an endpoint added later fails one test or the other and
//! whoever added it has to classify it, type its inputs, and decide whether the walk may
//! fire it.
//!
//! Two kernels, because this crate composes two and they are different compositions:
//!
//! | kernel | what it is | who reaches it |
//! |---|---|---|
//! | [`ikigai_embedded::kernel`] | the REPL/TUI root: personal, Lisp, browse, the lot | the owner, in process |
//! | [`ikigai_embedded::kernel_for`] | the HTTP door (`ikigai serve --http`) | the public edge, under a `--cap` ceiling |
//!
//! The second is a SUBSET with a different posture, and the walk over it is the check that
//! the subset is what it is claimed to be: no personal space, no Lisp, no subprocess seam.
//! [`the_http_door_serves_no_owner_only_resource`] states that as a list rather than a
//! sentence.
//!
//! ## ★ What the walk is NOT allowed to do — decided BEFORE the first bare run
//!
//! A conformance walk fires every non-opted-out action under `Capability::root()`. On a
//! MODULE that is a read of the module's own data; on a composing host it is a remote-code-
//! execution surface, and nothing in the suite warns (conformance PENDING #139: the bare
//! first run over `ikigai-dev-server` really executed `git`, shelled out to `gh` over the
//! network, and POSTed to a live inference server). This workspace binds a subprocess seam,
//! six outbound HTTP verbs, an SMTP sender, a Zoom scheduler, the macOS EventKit calendar,
//! a Keychain with a Touch ID prompt, mDNS multicast, a job scheduler and a Steel evaluator.
//!
//! So the opt-out list in [`suite`] was written from the CATALOG, before this file ever
//! ran an invoking check — not from reading the first report. Every entry says which of the
//! five hazards it is: **spawns a process**, **reaches the network**, **sends a message**,
//! **touches the platform**, or **evaluates code**. The description-only checks (ARGSPECS,
//! NAMES, REQUIRES-VERB) still run over every one of them, and that is where nearly all of
//! the inherited findings are anyway.
//!
//! ## Hermetic, and the three things that leak if it is not
//!
//! `HOME` and `XDG_CONFIG_HOME` are redirected and [`ikigai_embedded::set_file_root`] is
//! called before any kernel is built, because:
//!
//! * the file module is jailed to `file_root()`, and the walk FIRES its Sink and Delete;
//! * `urn:orgfile:{path}` is jailed to whatever `calendar.json` names as `org_dir` — and
//!   with no config at all that is the empty path, which is the process's own working
//!   directory. A walk that fired a Sink there would write into the checkout;
//! * `browse.root` and the a11y layering read the config home, so a walk against the real
//!   one is about the machine it ran on rather than about the code.
//!
//! `set_file_root` is the typed channel this crate documents for exactly this: `cfg(test)`
//! does not reach a `tests/` binary, so the redirect has to be a call, not a compile flag.
//!
//! ## Fixtures
//!
//! Real Turtle for the `urn:rdf:*` trio, real queries for the four `sparql-*` reads, a real
//! JSON-LD document AND a context for the three `jsonld-*` operators, a seeded stylesheet and
//! document (named by IRI) for `xslt-transform`, urlencoded submissions for the two public
//! intake Sinks, and `path` / `name` / `token` / `action` bindings for the template-bound
//! entries. Without them those endpoints report "did not resolve with the minimal inputs",
//! which reads as a caching finding (conformance PENDING #33) and means the walk failed to
//! CALL rather than that the endpoint failed to conform.
//!
//! ⚠ **A fixture is a claim, and 0.2.0 is the first version that audits it.** Under the 0.1.0
//! pin this file carried six fixtures that bought nothing — three whose ids the walk never
//! reached on the kernel being walked, and three (`xslt-transform`, `jsonld-compact`, and the
//! `contact`/`booking` Sinks) that named inputs the endpoint could not use, so the endpoint
//! was never probed while the report printed a `fixture:` line for it. Its DECLARATIONS check
//! is what said so; see [`base`] for the structural half of the same lesson.

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;

use ikigai_conformance::{rdf, Check, Fixture, Report, Suite};
use ikigai_core::{Kernel, Verb};

// ---------------------------------------------------------------------------
// Who owns what.
// ---------------------------------------------------------------------------

/// Every description id bound by a crate in THIS workspace, with that crate.
///
/// The list is the claim the `conforms` test makes: these are ours, so a finding against one
/// of them is a defect here and turns the test red. It is also a linkage inventory — a
/// member crate that grows an endpoint puts it on the REPL, the HTTP door or both, and
/// nothing else would say so.
const OWN: &[(&str, &str)] = &[
    // ikigai-embedded's own endpoints.
    ("about", "ikigai-embedded"),
    ("agent-select", "ikigai-embedded"),
    ("alias-demo", "ikigai-embedded"),
    ("calendar-request", "ikigai-embedded"),
    ("catalog-cards-xsl", "ikigai-embedded"),
    ("client", "ikigai-embedded"),
    ("client-issue", "ikigai-embedded"),
    ("clock-now", "ikigai-embedded"),
    ("contact-block", "ikigai-embedded"),
    ("contactblock-link", "ikigai-embedded"),
    ("control", "ikigai-embedded"),
    ("decide-accept", "ikigai-embedded"),
    ("decide-link", "ikigai-embedded"),
    ("decisions", "ikigai-embedded"),
    ("foaf", "ikigai-embedded"),
    ("greeter", "ikigai-embedded"),
    ("host-demo", "ikigai-embedded"),
    ("host-heartbeat", "ikigai-embedded"),
    ("host-history", "ikigai-embedded"),
    ("host-identity", "ikigai-embedded"),
    ("host-info", "ikigai-embedded"),
    ("kernel-health", "ikigai-embedded"),
    ("lisp-aliases", "ikigai-embedded"),
    ("page", "ikigai-embedded"),
    ("passkey-challenge", "ikigai-embedded"),
    ("passkey-enroll-open", "ikigai-embedded"),
    ("passkey-js", "ikigai-embedded"),
    ("passkey-register", "ikigai-embedded"),
    ("peer-list", "ikigai-embedded"),
    ("people", "ikigai-embedded"),
    // Sibling members of this workspace.
    ("booking", "ikigai-intake"),
    ("contact", "ikigai-intake"),
    ("send", "ikigai-email"),
    ("space", "ikigai-intray"),
    ("time-cancel", "ikigai-time"),
    ("time-jobs", "ikigai-time"),
    ("time-schedule", "ikigai-time"),
    ("tz-convert", "ikigai-tz"),
    ("tz-now", "ikigai-tz"),
    ("view-derive", "ikigai-view"),
    ("view-derive-tick", "ikigai-view"),
    ("view-ingest", "ikigai-view"),
];

/// Every description id that arrives from a PUBLISHED module crate, with that crate.
///
/// Findings against these are recorded and printed, never fixed here — the fix belongs in
/// the module's own repo, where its own adoption of this suite will make it (most already
/// have; those releases are simply not on crates.io yet). Listed rather than matched by
/// prefix so that a module quietly gaining an endpoint is visible in a diff.
const INHERITED: &[(&str, &str)] = &[
    ("bookmarks", "ikigai-cms"),
    ("compose", "ikigai-fn"),
    ("conditional", "ikigai-fn"),
    ("echo", "ikigai-fn"),
    ("greet", "ikigai-fn"),
    ("reverseList", "ikigai-fn"),
    ("split", "ikigai-fn"),
    ("toUpper", "ikigai-fn"),
    ("wrap", "ikigai-fn"),
    ("availability", "ikigai-personal"),
    ("calendar", "ikigai-personal"),
    ("calendar-config", "ikigai-personal"),
    ("calendars", "ikigai-personal"),
    ("contacts", "ikigai-personal"),
    ("eval", "ikigai-lisp"),
    ("file", "ikigai-fs"),
    ("httpDelete", "ikigai-http"),
    ("httpGet", "ikigai-http"),
    ("httpHead", "ikigai-http"),
    ("httpPatch", "ikigai-http"),
    ("httpPost", "ikigai-http"),
    ("httpPut", "ikigai-http"),
    ("ikigai-vocab", "ikigai-vocab"),
    ("jsonld-compact", "ikigai-jsonld"),
    ("jsonld-expand", "ikigai-jsonld"),
    ("jsonld-flatten", "ikigai-jsonld"),
    ("llm-ask", "ikigai-llm"),
    ("llm-config", "ikigai-llm"),
    ("llm-models", "ikigai-llm"),
    ("llm-ollama-ask", "ikigai-llm"),
    ("llm-ollama-installed", "ikigai-llm"),
    ("llm-ollama-model", "ikigai-llm"),
    ("llm-ollama-up", "ikigai-llm"),
    ("llm-select", "ikigai-llm"),
    ("org-agenda", "ikigai-org"),
    ("rdf-diff", "ikigai-rdf"),
    ("rdf-from-sexpr", "ikigai-sexpr"),
    ("rdf-transrept", "ikigai-rdf"),
    ("rdf-union", "ikigai-rdf"),
    ("repo-branch", "ikigai-repo"),
    ("repo-list", "ikigai-repo"),
    ("repo-log", "ikigai-repo"),
    ("repo-pr-checks", "ikigai-repo"),
    ("repo-pr-diff", "ikigai-repo"),
    ("repo-pr-files", "ikigai-repo"),
    ("repo-pr-list", "ikigai-repo"),
    ("repo-pr-view", "ikigai-repo"),
    ("repo-status", "ikigai-repo"),
    ("system-exec", "ikigai-repo"),
    ("sexpr-from-rdf", "ikigai-sexpr"),
    ("sexpr-to-rdf", "ikigai-sexpr"),
    ("shacl-validate", "ikigai-shacl"),
    ("sign", "ikigai-sign"),
    ("verify", "ikigai-sign"),
    ("encrypt", "ikigai-encrypt"),
    ("decrypt", "ikigai-encrypt"),
    ("sniff", "ikigai-sniff"),
    ("transrept-auto", "ikigai-sniff"),
    ("sparql-ask", "ikigai-sparql"),
    ("sparql-construct", "ikigai-sparql"),
    ("sparql-describe", "ikigai-sparql"),
    ("sparql-from-sexpr", "ikigai-sexpr"),
    ("sparql-select", "ikigai-sparql"),
    ("urn:meeting:zoom:schedule", "ikigai-meeting"),
    ("urn:secret", "ikigai-secret"),
    ("secret-generate", "ikigai-secret"),
    ("secret-unlock", "ikigai-secret"),
    ("grep", "ikigai-text"),
    ("head", "ikigai-text"),
    ("nl", "ikigai-text"),
    ("rev", "ikigai-text"),
    ("sort", "ikigai-text"),
    ("tail", "ikigai-text"),
    ("uniq", "ikigai-text"),
    ("wc", "ikigai-text"),
    ("xslt-transform", "ikigai-xslt"),
];

/// Resources the REPL's root kernel binds that the public HTTP door must NOT.
///
/// The door resolves under an operator `--cap` ceiling and (in the edge posture)
/// `--routes-only`, so authority and surface are gated twice above this list. It is still
/// worth pinning as a catalog fact: a capability ceiling is configuration, and a binding is
/// code. Nothing here should ever be reachable on the public face even at full authority.
const OWNER_ONLY: &[&str] = &[
    "contacts",
    "calendar",
    "availability",
    "calendars",
    "calendar-config",
    "eval",
    "system-exec",
    "repo-status",
    "urn:secret",
    "secret-generate",
    "secret-unlock",
    "send",
    "urn:meeting:zoom:schedule",
    "client-issue",
    "peer-list",
];

// ---------------------------------------------------------------------------
// The hermetic fixture home.
// ---------------------------------------------------------------------------

/// Redirect every ambient path this crate reads, once for the whole test binary.
///
/// Returns the scratch root. Called by every test before it builds a kernel; `OnceLock`
/// makes the writes happen exactly once however many threads arrive.
fn fixture_home() -> &'static Path {
    static HOME: OnceLock<PathBuf> = OnceLock::new();
    HOME.get_or_init(|| {
        let dir = std::env::temp_dir().join(format!(
            "ikigai-embedded-conformance-{}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(dir.join("config/ikigai")).expect("config home");
        std::fs::create_dir_all(dir.join("workspace")).expect("workspace");
        std::fs::create_dir_all(dir.join("org")).expect("org dir");
        std::fs::write(
            dir.join("org/agenda.org"),
            "* TODO a fixture task\n  SCHEDULED: <2026-01-01 Thu>\n",
        )
        .expect("org file");
        // `urn:orgfile:{path}` is jailed to this directory. ⚠ With no `calendar.json` at all
        // the jail root is the EMPTY path — the process's working directory — and the walk
        // fires the file module's Sink. Writing this config is what keeps a conformance run
        // out of the checkout.
        //
        // ⚠ `view` is REQUIRED and was missing until 2026-09-12. ONE file, TWO independent
        // readers: `org_config` parses it with raw serde_json (so the org jail was always
        // set correctly), while `calendar_config` parses it as `CalendarConfig` — which
        // has no `serde(default)` for `view` and so rejected the whole document, printing
        // eight "parse error … — ignoring" lines per run. Nothing failed, and that is the
        // problem: a fixture that half-parses is a fixture whose reach nobody measures.
        std::fs::write(
            dir.join("config/ikigai/calendar.json"),
            format!(
                r#"{{"view":"Conformance-View","org_dir":"{}","org_files":["agenda.org"]}}"#,
                dir.join("org").display()
            ),
        )
        .expect("calendar.json");
        std::env::set_var("HOME", &dir);
        std::env::set_var("XDG_CONFIG_HOME", dir.join("config"));
        ikigai_embedded::set_file_root(dir.join("workspace"));
        seed_workspace(&dir.join("workspace"));
        dir
    })
    .as_path()
}

/// Seed the workspace state the edge's decision faces read, so the walk PROBES them.
///
/// Without this, four endpoints report "did not resolve with the minimal inputs" — which
/// reads as a caching finding (conformance PENDING #33) and means the walk failed to CALL
/// rather than that the endpoint failed to conform. An empty fixture is the most likely
/// place for a composing host to mistake "nothing was checked" for "clean" (PENDING #142).
///
/// Two keypairs and one client record:
///
/// * `decide.pub` / `contact-block.pub` — SPKI PEM verifying halves. `urn:calendar-request`
///   and `urn:contact-block` read these BEFORE looking at a token, so with no file at all
///   they fail at the first line of `invoke` and nothing downstream is ever reached.
/// * `contact-block.key` — the PKCS8 PEM signing half, so `urn:contactblock:link` can mint.
/// * `clients/conformance.json` — so `urn:client:{token}` resolves for the bound fixture.
/// * `conformance.txt` / `conformance.xsl` — the two documents the `file` and
///   `xslt-transform` fixtures name. `urn:xslt:transform`'s `stylesheet` is a resolvable
///   resource IRI, not inline markup: passing the stylesheet BY VALUE failed at
///   `Iri::parse` with "invalid IRI code point '<'", so that endpoint was never probed at
///   all while its fixture sat in the report looking like coverage.
///
/// A FIXED key (`[9u8; 32]`), not a generated one: the walk only needs the file to parse,
/// and a deterministic fixture is one less thing that differs between runs.
fn seed_workspace(root: &Path) {
    use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
    use ed25519_dalek::pkcs8::{EncodePrivateKey, EncodePublicKey};
    use ed25519_dalek::SigningKey;

    let key = SigningKey::from_bytes(&[9u8; 32]);
    let public = key
        .verifying_key()
        .to_public_key_pem(LineEnding::LF)
        .expect("SPKI PEM");
    for name in ["decide.pub", "contact-block.pub"] {
        std::fs::write(root.join(name), &public).expect("verifying key");
    }
    std::fs::write(
        root.join("contact-block.key"),
        key.to_pkcs8_pem(LineEnding::LF)
            .expect("PKCS8 PEM")
            .as_str(),
    )
    .expect("signing key");
    std::fs::create_dir_all(root.join("clients")).expect("clients dir");
    std::fs::write(
        root.join("clients/conformance.json"),
        r#"{"id":"conformance","name":"Conformance Fixture"}"#,
    )
    .expect("client record");
    std::fs::write(root.join("conformance.txt"), "a fixture document\n").expect("file fixture");
    std::fs::write(root.join("conformance.xsl"), XSL).expect("stylesheet fixture");
    std::fs::write(root.join("conformance.xml"), "<doc/>").expect("xml fixture");
}

fn root_kernel() -> Kernel {
    fixture_home();
    ikigai_embedded::kernel()
}

fn http_kernel() -> Kernel {
    fixture_home();
    ikigai_embedded::kernel_for("conformance")
}

// ---------------------------------------------------------------------------
// The suite.
// ---------------------------------------------------------------------------

/// A graph small enough to read and real enough to parse — the `content` every `urn:rdf:*`
/// fixture hands over.
///
/// ⚠ `http://`, not the `urn:` everything here is named with: `ikigai-rdf`'s sniff tells an
/// IRI from an XML element tag by looking for `://` in the first `<…>` token, so a document
/// whose first subject is `<urn:demo:a>` is sniffed as RDF/XML and fails to parse. Recorded
/// for ikigai-rdf rather than worked around silently (the same note ikigai-dev-server #10
/// made).
const TURTLE: &str = "<http://example.org/a> <http://purl.org/dc/terms/title> \"demo\" .\n";

/// A JSON-LD document with an actual node, not `{}`.
///
/// `{}` expands to zero triples, which makes every RDF check on the `jsonld-*` operators
/// pass without seeing anything — a report that reads as coverage and is not (conformance
/// PENDING #26, which the suite's own README example has).
const JSON_LD: &str = r#"{"@context":{"title":"http://purl.org/dc/terms/title"},"@id":"http://example.org/a","title":"demo"}"#;

/// The context `urn:jsonld:compact` compacts against — the same term [`JSON_LD`] carries, so
/// the round trip has something to shorten. A bare context value; the module also accepts a
/// `{"@context": …}` document.
const JSON_LD_CONTEXT: &str = r#"{"title":"http://purl.org/dc/terms/title"}"#;

/// An identity stylesheet — enough for `xslt-transform` to have a real transform to run.
const XSL: &str = r#"<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"><xsl:output method="xml"/><xsl:template match="/"><ok/></xsl:template></xsl:stylesheet>"#;

/// ★ **A `Suite` is a statement about the MANIFOLD it walks — so there are three of them.**
///
/// This crate composes two kernels, and they are not the same catalog: sixty-six ids are on
/// the REPL root only (the whole personal/Lisp/repo/LLM surface), six are on the HTTP door
/// only (`foaf`, the passkey trio, `calendar-request`, `contact-block`), and forty are on
/// both. One shared builder therefore carried, on every run, a pile of declarations that
/// could not apply to the kernel being walked — a `sparql-select` fixture over a door with no
/// SPARQL, a `foaf` opt-out over a root that does not bind it.
///
/// Conformance 0.1.0 printed those as `opted out:` and `fixture:` lines, indistinguishable
/// from the ones that did something: a report that reads as coverage and is not. 0.2.0's
/// DECLARATIONS check reports them, and the fix is to split the builder rather than to change
/// any id — [`base`] is what is true of both manifolds, [`root_suite`] and [`door_suite`] add
/// what is true of one. (Same shape and same conclusion as ikigai-module #17.)
///
/// The split is also a claim a diff can see: moving an endpoint between the two compositions
/// now has to move its declarations too, and `the_walked_catalog_is_classified` fails first if
/// it does not.
fn base() -> Suite {
    Suite::new()
        // ---- fired, with inputs that work ----------------------------------------
        .fixture(
            Fixture::new("rdf-union", Verb::Source)
                .arg("content", TURTLE)
                .arg("with", TURTLE),
        )
        .fixture(
            Fixture::new("rdf-diff", Verb::Source)
                .arg("content", TURTLE)
                .arg("with", TURTLE)
                .arg("mode", "added"),
        )
        .fixture(
            Fixture::new("rdf-transrept", Verb::Source)
                .arg("content", TURTLE)
                .arg("as", "application/n-triples"),
        )
        .fixture(Fixture::new("jsonld-expand", Verb::Source).arg("content", JSON_LD))
        .fixture(Fixture::new("jsonld-flatten", Verb::Source).arg("content", JSON_LD))
        // ⚠ `context` is REQUIRED and was missing until 2026-09-12, so the walk supplied its
        // own sample `x` and the endpoint failed at `bad context IRI` — `urn:jsonld:compact`
        // has never actually been probed here. Inline JSON is accepted (the module tells an
        // inline context from a resource reference by its first character).
        .fixture(
            Fixture::new("jsonld-compact", Verb::Source)
                .arg("content", JSON_LD)
                .arg("context", JSON_LD_CONTEXT),
        )
        // ⚠ BY IRI, not by value — BOTH of them. `src` and `stylesheet` are each "a
        // resolvable resource IRI", so the inline markup this used to pass for `stylesheet`
        // failed at `Iri::parse` ("invalid IRI code point '<'") and the walk's own sample
        // `x` for the undeclared-by-the-fixture `src` failed the same way. `urn:xslt:transform`
        // has therefore never been probed here at all — a fixture in the report that bought
        // nothing. Both documents are seeded into the hermetic workspace by `seed_workspace`.
        //
        // ⚠ What firing it revealed, for ikigai-xslt rather than for here: under NO grants it
        // now refuses with `Denied` because reading a `urn:file:` stylesheet needs
        // `urn:cap:fs:read:*`, which the endpoint does not declare. That floor is inherited
        // from whatever the `src`/`stylesheet` IRIs resolve THROUGH, so it is parameterized —
        // the wildcard form the recipe describes. Reported up, not worked around.
        .fixture(
            Fixture::new("xslt-transform", Verb::Source)
                .arg("src", "urn:file:conformance.xml")
                .arg("stylesheet", "urn:file:conformance.xsl")
                // `as` was declared REQUIRED through ikigai-xslt 0.1.1, so the walk supplied
                // it whatever we did — and its sample `x` came back as the served media type
                // verbatim, unvalidated (one for ikigai-xslt, reported up). 0.1.2 makes `as`
                // optional and takes the media type from the stylesheet's `xsl:output` when it
                // is absent; the fixture keeps supplying a real one, because the point of the
                // arg here is to probe the negotiated face rather than the default.
                .arg("as", "text/html"),
        )
        // Bindings are per ENTRY and a binding-only fixture's verb is ignored
        // (conformance PENDING #2): one binding per template variable is all the walk reads.
        .fixture(Fixture::new("file", Verb::Source).binding("path", "conformance.txt"))
        .fixture(Fixture::new("space", Verb::Source).binding("name", "conformance"))
        .fixture(Fixture::new("client", Verb::Source).binding("token", "conformance"))
        // ★ The two public intake Sinks, FIRED — and the reason a fixture is right here
        // rather than a waiver. `content` is a urlencoded submission, so the walk's sample
        // value (`x`) can never satisfy the declared fields and OUTPUTS reported only "the
        // minimal resolution failed". These are hermetic: the handler consults `urn:decisions`
        // and `urn:client:*` (local files under the fixture root) and drops a tuple into a
        // local space. Nothing is mailed and nothing leaves the machine — the reactive handler
        // that would act on the tuple is a WATCHER, and no watcher runs in this binary.
        .fixture(Fixture::new("contact", Verb::Sink).arg(
            "content",
            "name=Conformance&email=someone%40example.org&message=a+fixture+enquiry",
        ))
        .fixture(Fixture::new("booking", Verb::Sink).arg(
            "content",
            "name=Conformance&email=someone%40example.org&period=week&zone=UTC",
        ))
        // Real IANA zones: the suite's `x` is refused by the tzdata lookup, which reads as a
        // caching finding on an endpoint whose caching is fine (PENDING #33).
        .fixture(
            Fixture::new("tz-convert", Verb::Source)
                .arg("in", "2026-01-01T12:00:00Z")
                .arg("from", "UTC")
                .arg("to", "America/New_York"),
        )
        // ---- declared -------------------------------------------------------------
        // Constant documents compiled into the binary: `urn:data:page` / `:control` /
        // `:about` and the catalog stylesheet are `include_str!`-shaped resources with no
        // state behind them, so an empty thread set is correct rather than a cache nothing
        // can cut. (A document that ever starts reading a file must lose this declaration
        // and grow a `depends_on` — which is what makes the declaration worth making.)
        .pure("page")
        .pure("control")
        .pure("about")
        .pure("catalog-cards-xsl")
        // A genuinely pure function: an instant and two IANA zone names in, the same instant
        // in the target zone out. It reads the bundled tzdata, which is compiled in and
        // therefore not a resource anything could cut — its own module docs say "Pure and
        // cacheable". (`urn:tz:now` below is the sibling that is NOT this, and the two
        // declarations sitting next to each other is the point.)
        .pure("tz-convert")
        // ⚠ A STATED DEVIATION, not a claim of purity. `urn:time:now` and `urn:tz:now` read
        // the kernel's CLOCK, so by the wave's rule 3 they are not pure functions of their
        // inputs. They are `cacheable_until(next minute)` — `Expiry::At` — and 0.2.0 still has
        // no declaration for that: `pure` is the only spelling that says "an empty thread set
        // is correct here", and it is correct here for a reason the suite cannot express,
        // namely that a clock is not a resource and no `depends_on` could name it. The expiry
        // itself is pinned by hand in `a_clock_derived_result_expires_rather_than_threading`
        // below, so the declaration covers nothing that test does not check. Reported as
        // conformance PENDING #86/#123 (`Suite::cacheable_until`), which ikigai-web-demo #57
        // also wanted.
        .pure("clock-now")
        .pure("tz-now")
        // ---- NOT fired, and which hazard each one is -----------------------------
        //
        // ★ Written from the catalog BEFORE the first invoking run. See the module docs.
        //
        // Reaches the network. All six HTTP verbs are backed by a REAL transport here
        // (`ureq`), so the walk would issue live requests to whatever the sample `url`
        // happens to be. Bound on BOTH compositions, so they live in the base.
        .opt_out("httpGet", None, "outbound HTTP over a real transport")
        .opt_out("httpHead", None, "outbound HTTP over a real transport")
        .opt_out("httpPost", None, "outbound HTTP over a real transport")
        .opt_out("httpPut", None, "outbound HTTP over a real transport")
        .opt_out("httpPatch", None, "outbound HTTP over a real transport")
        .opt_out("httpDelete", None, "outbound HTTP over a real transport")
}

/// [`base`] plus everything bound only on the REPL/TUI root — which is where every one of
/// this host's five hazards actually lives.
fn root_suite() -> Suite {
    base()
        // ---- fired, with inputs that work ----------------------------------------
        .fixture(Fixture::new("sniff", Verb::Source).arg("content", TURTLE))
        .fixture(Fixture::new("transrept-auto", Verb::Source).arg("content", TURTLE))
        .fixture(
            Fixture::new("sparql-select", Verb::Source)
                .arg("query", "SELECT * WHERE { ?s ?p ?o } LIMIT 1"),
        )
        .fixture(Fixture::new("sparql-ask", Verb::Source).arg("query", "ASK { ?s ?p ?o }"))
        .fixture(
            Fixture::new("sparql-describe", Verb::Source).arg("query", "DESCRIBE <urn:demo:a>"),
        )
        .fixture(
            Fixture::new("sparql-construct", Verb::Source)
                .arg("query", "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o } LIMIT 1"),
        )
        // An address, because the endpoint refuses anything that is not one before it mints.
        .fixture(
            Fixture::new("contactblock-link", Verb::Source).arg("email", "someone@example.org"),
        )
        // ---- waived, per check, with the exception pinned by hand -----------------
        // ★ A REAL VOCABULARY GAP, in a repo that cannot fix it. Declaring `urn:host:health`'s
        // `text/turtle` face (it was served and unannounced) brought it under VOCABULARY,
        // which found four `ik:` terms `ikigai-vocab` does not define — not at the pinned
        // version and not at HEAD. The fix is `vocabulary.ttl` in ikigai-core plus a manual
        // deploy of https://ikigai-rs.dev/ns; both belong to that repo, so this waits.
        //
        // ⚠ `opt_out_check`, never `opt_out`: the coarse lever would take ENFORCED,
        // CACHEABLE and SKOLEM-RDF down with it on a capability-gated endpoint. And the
        // exception is pinned EXACTLY in `the_health_graph_uses_exactly_four_undefined_terms`
        // below, so this goes red in both directions — including the day the terms land.
        .opt_out_check(
            "kernel-health",
            Check::Vocabulary,
            "ik:Health / ik:verdict / ik:uptimeSeconds / ik:staleJobs are undefined in              ikigai-vocab (at the pin AND at HEAD); the fix is vocabulary.ttl in ikigai-core              plus a /ns deploy. The exact undefined set is pinned by hand below.",
        )
        // ---- NOT fired, and which hazard each one is -----------------------------
        //
        // Spawns a process.
        .opt_out("system-exec", None, "spawns a subprocess")
        .opt_out("eval", None, "evaluates arbitrary Steel code")
        // Runs git/gh against the invoking working tree, which this test does not own — and
        // CI checkouts are shallow, so what these read differs between machines.
        .opt_out("repo-status", None, "runs git in the invoking working tree")
        .opt_out("repo-log", None, "runs git in the invoking working tree")
        .opt_out("repo-branch", None, "runs git in the invoking working tree")
        .opt_out("repo-list", None, "enumerates repositories on the machine")
        .opt_out(
            "repo-pr-checks",
            None,
            "shells out to `gh`: network and auth",
        )
        .opt_out("repo-pr-view", None, "shells out to `gh`: network and auth")
        .opt_out("repo-pr-list", None, "shells out to `gh`: network and auth")
        .opt_out("repo-pr-diff", None, "shells out to `gh`: network and auth")
        .opt_out("repo-pr-files", None, "shells out to `gh`: network and auth")
        // Reaches the network.
        .opt_out("llm-ask", None, "POSTs to a live inference server")
        .opt_out("llm-ollama-ask", None, "POSTs to a live inference server")
        .opt_out("llm-ollama-up", None, "probes a live inference server")
        .opt_out(
            "llm-ollama-installed",
            None,
            "queries a live inference server",
        )
        .opt_out("llm-ollama-model", None, "queries a live inference server")
        .opt_out("llm-models", None, "may discover over the network")
        .opt_out("llm-select", None, "may discover over the network")
        .opt_out("peer-list", None, "mDNS multicast on the local network")
        // Sends a message to a person or a third party. These are not reversible by
        // deleting a file afterwards, which is what makes them a different class from the
        // hermetic Sinks the walk is welcome to fire.
        .opt_out("send", None, "submits real mail over SMTP")
        .opt_out(
            "urn:meeting:zoom:schedule",
            None,
            "creates a real Zoom meeting through the provider API",
        )
        .opt_out(
            "decide-accept",
            None,
            "runs booking confirmation: schedules and emails downstream",
        )
        .opt_out(
            "client-issue",
            None,
            "mints a durable client credential, and `send=` emails it",
        )
        // Touches the platform. EventKit is macOS-gated and TCC-prompted (an agent shell is
        // silently denied), and the calendar Sink and Delete write the USER'S real calendar.
        .opt_out(
            "contacts",
            None,
            "reads the macOS contact store (EventKit/TCC)",
        )
        .opt_out(
            "calendar",
            None,
            "reads and WRITES the macOS calendar (EventKit/TCC)",
        )
        .opt_out(
            "calendars",
            None,
            "reads and writes the macOS calendar set (EventKit/TCC)",
        )
        .opt_out(
            "calendar-config",
            None,
            "reads the macOS calendar set (EventKit/TCC)",
        )
        .opt_out(
            "availability",
            None,
            "reads the macOS calendar (EventKit/TCC)",
        )
        .opt_out(
            "view-derive",
            None,
            "writes the derived calendar through EventKit",
        )
        .opt_out(
            "view-derive-tick",
            None,
            "writes the derived calendar through EventKit",
        )
        .opt_out(
            "view-ingest",
            None,
            "writes the derived calendar through EventKit",
        )
        .opt_out(
            "urn:secret",
            None,
            "macOS Keychain, behind a Touch ID prompt",
        )
        // ★ **A hazard waiver keyed on an endpoint ID does not survive the module splitting
        // that endpoint** — and the failure is silent in the direction that matters.
        //
        // Through ikigai-secret 0.1.5, `unlock` and `type=`/`into=` rode on the
        // `urn:secret:{name}` template, so the one waiver above covered them. 0.1.6 binds
        // `urn:secret:unlock` and `urn:secret:generate` as their OWN endpoints ahead of the
        // template — a strictly better contract, and the right change in that repo — and the
        // waiver stopped reaching them without anything saying so. The 0.1.6 walk therefore
        // FIRED both against the real Keychain: `secret-unlock` was probed and served eight
        // bytes, and `secret-generate` reached `keychain write` and came back "The
        // authorization was canceled by the user" — i.e. it got as far as a Touch ID prompt on
        // a developer's machine, in a test.
        //
        // Nothing in the suite could have warned: an id that is not in any table is a
        // classification failure, but an id that IS classified and simply not opted out is
        // indistinguishable from one the walk is welcome to fire. The general lesson for this
        // file, recorded because it will happen again on the next module that splits a
        // template: **re-read the hazard list whenever a module pin moves, not only when this
        // host binds something new.**
        .opt_out(
            "secret-unlock",
            None,
            "macOS Keychain, behind a Touch ID prompt",
        )
        .opt_out(
            "secret-generate",
            None,
            "mints a real key into the macOS Keychain, behind a Touch ID prompt",
        )
        // Mutates process-global state the rest of the binary shares: the job registry is a
        // static, so a walk that schedules or cancels is not confined to its own kernel.
        .opt_out(
            "time-schedule",
            None,
            "registers a real job on the process-global registry",
        )
        .opt_out(
            "time-cancel",
            None,
            "cancels real jobs on the process-global registry",
        )
        // Ours, and it reaches the platform through a sub-resolution the manifold cannot
        // show: minting a link reads `urn:secret:booking-decide`, which on this machine is
        // the macOS Keychain behind a Touch ID prompt. The same reason `urn:secret` is out.
        .opt_out(
            "decide-link",
            None,
            "reads the signing key through urn:secret:*: Keychain, behind a Touch ID prompt",
        )
}

/// [`base`] plus the six resources bound only on the public HTTP door.
///
/// A short list, and that is the door's whole point: it is the SUBSET, and
/// `the_http_door_serves_no_owner_only_resource` states the other half as a red line.
fn door_suite() -> Suite {
    base()
        // A binding-only fixture: `urn:calendar-request:{action}` cannot be formed without it.
        .fixture(Fixture::new("calendar-request", Verb::Source).binding("action", "decline"))
        // Ours, and it fetches: every face of `urn:iki:foaf` starts with a `urn:httpGet` of
        // `src=`. Its capability floor is pinned by hand instead, in
        // `foaf::tests::without_a_net_grant_the_door_refuses` — which is the check `opt_out`
        // drops (conformance PENDING #21) and the one that matters for a network-backed
        // action.
        .opt_out("foaf", None, "issues urn:httpGet: reaches the network")
        // ★ A FALSE POSITIVE with a real observation inside it, and the reason it is an
        // opt-out rather than a fix.
        //
        // ENFORCED reports "declares no capability but refused with `Denied` under no
        // grants". True, and the refusal is `no enrollment window is open` — a STATE gate,
        // not an authority gate. There is no capability to declare: the window is opened by
        // a different, cap-gated resource (`urn:passkey:enroll-open`), and registration
        // during the window is deliberately open to an anonymous browser. Nor can the type
        // change: `Denied` is what the HTTP face maps to 403, and a closed enrollment window
        // answering 500 would be worse than a wrong label.
        //
        // What the finding is really pointing at is real and has no spelling: a description
        // has no way to say "this action is offered only while the host is in state S", so
        // the manifold over-offers on an axis that is not the capability axis. Reported for
        // the hub rather than worked around in the endpoint.
        //
        // ⚠ Per VERB, and per this manifold: `urn:passkey:register` is bound on the door
        // alone, so the same line on the root suite would be a DECLARATIONS finding.
        .opt_out(
            "passkey-register",
            Some(Verb::Sink),
            "refuses with a typed Denied on a STATE gate (no enrollment window), which \
             ENFORCED reads as an undeclared capability; there is no capability to declare",
        )
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Every non-kernel pattern a kernel binds, mapped to the id it describes itself under.
fn walked(kernel: &Kernel) -> BTreeMap<String, String> {
    kernel
        .entries()
        .expect("an enumerable root")
        .iter()
        .filter(|e| !e.pattern.starts_with("urn:kernel:"))
        .map(|e| {
            let id = kernel
                .describe_pattern(&e.pattern)
                .unwrap_or_else(|| panic!("`{}` describes itself", e.pattern))
                .id;
            (e.pattern.clone(), id)
        })
        .collect()
}

fn owners() -> BTreeMap<&'static str, &'static str> {
    OWN.iter().chain(INHERITED).copied().collect()
}

/// The findings, tallied by the crate that owns the endpoint each names.
fn by_owner(report: &Report) -> BTreeMap<&'static str, usize> {
    let owners = owners();
    let mut tally: BTreeMap<&'static str, usize> = BTreeMap::new();
    for finding in &report.findings {
        if let Some(owner) = owners.get(finding.endpoint.as_str()) {
            *tally.entry(*owner).or_default() += 1;
        }
    }
    tally
}

// ---------------------------------------------------------------------------
// The tests
// ---------------------------------------------------------------------------

/// **Every endpoint either belongs to this workspace or names the crate it came from.**
///
/// The classification is what makes `conforms` meaningful: without it, "no finding names an
/// id in `OWN`" is satisfiable by leaving an id out of `OWN`. This test closes that by
/// requiring the union to cover the catalog exactly — an endpoint added to a member crate
/// fails here until it is listed, and a module crate that grows one fails here too, which is
/// the notification a dependency bump otherwise does not give.
#[test]
fn the_walked_catalog_is_classified() {
    let known: BTreeSet<&str> = owners().keys().copied().collect();
    let own: BTreeSet<&str> = OWN.iter().map(|(id, _)| *id).collect();

    let mut seen: BTreeSet<String> = BTreeSet::new();
    for kernel in [root_kernel(), http_kernel()] {
        for (pattern, id) in walked(&kernel) {
            assert!(
                known.contains(id.as_str()),
                "`{pattern}` describes itself as `{id}`, which is in neither OWN nor \
                 INHERITED: classify it (and if it is ours, its inputs need classes)"
            );
            seen.insert(id);
        }
    }
    let listed: BTreeSet<&str> = known.iter().copied().collect();
    let unseen: Vec<&&str> = listed
        .iter()
        .filter(|id| !seen.contains(**id))
        .collect::<Vec<_>>();
    assert!(
        unseen.is_empty(),
        "these ids are classified but no longer bound — drop them from the tables: {unseen:?}"
    );
    assert!(
        own.len() >= 40,
        "OWN lost entries without the catalog shrinking: {}",
        own.len()
    );
}

/// ★ **The suite over both compositions: nothing unattributed, and nothing of ours.**
///
/// The inherited count is printed rather than pinned. It drops on its own as each module's
/// own adoption reaches crates.io, and pinning it would turn another crate's improvement
/// into a failure here.
#[test]
fn conforms() {
    let mut ours: Vec<String> = Vec::new();
    let own: BTreeSet<&str> = OWN.iter().map(|(id, _)| *id).collect();

    // Each composition gets the suite written FOR it — see `base` for why one shared
    // builder is a statement about a manifold that is only half true of either kernel.
    for (label, kernel, suite) in [
        (
            "ikigai_embedded::kernel() — the REPL/TUI root",
            root_kernel(),
            root_suite(),
        ),
        (
            "ikigai_embedded::kernel_for() — the HTTP door",
            http_kernel(),
            door_suite(),
        ),
    ] {
        let report = suite.run_blocking(&kernel);
        // Which run produced a report is not printed by 0.2.0 (PENDING #138), so the header
        // is this file's own.
        eprintln!("--- {label} ---\n{report}");
        eprintln!("findings by owning crate: {:?}", by_owner(&report));

        let owners = owners();
        let unattributed: Vec<String> = report
            .findings
            .iter()
            .filter(|f| !owners.contains_key(f.endpoint.as_str()))
            .map(|f| format!("{} {}", f.endpoint, f.check.label()))
            .collect();
        assert!(
            unattributed.is_empty(),
            "every finding must name a classified endpoint; these do not: {unattributed:?}"
        );
        ours.extend(
            report
                .findings
                .iter()
                .filter(|f| own.contains(f.endpoint.as_str()))
                .map(|f| format!("[{label}] {} {} {}", f.endpoint, f.check.label(), f.detail)),
        );
        assert_eq!(
            report.checks.skipped().count(),
            0,
            "every check runs over a composing host: {report}"
        );
    }

    assert!(
        ours.is_empty(),
        "findings against endpoints this workspace owns:\n{}",
        ours.join("\n")
    );
}

/// ★ **A clock-derived result EXPIRES rather than threading — and the HTTP door has a clock.**
///
/// Two things the `pure("clock-now")` / `pure("tz-now")` declarations would otherwise be
/// covering, both pinned here so the declaration certifies nothing this test does not.
///
/// 1. The expiry is `Expiry::At`, never `Never`. A clock-derived result cached forever is
///    the bug the empty-thread rule exists to catch, and `pure` silences that rule; if one
///    of these ever became `.cacheable()` outright, the suite would stay green and this
///    would not. (`Suite::cacheable_until` is the missing declaration — conformance PENDING
///    #86; ikigai-web-demo #57 wanted it too.)
/// 2. **`kernel_for` HAS a clock.** This is the regression line under a real defect the
///    walk found: the HTTP door was built without one until 0.1.20, and `Expiry::At` on a
///    clockless kernel is not a deadline — it is simply uncacheable. The door binds
///    `http_space()`, whose `Cache-Control: max-age` deadlines are exactly that expiry, so
///    the FOAF face re-fetched its source on every single hit. The symptom the suite printed
///    was two lines about `clock-now` and `tz-now`; the cost was on `urn:httpGet`, which is
///    opted out of the walk and could not have reported it itself.
#[test]
fn a_clock_derived_result_expires_rather_than_threading() {
    use ikigai_core::{Expiry, Iri, Request, Verb};

    for (label, kernel) in [("root", root_kernel()), ("http door", http_kernel())] {
        for target in ["urn:time:now", "urn:tz:now"] {
            let repr = futures::executor::block_on(kernel.issue(
                Request::new(Verb::Source, Iri::parse(target.to_string()).expect("iri")),
                &ikigai_core::Capability::root(),
            ))
            .unwrap_or_else(|e| panic!("{label}: {target}: {e}"));
            assert!(
                matches!(repr.expiry, Expiry::At(_)),
                "{label}: `{target}` must expire at a deadline, not be cached forever or be \
                 live: {:?}",
                repr.expiry
            );
            // The deadline is honoured only by a kernel that can ask the time. A second
            // resolution inside the same minute is therefore a cache HIT — on BOTH kernels,
            // which is the property the door lacked.
            assert!(
                kernel.is_cached(
                    &Request::new(Verb::Source, Iri::parse(target.to_string()).expect("iri")),
                    &ikigai_core::Capability::root(),
                ),
                "{label}: `{target}` is not cached — this kernel has no clock, so every \
                 `Expiry::At` result on it (including every `urn:httpGet` max-age) \
                 recomputes on every request"
            );
        }
    }
}

/// **The HTTP door is a subset, stated as a list.**
///
/// `kernel_for` is what `ikigai serve --http` exposes to the public edge. Its posture is
/// documented ("no personal space") and enforced twice above the kernel — by the `--cap`
/// ceiling and by `--routes-only`. Both of those are CONFIGURATION. What is code is the
/// binding, and this is the red line under it: an owner-only resource must not be bound on
/// the door at all, so a mistake in the config cannot reach one.
#[test]
fn the_http_door_serves_no_owner_only_resource() {
    let door: BTreeSet<String> = walked(&http_kernel()).into_values().collect();
    let root: BTreeSet<String> = walked(&root_kernel()).into_values().collect();
    for id in OWNER_ONLY {
        assert!(
            root.contains(*id),
            "`{id}` is not bound on the REPL root either: this list has gone stale"
        );
        assert!(
            !door.contains(*id),
            "`{id}` is bound on the PUBLIC HTTP door: authority gates it, but it should not \
             be reachable there at all"
        );
    }
    // And the door does carry the resources it exists for.
    for id in [
        "foaf",
        "contact",
        "booking",
        "passkey-challenge",
        "decisions",
    ] {
        assert!(door.contains(id), "`{id}` is missing from the HTTP door");
    }
}

/// ★ **The health graph's undefined terms, as an EXACT list — the waiver's other half.**
///
/// `opt_out_check("kernel-health", Check::Vocabulary, …)` in [`root_suite`] silences a rule
/// this repo cannot satisfy: `urn:host:health`'s `text/turtle` face names four `ik:` terms no
/// version of `ikigai-vocab` defines. A waiver alone would also waive the FIFTH invented term
/// somebody adds next, so the set is pinned here instead, reproducing `VOCABULARY` from the
/// public [`rdf`] helpers. It fails in both directions: a new invented term, and the day
/// `vocabulary.ttl` defines these.
///
/// ⚠ **What this does NOT cover, stated because the count looks complete and is not.** The
/// same face emits `ik:Job`, `ik:intervalSeconds`, `ik:runs` and `ik:stale` for each recurring
/// job, and those are undefined too — the fixture kernel simply schedules none, so the walk
/// never sees them. Eight terms need defining; four are what a test can hold.
#[test]
fn the_health_graph_uses_exactly_four_undefined_terms() {
    use ikigai_core::{Capability, Iri, Request, Verb};

    let kernel = root_kernel();
    let repr = futures::executor::block_on(
        kernel.issue(
            Request::new(
                Verb::Source,
                Iri::parse("urn:host:health".to_string()).expect("iri"),
            )
            .with_arg("as", ikigai_core::ArgRef::Inline(b"text/turtle".to_vec())),
            &Capability::root(),
        ),
    )
    .expect("the health graph face resolves");
    let triples =
        rdf::parse(&repr.repr_type.media_type, &repr.bytes).expect("the face parses as Turtle");
    let undefined: Vec<String> = rdf::terms(&triples)
        .into_iter()
        .filter(|t| !rdf::is_defined(t, &[]))
        .collect();
    assert_eq!(
        undefined,
        vec![
            "https://ikigai-rs.dev/ns#Health",
            "https://ikigai-rs.dev/ns#staleJobs",
            "https://ikigai-rs.dev/ns#uptimeSeconds",
            "https://ikigai-rs.dev/ns#verdict",
        ],
        "the waived vocabulary exception has changed — widen the waiver's reason, or drop \
         both it and this test if `vocabulary.ttl` now defines these"
    );
}