ngit 3.0.3

nostr plugin for git
Documentation
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
# CI Status and Trust Context

**Status:** all six work packages implemented — WP1 (`src/lib/ci/` core),
WP2 (`provenance`, `domain`, `resolve`), WP3 (`ngit ci status`), WP4
(`pr view` Checks, `pr list` CI column), WP5 (merge gating) and WP6
(`ngit ci request|stop|trigger`). The deferred list below still stands.
Written as a build plan: each work package below is independently buildable
and reviewable.

## Purpose

Surface NIP-34 CI results (workflow runs, conclusions, progress) inside ngit,
attached primarily to PRs, with every result labelled by the CI trust-context
model so a signature is never silently presented as maintainer endorsement.

## Normative sources

- **Event shapes:** `NIP.md` and `NIP-guidance.md` in the `ngit-ci` repository.
  Kind numbers are experimental placeholders; centralize them in one module and
  surface incompatible shapes rather than reinterpreting them.
- **Trust semantics and canonical wording:** `docs/ci-trust-context.md` in the
  `gitworkshop` repository. Its "Recommended language" strings are canonical;
  ngit must reuse them verbatim so CLI and web agree.
- **Reference implementation to port:** gitworkshop
  `src/lib/ciTrustContext.ts`, `src/lib/ciCoordinatorRelationship.ts`, and the
  evidence-assembly logic in `src/hooks/useCITrustContext.ts`.
- **Maintainer model:** the
  [maintainer protocol for AI implementers]https://ngit.dev/protocol/nip-34/maintainers/ai-implementers.
  Trust evidence requiring "a confirmed repository maintainer" means
  `RepoRef::confirmed_maintainers()` — never invited maintainers.

## Event vocabulary (consumed subset)

| kind  | event                | role in this design                          |
| ----- | -------------------- | -------------------------------------------- |
| 9842  | Workflow Result      | terminal outcome; quotes Job Results and provenance |
| 39842 | Workflow Progress    | queued/in_progress/concluded; NIP-40 expiry ≤ 30 min |
| 9841  | Job Result           | per-job outcome, provider-signed             |
| 9843  | Service Request      | maintainer-directed evidence (standing)      |
| 9844  | Service Stop         | closes requests; total order                 |
| 9840  | Manual Trigger       | maintainer-directed evidence (one-shot)      |

PR-triggered CI events carry NIP-22 tags: `E` = kind-1618 PR root, `e` = PR or
kind-1619 update that supplied the commit, `c` = commit id. Push/tag-triggered
events carry an `r` Git-ref tag instead. Attaching CI to a PR is therefore a
`#E` filter; to a commit, a `#c` filter.

Out of scope for now: Repository Secret Updates (29846), Coordinator
Advertisement/Readiness/Status (19843/19844/39844) beyond what provenance
validation needs, and NIP-11 operator-key strengthening.

## Trust model — port requirements

Port the gitworkshop model 1:1 unless noted. The load-bearing rules:

1. **Evidence, not scores.** A resolution is a list of typed evidence items
   `{ kind, classification, summary, detail, authors, scope }`. Classification
   of a signer = strongest evidence item; empty list = `NoKnownContext`
   (absence of evidence, never "untrusted").
2. **Classifications:** `MaintainerDirected` > `OperationallyAssociated` >
   `SeenInYourNetwork` > `NoKnownContext`. Level 3 (viewer-relative network
   evidence) is deferred — see phasing — but the enum includes it from the
   start.
3. **Temporal correctness.** A *current* standing Service Request never
   retroactively covers old runs, and neither does a quoted one. Per-run
   coverage is derived from either (a) the run's frozen `service-request` /
   `manual-trigger` `q` quote, validated (below) *and* placed in time by the
   same reduction — a quoted Request must have been open at the run's handoff,
   a quoted Trigger must precede it — or (b) reduction of the immutable
   control history at the
   run's `started_at` (fallback `queued_at`), using the NIP's total order:
   greater `created_at` is later; at equal timestamps the lexicographically
   lower event id is later. A Stop from a confirmed maintainer closes the whole
   perspective; any other author's Stop closes only their own requests.
4. **Quote validation.** A coordinator-authored `q` tag is not evidence. The
   quoted 9843/9840 must be fetched and checked: event id, kind, a valid
   signature, author is a confirmed maintainer, `a` repository coordinates intersect the resolved
   confirmed-member coordinate set, `p` names the coordinator, and (for manual triggers) the
   run context matches. Only then does it yield `MaintainerDirected` evidence
   with `scope: run`.
5. **Delegation scoping.** When a Job Result is signed by a different pubkey
   than the coordinator, coordinator trust reaches the provider only through
   the Workflow Result that accepts that job, only for that job, and downgraded
   (never above `OperationallyAssociated`, except seen-in-your-network stays
   seen-in-your-network). Provider evidence never flows back to the
   coordinator.
6. **Rollups are conservative.** Summarizing several runs (e.g. one table
   cell) surfaces the *weakest* settled run, and any partial coverage makes the
   rollup partial.
7. **Domain evidence (Level 2).** Verified NIP-05 identities — the signer's
   kind-0 `nip05` and the synthetic root candidates `_@<grasp-domain>` for
   every GRASP domain in the resolved repository's clone URLs — matched against
   those domains. Exact match is direct association; proper parent/child
   subdomain (DNS-label boundaries only, ports and trailing dots stripped) is
   weaker and must say so; sibling domains are not evidence. Never use string
   suffix matching without label boundaries.
8. **Resolution ≠ classification.** States: `loading`, `settled+complete`,
   `settled+partial`. A signer must never display "No known context" while
   evidence queries are unsettled; failed relays/lookups settle as partial with
   the canonical caveat **Context incomplete**.

ngit-specific addition: **local integrity check**. ngit holds the git objects,
so for each run it can verify the `c` commit exists locally (or in fetched PR
refs) and that the `w` tag's SHA-256 matches the workflow file blob at that
commit. Report as a separate integrity marker alongside trust — it is not a
trust level, and the trust doc is explicit that trust context does not prove
source integrity.

## Library design: `src/lib/ci/`

- `kinds.rs` — kind constants and strict shape validation for consumed kinds.
- `events.rs` — parse events into `WorkflowRun { coordinator, workflow_path,
  workflow_hash, trigger, pr_root, supplying_event, commits, progress, result,
  jobs }`; group Progress/Result/Job Results by workflow-run id; latest attempt
  per (coordinator, workflow) is current; respect Progress expiration (expired
  progress with no result = stale, not running).
- `controls.rs` — Service Request/Stop reduction and coordinator relationship
  tiers (`requested` / `previously-requested` / `unassociated`); port of
  `ciCoordinatorRelationship.ts` including `wasCIServiceRequestedWhenRunStarted`.
- `provenance.rs` — frozen-quote fetch + validation (rule 4).
- `domain.rs` — NIP-05 resolution (reuse the existing nostr-URL NIP-05 fetch
  path) and the domain-relationship ladder (rule 7), with a TTL cache in the
  local cache dir.
- `trust.rs` — classification/evidence types, strongest-evidence
  classification, run/job resolution (rules 3–5), weakest rollup (rule 6),
  canonical label/wording constants.
- `resolve.rs` — assembly with **two tiers**:
  - *cache tier*: evidence computable from the local nostr cache alone
    (control-history reduction, quote validation when the quoted event is
    cached). Used by `pr list`. Coverage is `partial` when domain checks were
    skipped.
  - *full tier*: additionally fetch missing quoted events and perform NIP-05
    domain verification. Used by `pr view`, `ci status`, and a merge with an
    explicit CI trust gate.

### WP1 implementation decisions

Recorded where the build resolved something this design or the TypeScript
reference left open:

- **A frozen quote is only evidence once validated.** Rule 4 is enforced at
  this layer by construction: `run_maintainer_link` and
  `run_trust_resolution` take a caller-supplied `validated_provenance` set of
  verdicts that WP2 has fetched and checked, and report maintainer direction
  only for a verdict covering *this* run whose requester is a confirmed
  maintainer and whose request the history places at or before the run (two
  bullets down). With an empty set — every caller until WP2 lands — an
  unvalidated quote contributes nothing, and only the control-history
  reduction can establish maintainer direction.
- **A verdict is scoped to the run it was reached for.** A validated quote is
  a `ValidatedProvenance { coordinator, run_id, request }`, not a bare event
  id, and `covers(run)` requires the coordinator, the run id and the quoted
  event id to match the run's own frozen quote.
  Keying by quoted id alone would let one run's valid quote legitimize every
  other run referencing it: a Manual Trigger authorizes one workflow, commit
  and pull-request context, so a coordinator could replay the id on a run the
  maintainer never authorized and satisfy `--require-ci-trust
  maintainer-directed`. A Service Request is likewise checked against the
  coordinator the quoting run names.
- **A quote decides *which* request is cited, never *when* it applied.**
  Freezing a `q` tag is the coordinator's act, so a validated quote passes the
  same temporal evaluation an unquoted control does, at the run's handoff
  (`coverage_time`, and no handoff time is indeterminate as everywhere else):
  a quoted Service Request is reduced *with* the perspective's control history
  by `run_maintainer_link`, so one signed after the run, or closed by a Stop
  before it, covers nothing; a quoted Manual Trigger must precede the handoff.
  Without this a coordinator could quote a withdrawn request, or one a
  maintainer signed for later work, and present an old run as
  maintainer-directed — which is exactly the retroactivity rule 3 forbids on
  the control-history route. The verdict therefore carries the validated
  request (`ValidatedRequest::ServiceRequest` with the control itself,
  `ManualTrigger` with its signing time) rather than the bare fact that
  validation held. The reduction reads the request's *own* perspective, which
  provenance already placed inside the confirmed-member coordinate set, so the check cannot
  be dodged by naming a coordinate the caller did not ask about.
- **Strict shapes, skipped with a reason.** `kinds.rs` rejects an event that
  breaks a NIP MUST rather than reinterpreting it: a Job Result quoting a
  Service Request; a *queued* Progress carrying the `service-request` quote
  that is only frozen at runner handoff; a provenance quote without its
  required requester pubkey hint; a Workflow Result without the non-`refs/`
  workflow-run `r`; a Job Result without exactly one quoted Workflow
  Progress address; empty-content and cardinality MUSTs (9843/9844 `a`/`p`,
  Progress `d`/`status`/`expiration`, `conclusion` only when concluded).
  `events.rs` reports each rejection with its reason instead of dropping it
  silently. Two of these bounds are ngit's, not the NIP's, and are noted as
  such: the Progress `expiration` must be *after* `created_at` (the NIP only
  caps it at 30 minutes, and a marker expiring before it was signed is
  meaningless), and repeated `a` tags must share one repository identifier.
  Everything the NIP does not forbid is tolerated: unmarked or unrecognised
  `q` entries, extra `p` tags on result-like events (the first NIP-22 `p` is
  the parent author), and a non-`refs/` `r` on a Progress marker are ignored,
  not rejected. The exception is a Manual Trigger with more than one `p`: on a
  9840 the `p` slot is the coordinator address, and the NIP has coordinators
  require exactly one `p` naming themselves. That holds for a PR-context
  trigger too — the NIP-22 context it carries excludes the participant `p`  so a second `p` is never a parent author, and tolerating one would let a
  coordinator the maintainer never addressed pass provenance validation.
- **Run identity is the workflow-run id.** Grouping keys on
  `(coordinator, run id)` only. gitworkshop's `queued_at`-plus-context
  fallback for publishers that predate the `r` run id is not carried over;
  ngit requires the run id.
- **Job Results are grouped by their quoted 39842 address**, which is the
  provider's own statement of the run. A job whose run has no Result or
  Progress marker is skipped as orphaned. This is *not* the NIP's "clients
  MUST NOT require a quoted Workflow Progress event to remain available after
  its expiration in order to accept a Job Result": an expired Progress marker
  is irrelevant here, because a run is retained as soon as either container
  was seen, and the coordinator's Result outlives the marker. The skip only
  covers a Job Result whose run ngit has never seen a container for, where
  the repository association would rest on the provider's own `a` tags alone.
  If a real deployment loses containers often enough for that to hide
  results, surfacing provider-only runs — clearly labelled — is the intended
  follow-up.
- **A coordinator's acceptance decides which job claim represents a job.**
  Where the Workflow Result quotes a Job Result for a job id, only accepted
  results represent that job, so an unaccepted 9841 from any signer cannot
  displace it. Where nothing was accepted, every signer's latest claim for
  that job id is surfaced rather than one silently winning; Job Results are
  keyed by `(job id, signer)`, never by job id alone.
- **Control reduction is per perspective.** Each `a` coordinate is reduced
  separately and the results OR'd, so a Stop on one maintainer's coordinate
  never closes a Request made on another's. The OR is the permissive
  direction — a Request standing on any perspective the caller passes counts
  as coverage — which is why callers pass the perspectives explicitly rather
  than letting the coordinator-authored `a` tags on the run widen the set.
- **Only a confirmed maintainer's Request is accepted.**
  `wasCIServiceRequestedWhenRunStarted` takes the latest control regardless of
  author; ngit treats the confirmed-maintainer list as the acceptance policy,
  so a stranger's Request never yields coverage while their Stop still closes
  their own Requests. An operator may accept other requester pubkeys for
  running work; that is not maintainer direction.
- **Delegation requires the accepting quote.** `getCIJobTrustResolution` only
  checks that *a* Workflow Result exists; ngit additionally requires that
  result to quote the exact Job Result, which is what rule 5 actually says.
- **Evidence classification is a separate type** from the trust
  classification, so `NoKnownContext` — an absence — is not representable on
  an evidence item (the TypeScript `Exclude<>`, enforced by the compiler).

### WP2 implementation decisions

- **Domain normalization strips the port before the root dot.** The
  TypeScript `normalizedDomain` lowercases, removes one trailing dot and
  *then* splits on `:`, so `grasp.example.:443` normalizes to
  `grasp.example.` and never matches `grasp.example`. ngit drops the port
  first. Both spellings name the same DNS host, so the reference order only
  ever loses a true match; the reversal cannot make two distinct hosts equal.
  Everything else about the ladder is a verbatim port, including that an
  exact match beats a subdomain in either direction, that the label boundary
  comes from the leading `.` in the comparison, and that siblings are
  nothing. gitworkshop should be aligned with this ordering.
- **A Manual Trigger's pull-request context must match the run's exactly.**
  Rule 4 only says PR-context triggers must match the run's PR root; ngit
  compares the two `Option<EventId>`s, so a PR trigger never covers a
  push-context run and a push/ref trigger never covers a PR run. The NIP has
  a PR run's trigger carry the PR tags, so the stricter reading costs
  nothing, and a rejection is never a negative claim — the run simply keeps
  no run-scoped maintainer direction.
- **The quoted event's signature is verified here, not at the fetch.**
  Everything after the kind check reads the quoted event's tags as the
  requester's statement, so `validate_run_provenance` requires
  `Event::verify` first. Without it a coordinator could supply a doctored
  copy of a real maintainer request — same event id, `p` rewritten to address
  itself — and pass every remaining check. Verifying at this layer rather
  than in `QuotedEventFetcher` means the guarantee holds whatever the
  caller's source is, so a fetcher may return unverified and even unrequested
  events.
- **A standing Service Request has no run context to match.** Its validation
  checks the id, kind, shape, author, repository closure, coordinator and the
  requester hint only. Temporal coverage of a *particular* run remains the
  control-history reduction's job, which is where WP1 enforces
  non-retroactivity — so validation hands the request itself back in the
  verdict rather than answering a question it cannot see the history for.
- **An unavailable quote is not a rejected quote.** `ProvenanceOutcome`
  separates `validated`, `rejected` and `unavailable`. An unavailable quote —
  one nothing could retrieve — yields no evidence *and* makes coverage
  partial, so it surfaces as **Context incomplete** rather than as a
  finding against the run.
- **A NIP-05 lookup error is unsettled, not falsified.** ngit reuses
  `client::nip05_query` (the path that resolves `nostr://` URLs) behind the
  `Nip05Lookup` trait. That call cannot distinguish a reachable document that
  omits the name from a transport failure, so every error settles as `failed`
  → partial coverage. A document that resolves the local part to *another*
  pubkey is a settled non-match: no evidence, no partial.
- **Every lookup is bounded, and so is the step as a whole.** `nip05_query`
  has no deadline of its own, so `NetworkNip05Lookup` wraps it in a
  five-second timeout — gitworkshop's `IDENTITY_TIMEOUT_MS` — and an elapsed
  lookup becomes a failed one. That is what makes the trust doc's "a bounded
  identity-resolution failure counts as settled" true here. A per-lookup
  timeout alone is not enough: the candidate set is publisher-supplied, so
  resolving candidates one at a time let anyone who can add a signer add a
  distinct slow domain, and another whole timeout, to every full-tier
  command — including a merge with an explicit CI trust gate.
  `verify_identities` therefore resolves each distinct address once however
  many signers name it, keeps at most `IDENTITY_LOOKUP_CONCURRENCY` (8)
  lookups in flight, tries at most `MAX_IDENTITY_LOOKUPS` (32) addresses,
  and abandons the step at `IDENTITY_RESOLUTION_DEADLINE` — the same five
  seconds one lookup was already allowed, since concurrent lookups share
  that budget rather than each consuming one of their own. The repository's
  own GRASP roots are resolved first, so publisher-declared identities
  cannot crowd them out of the count. Whatever is left — past the cap, or
  still in flight at the deadline — is reported exactly like a failed
  lookup: partial coverage, never a negative claim. Only lookups that
  actually completed are written to the cache, so a budget decision does not
  suppress the next command's attempt, and successes are reused across
  commands.
- **Only signers that survive authorization are resolved.**
  `identity_lookup_signers` is deliberately narrower than the view's signer
  set: run coordinators and job providers — which every surface displays and
  the merge gate rolls up — plus the coordinators of controls a *confirmed
  maintainer* authored, which are the only controls that yield a relationship
  tier. Anyone may sign a Service Request naming this repository's coordinate
  and any coordinator pubkey, and `controls_in_closure` filters by coordinate
  rather than by author, so the broad set let an unrelated publisher put a
  domain of their choosing into the identity step of every full-tier command.
  Such a coordinator keeps its (empty) resolution and its unassociated
  relationship — no surface describes it either way; only the lookup is
  withheld. `ci_projection` scopes its kind-0 profile query to the same set,
  since a profile fetched for anybody else feeds a lookup that never happens.
- **Identity candidates use every GRASP clone URL.** `RepoRef::grasp_servers`
  additionally requires a matching relay entry, which is the right test for
  publishing infrastructure but too narrow for evidence. `domain::
  repository_grasp_domains` keeps the host (with port) of every clone URL
  `is_grasp_server_clone_url` accepts, matching gitworkshop's
  `graspServerDomains`.
- **NIP-05 TTLs: one hour for a success, five minutes for a failure.** The
  cache is one JSON file per address, named by the hex of the address, under
  `<ngit cache dir>/ci-nip05`. A missing, unreadable, malformed, foreign,
  expired or future-stamped entry is treated as absent, so a corrupt cache
  costs a lookup and never a wrong answer. The cache is optional at every
  call site (`Option<&Nip05Cache>`) and its directory and TTLs are
  injectable, so tests never touch the user's cache or the clock.
- **Cache-tier coverage is partial whenever there is a signer to describe.**
  The domain ladder is skipped there by definition. A view with no signers
  has nothing to leave unchecked, so it stays complete rather than
  displaying a caveat about evidence that does not exist.
- **Control history is filtered to the confirmed-member coordinate set once.**
  `resolve` reduces `CiInputs::controls` to the controls naming a coordinate
  in that set before anything reads them, so a Service Request for a
  *different* repository — even one a maintainer of this repository signed —
  introduces no coordinator, no relationship entry and no requester
  attribution here. The filtered history is what the context stores and what
  every per-run reduction later reads.
- **Provenance verdicts are read-only.** `validated_provenance`,
  `rejected_provenance` and `unavailable_provenance` are private with
  accessors, like the repository facts beside them: a verdict enters the
  validated set only by passing `validate_run_provenance` for the run it
  names, so no caller can manufacture maintainer direction for a run.
- **Callers declare their own coverage.** `CiInputs::input_coverage` carries
  the settlement of the caller's relay queries into the context, so a failed
  relay makes the whole context partial even when every check this layer
  performs succeeds.
- **Loading is a caller-side state.** Both tiers are called with the inputs
  they need and always return a settled context;
  `CiTrustContext::loading()` is what a caller renders while its own queries
  are outstanding. That is how rule 8 is kept structurally: nothing can
  settle a signer as "No known context" while a query it depends on is
  unresolved.
- **Level 3 is a seam, not a stub.** Seen-in-your-network evidence would be
  appended per signer in `resolve::assemble` and its inputs added to
  `CiInputs`; nothing else in the model changes. No placeholder types were
  added for it.

### WP3 implementation decisions

- **The fetch filter lives where the filters are built.** The design pointed
  at `fetching_with_account`; the repo-wide filters it ultimately sends are
  assembled in `client::get_fetch_filters`, so the consumed CI kinds are one
  further `#a` filter there (`client::get_filter_ci_events`). That places
  them in the `RelayFetchScope::Repository` set only, so CI events are
  requested from repository relays and never from announcement indexers or
  user relays, and `process_fetched_events` caches them like every other
  repository event.
- **A refused `--require-ci-trust` is output, not a failure to produce
  output.** `main`'s JSON error path replaces the document with
  `{"command_status":"error"}`, which would discard the runs that explain
  the refusal. `ci status` therefore emits its full document — the `ci` object,
  plus `command_status: "error"` and the reason — and exits non-zero through
  `output::finish_and_exit`. The gate is evaluated on the rolled-up current
  result: not `concluded`, not green, or a weakest run below the floor.
- **A PR's `#E` anchor is not always the event ngit threads from.** When a
  patch thread is upgraded to a PR, ngit keeps the patch root as the thread's
  identity while every later PR update — and every CI event — references the
  kind-1618 upgrade root. `ci status` therefore resolves the anchor through
  the same `utils::pr_upgrade_root` rule the push path uses, rather than
  reimplementing it, and reports it as `target.anchor` beside `target.pr`.
  For the same reason, an exact event id that names a revision root (a patch
  revision, or that upgrade root) maps to the thread it revises rather than
  being taken for a thread root. Without both, an upgraded thread's `#E`
  query would silently find nothing and report `state: "none"`.
- **`revision_matched` distinguishes "no CI" from "CI for something else".**
  It is true when the target has no CI events at all, or at least one run
  describes the current revision; it is false only when CI exists for the PR
  but every run supplied a different revision. A run with no `e` supplying
  event matches only when the PR's current revision is its root.
- **A live marker wins the target state, and an unfinished run withholds
  `concluded`.** Any current run that is running makes the target `running`,
  even beside a workflow that already concluded. `concluded` requires *every*
  current run to have concluded; it then rolls up the worst of them, where
  `neutral`/`skipped` rank below `success` so they neither fail the rollup nor
  displace a real outcome. `stale` is the state where nothing is running and
  at least one current run never concluded — a wholly abandoned target, and
  equally one workflow that succeeded beside another that stopped renewing its
  marker. Reporting the latter as `concluded` would roll the survivors up into
  a `success` and let `--require-ci-trust` pass a target whose CI never
  completed, which is not "block until CI is green"; the mixed case is
  therefore the same "has not concluded" shortfall as the wholly abandoned
  one, on every surface that reads the state.
- **`attempt_of` is the attempt count for the run's `(coordinator,
  workflow)` on this target**, which is the position of the current attempt.
  The design's JSON shape named the field without defining it.
- **The integrity marker is never an error.** A commit ngit does not hold
  reports `commit_present: false` and leaves `workflow_hash_matches` null; a
  commit it holds without the workflow file at that path leaves the hash
  null as well. Only a blob that hashes to something other than the `w`
  value is `false`. It is printed on its own line, below the trust label, so
  it cannot be read as a trust level. Every `c` value is tried and peeled, so
  a publisher that ordered an annotated tag object before the commit is still
  resolved. The hash is taken over the **blob** at that commit, never over
  the working-tree file: `core.autocrlf` and clean/smudge filters make the
  checkout differ from the object, and the object is what the NIP signs.
- **Runs are read per target, controls repository-wide.** A run query is a
  `#E` (PR) or `#c` (commit) filter, but Service Requests/Stops carry
  neither, so they are read by the repository's own `a` coordinates and
  handed to `resolve`, which filters them to the confirmed-member coordinate set.
- **The full tier fetches missing quotes from the repository relays plus the
  quote's own relay hint**, and verifies NIP-05 through the WP2 machinery
  and its TTL cache. Coverage is routinely `partial` in practice — a GRASP
  host that serves no NIP-05 document is an unsettled lookup, not a negative
  claim — so the caveat is expected rather than exceptional.
- **CI signers' kind-0 profiles are fetched by this command.** A coordinator
  or compute provider is not a repository contributor, so the repository
  fetch never asks for its profile and the signer-declared `nip05` route
  would be dead evidence. The full tier therefore issues one targeted
  kind-0 query for the run and control signers over the repository relays
  plus the user's default relay set, caches what it gets, and reads the
  declared `nip05` back from the cache. It is best effort: an unreachable
  profile is an absence of evidence, so a failure is not surfaced. The
  synthetic `_@<grasp-domain>` route needs no profile and is unaffected.
- **Caller-side coverage comes from the fetch report.** `FetchReport::
  state_per_relay` records every repository relay the fetch reached; a relay
  whose query errored never reaches the consolidated report. Any announced
  relay missing from it makes `CiInputs::input_coverage` partial, which is
  how rule 8's "failed relays settle as partial" holds for this command
  rather than the trust layer's local checks being mistaken for the whole
  picture.
- **JSON ids and pubkeys are bech32.** The design's `<pubkey>` placeholder
  left the encoding open; every other ngit `--json` surface emits `nevent`
  and `npub`, and consistency across ngit's own output beats matching the
  wire encoding of a tag value. Commit ids stay hex.
- **`--offline` is "no relay fetch and no NIP-05", not "no network".**
  Resolving a repository coordinate from a `nostr://` remote in nip05 form
  can still perform the NIP-05 HTTP lookup that names the maintainer, before
  the offline guard is reached. The flag's help says what it actually
  skips.
- **The stale state is seeded into the local cache in tests.** A relay
  refuses an already-expired event (NIP-40), and waiting for a live marker
  to expire would be the wall-clock sleep the harness forbids. `stale` is by
  definition a marker ngit fetched while it was live and still holds, so the
  test writes it with `client::save_event_in_local_cache` — the same call
  the fetch would have made — and asserts on what ngit reads back. Every
  other CI fixture is published to a relay listed on the announcement.

### WP4 implementation decisions

- **One projection module, three surfaces.** The target types, the run
  selection, the state machine, the integrity check, the cache reads and the
  JSON/human rendering moved out of `ci_status.rs` into
  `src/bin/ngit/ci_projection.rs`. `ci status`, `pr view` and (WP5) a merge
  with an explicit CI trust gate differ only in the `ProjectionRequest` they
  pass: which `Tier`, whether earlier revisions are collected, and the
  caller's own coverage. The module is bin-local because it renders
  (`crate::output`, the stdout-guarding `println!`) and reads the
  `--require-ci-trust` floor; nothing in it belongs in `ngit::ci`.
- **`Target::select` splits rather than filters.** It now returns the
  current runs, the runs of superseded revisions, and `revision_matched`,
  computed from *this target's* runs rather than the whole query result.
  That is what lets `pr list` group one batched `#E` query for every listed
  PR without a per-PR query, and it is why `revision_matched` stays correct
  when the run set describes several PRs.
- **Outdated attempts are collapsed within a revision, never across.**
  `latest_attempts` keys on `(coordinator, workflow)` alone, so collapsing
  the whole outdated set would hide one revision's result behind another's.
  They are grouped by supplying event, collapsed inside each group, and
  ordered newest superseded revision first.
- **Evidence assembly sees every run known for the target; state,
  conclusion and the rollup never do.** `CiInputs::runs` is the current
  attempts *plus* the latest attempt of every superseded revision, on all
  three surfaces, whether or not the surface reports the outdated ones. A
  run for an earlier revision is legitimate identity-level evidence about
  its signer — gitworkshop's `classifyCICoordinatorRelationships` is
  likewise given every known run — and a coordinator whose only maintainer
  direction is an earlier revision's manual trigger is
  `previously-requested` no matter which command is asking. Feeding only
  the current runs, as the first cut of `ci status` did, made the same
  signer `no-known-context` in `ci status` and `operationally-associated`
  in `pr view`. Display, `state`, `conclusion` and the trust rollup are
  computed from the current runs alone, so an earlier result never becomes
  the PR's outcome. WP5's `--require-ci-trust` gate inherits both halves:
  it reads the rollup, which is current-only, over classifications that saw
  the target's whole history.
- **`outdated` is a JSON array, present only where the surface groups by
  revision.** `ngit ci status` describes one revision and emits no `outdated`
  key at all; `ngit pr view` always emits the array, empty when the PR has
  one revision. Each entry is a run object plus `revision`, the `nevent` of
  the event that supplied it (`null` when the run named no supplying event),
  so an earlier result cannot be read as current even by a consumer that
  flattens the document.
- **`pr list` costs three cache reads for the whole table, not three per
  row.** The anchors of every listed PR go into a single `#E` query; the
  service controls and the quoted requests are one read each; one
  `resolve_cache_tier` labels every run; and each row's cell is that
  context's rollup over its own current runs. Resolving a PR's anchor still
  costs the per-thread cache read `get_open_or_draft_proposals` already
  performs on every `git fetch`, and a thread that cannot be read shows no
  CI rather than failing the listing.
- **"No CI" has one encoding.** A row with no current run reports
  `classification: null` and `coverage: null` — there is no signer to
  classify and nothing was left unchecked — which is exactly what a PR
  whose thread could not be read reports. Rolling up an empty set would
  instead have claimed `no-known-context` about a signer that does not
  exist.
- **`cancelled` is not a pass.** The glyph table names `` passing and ``
  failing without placing `cancelled`; nothing ran to completion, so it is
  grouped with the outcomes that ask for a look. `neutral` and `skipped`
  stay passes, consistent with the rollup that already refuses to let them
  displace a real outcome. Which conclusions pass is not decided here: the
  glyph calls the same `is_green` the merge gate does (WP5).
- **The column and its footer appear only when a listed PR has CI.** A
  repository with no CI gets no column of dashes and no legend. The footer
  is one line: it reads the glyphs and says what `?` means — the *weakest*
  signer behind a passing result has no known context, which is what the
  conservative rollup surfaces — and points at `ngit pr view` for the
  evidence. It is ngit's own wording, not one of the canonical
  gitworkshop strings.
- **The list path expresses no coverage caveat in human output.**
  Cache-tier coverage is partial for every row that has a signer at all
  (WP2), so a `Context incomplete` line would appear beside every row and
  distinguish nothing. Coverage is reported per row in JSON, and the
  detail surfaces — where the domain ladder actually runs, and where the
  caveat therefore means something — print it.
- **The list JSON carries fields, not the glyph.** Every row has a `ci`
  object — `state`, `conclusion`, `classification`, `trust_floor_met`,
  `coverage`, `revision_matched` — including rows with no CI, so a consumer
  never branches on a missing key and never parses a symbol. The glyph is
  derived from those fields and is unit-tested against them.
- **`pr view` prints no empty section.** The `ci` object is always in the
  JSON document; the human "Checks" section is printed only when the PR has
  a current or an outdated run. The section is otherwise `ci status`'s own
  rendering, including the integrity line and the `Context incomplete`
  caveat.

### WP5 implementation decisions

- **One shortfall shared by every gate.** `CiReport::gate_failure` was split:
  `shortfall(floor)` returns the bare reason a current result does not meet a
  floor — not concluded, not green, or a weakest run below it — and the
  gate is that reason prefixed with the flag that demanded it. Merge and
  `ci status` therefore cannot drift into disagreeing about what "below the
  floor" means, and the refusal wording `ci status` already emits is
  unchanged.
- **One green predicate, and every surface calls it.** `is_green` decides
  which conclusions pass; the `pr list` glyph reads it directly and
  `shortfall` reads it for the rolled-up conclusion, so the set of results
  that render `` and the set a merge
  `--require-ci-trust=operationally-associated` allows are the same set by
  construction rather than by two implementations agreeing. They did not
  agree at first: the glyph accepted `neutral`/`skipped` while the gate
  compared against `success` alone, so `pr list` showed a pass the merge gate
  refused. `neutral` and `skipped` are green. They are *concluded* runs
  reporting that there was nothing to do, which is why the worst-of rollup
  already ranks them below `success` rather than above it, and "block until
  CI is green" is not a licence to block on a workflow that decided it had no
  work. `cancelled` is not green — nothing ran to completion — and neither is
  a target that has not concluded at all.
- **The list's unnamed floor is `operationally-associated`.** `pr list` reads
  `DEFAULT_TRUST_FLOOR` to distinguish `` from `✓?`. Merge evaluates CI only
  when the caller explicitly supplies `--require-ci-trust`.
- **A demanded floor requires a current result.** `state: "none"` covers both
  a target CI was never asked about and one whose CI describes only another
  revision. An explicit `--require-ci-trust` refuses either because the caller
  requested evidence and no current result meets the floor. A running or
  stale target is likewise not concluded and is refused.
- **An unsettled trust context fails the floor.** `shortfall` used to return
  the "meets the floor" answer when the rollup had no classification, so a
  `TrustResolution::Loading` would have passed the gate silently. Both tiers
  always settle today, and `loading` is a caller-side state (WP2), so this is
  unreachable rather than latent — but a floor is a claim about evidence, and
  the absence of a classification is evidence that has not settled, never
  evidence that met it.
- **The gate runs before anything the command changes.** It is evaluated
  after proposal resolution but ahead of branch creation and `git merge`, so
  a refusal leaves no local branch, no merge commit and no status event. Like
  `ci status`, the refusal is emitted as the
  command's document through `output::finish_and_exit(1)` rather than
  `main`'s error path, which would replace the runs that explain it with
  `{"command_status":"error"}`.
- **The gated document retains `ci_warning: null`.** A demanded floor produces
  either a successful merge or the refusal in `error`, never a warning.
  `command_status`/`action` are derived from the presence of `error`
  (`ok`/`merged` against `error`/`refused`); neither outcome publishes an
  `event`.
- **The summary is the current revision only.** `include_outdated` is false,
  as in `ci status`: a merge is a decision about the revision being merged,
  and earlier revisions are `pr view`'s business. The document therefore
  carries no `outdated` key, consistent with WP4's rule that it appears only
  where the surface groups by revision. The section itself is printed only
  when the PR has a result, as in `pr view`; a refusal prints its reason
  regardless.
- **Both merge spellings share `--offline`.** For an explicitly requested
  gate, the tier follows the flag directly: `Tier::Cache` offline,
  `Tier::Full` otherwise, with caller-side coverage taken from the fetch report
  exactly as `ci status` takes it. Without a requested gate, merge does not
  build a CI projection at either tier.

Fetching: the consumed CI kinds (9840–9844, 39842) are one further repository
`#a` filter in `client::get_filter_ci_events`, added to the repository-scope
set built by `client::get_fetch_filters`, so CI events land in the normal
local cache during the fetch every PR command already performs. That set is
also what the `git-remote-nostr` fetch sends, so a `git fetch` against a
nostr remote carries the CI filter too. That is intended — it is how a
repository's CI history stays current for `pr list` without a command of its
own — and its cost is one extra filter per repository relay.

### WP6 implementation decisions

- **The perspective is the signer's own announcement when they have one.**
  The NIP has a requester ask "for one repository perspective", so a
  co-maintainer's control names the coordinate they are a maintainer of by
  construction: `30617:<signer>:<identifier>` whenever ngit holds an
  announcement from the signer for this repository, and the resolved
  selected maintainer's coordinate otherwise (which is also what a
  contributor with no announcement asks about). The relay hint is the
  repository's first relay. All three commands share the choice, so
  `ngit ci stop` normally closes what `ngit ci request` opened — but the
  choice reads live state, so it is not a promise that a stop always
  lands where its request did: a signer who publishes their own announcement
  between the two, or a repository whose selected maintainer changes, gets a
  stop on a different coordinate — and the reduction is per coordinate, so it
  closes nothing. Naming the perspective explicitly is the fix if that ever
  bites; today the ambiguity is not worth a flag.
- **One `a` on a control, several on a trigger — and a relay hint only on the
  control.** 9843/9844 carry exactly one `a`, which the NIP lets carry one
  relay hint. A Manual Trigger emits the perspective first and then every
  *other* confirmed-member announcement ngit holds for the repository, in
  `members_for_announcement_tags` order — the same ordering ngit's PR and
  issue events already use — and **no** hint on any of them: the NIP grants
  the hint to the Service Request's `a`, not to the common tags, and the
  reference coordinator rejects a common `a` tag with a third element and
  drops the request. The extra coordinates are candidates, never
  authorization: the coordinator resolves each independently, and WP2's
  provenance validation needs only one of them to be in the maintainer
  closure. The integration tests pin the arity of every published tag
  (`a`/`c`/`p`/`r` two elements, `w` three, no `o`) directly rather than
  through `ci::kinds`, because ngit's own reader is deliberately more
  tolerant than a coordinator's parser and cannot catch this class.
- **ngit validates what it is about to publish.** Every event is
  round-tripped through `kinds::validate_service_control` /
  `validate_manual_trigger` before it is sent, and a failure is an error
  naming the shape rule rather than a published event. The writing side
  therefore cannot drift from the reading side: an event this ngit would skip
  as malformed is one it refuses to sign off.
- **The git facts are resolved before anything touches the network.**
  `ngit ci trigger` resolves the commit-ish and hashes the workflow blob
  before the repository fetch and the login, so a typo in either fails
  immediately, without a fetch and without a signature. It is the same
  publishes-nothing contract seen from the user's side.
- **The `w` hash comes from the blob, and `--workflow` is a path at the
  commit.** Identical to WP3's integrity check and for the same reason:
  `core.autocrlf` and clean/smudge filters make the checkout differ from the
  object, and the object is what the coordinator hashes. A path that does not
  exist at the resolved commit — or names a tree — is an error naming the
  commit, and nothing is published.
- **Peel verification is performed on the values being published, not on how
  they were derived.** An annotated tag contributes two `c` values, peeled
  commit first; each is then re-read from the object database and peeled, and
  a disagreement is refused. Deriving both from one `revparse_single` already
  makes them consistent, so this is a check of the event rather than of the
  derivation — which is what a coordinator will repeat.
- **`--ref` is explicit and must be a full `refs/...` ref.** ngit does not
  infer the checked-out branch: the `r` tag is context a workflow may read,
  and inferring it would put a ref on a maintainer-signed event that the
  maintainer never named. A value without the `refs/` prefix is rejected with
  the full spelling suggested, because the reading side treats a non-`refs/`
  `r` as something else entirely.
- **PR-context Manual Triggers are deferred.** A `pr_root`-carrying 9840
  needs the anchor's author and both kinds — not the supplying revision's
  author, whose participant `p` the NIP excludes from a trigger — and a
  commit ngit may not hold locally to hash the workflow at; neither falls
  out of the WP3/WP4 target machinery, which resolves ids rather than the
  NIP-22 tag set. Push/ref-context triggers are the whole of WP6; the PR
  variant is listed with the other deferred work.
- **Relay targets: repository relays, plus the coordinator's NIP-65 read
  relays when ngit already knows them.** The NIP-guidance names both paths.
  The second reuses `login::user::get_user_details`, the profile machinery
  ngit already has — no new discovery — and yields the read *and* unmarked
  entries, which is exactly what "not write-only" means there. Those relays
  are passed in the same bucket as the signer's own write relays rather than
  with the repository set, so `nostr.repo-relay-only` and the private-
  repository suppression cover them: a private repository must hand its
  coordinator a repository relay rather than have ngit announce its
  coordinate on public inboxes.
- **A commit the repository state does not contain is warned about, not
  refused.** The NIP makes a non-PR trigger eligible only for a repository
  whose resolved state contains the requested commit, so a coordinator
  discards a trigger for a commit that was never pushed — silently, from the
  maintainer's side. ngit therefore checks reachability locally: every ref in
  the cached kind-30618 state is peeled to a commit and tested with
  `graph_descendant_of` against the resolved commit. A state ngit cannot
  read, or one whose tips are all absent from the local object database,
  produces no warning at all: for an advisory check a false alarm is worse
  than silence.
- **A control that reached no relay is an error.** `send_events` reports
  per-relay outcomes and returns `Ok` even when every one failed, which the
  older publishing subcommands accept. A CI control exists solely to be read
  by a coordinator, so publishing it nowhere is not a partial success worth
  reporting as `command_status: "ok"`; these commands bail, naming the relays
  tried.
- **A non-maintainer is warned, never refused.** The default acceptance
  policy is maintainer-only, but the NIP lets an operator accept other
  requester pubkeys, so refusing would make ngit stricter than the protocol.
  The caveat says the event may be ignored and that only the operator can
  accept another key. It is a `warning` field — always present, `null` when
  there is nothing to say, and carrying every caveat joined by newlines when
  there is more than one — following the structured publishing-result pattern
  used by other commands.
- **JSON is `entity: "ci"` with the action naming the control**:
  `service-requested`, `service-stopped`, `triggered`. The published event is
  an `nevent`, the coordinator an `npub` and the perspective an `naddr`, as
  everywhere else in ngit's JSON; a trigger adds `commit` and `commits` as
  hex object ids, `workflow`, `workflow_hash` and `ref`. There is no `id`
  field: the entity these commands act on is the repository, which
  `repository` already names.

## Per-PR CI state machine

For the PR's **latest revision** (root 1618 or newest 1619 tip):

- `running` — any current run has an unexpired 39842 with status
  queued/in_progress.
- `concluded`*every* current run has a 9842; conclusion rolls up worst-of
  across workflows (`failure`/`timed_out`/`startup_failure` beat `cancelled`
  beat `success`; `neutral`/`skipped` do not fail the rollup).
- `stale` — nothing is running and at least one current run never concluded,
  whether or not the workflows beside it did.
- `none` — no CI events reference the PR.

A target is **green** when it is `concluded` and the rolled-up conclusion is
`success`, `neutral` or `skipped` — the concluded outcomes that are not a call
to look. `cancelled` is not green, and neither is any state other than
`concluded`. That definition lives in one predicate (`is_green`) which every
surface calls, so "passing" cannot mean one thing in a table and another at a
gate.

Results for earlier revisions are never presented as current; the detail view
lists them under an "outdated" heading.

## CLI surfaces

### `ngit ci status <target>` (new command)

`<target>` resolution order (documented in `--help`):

1. `#<hex-prefix>` — always a PR/event prefix; resolve against cached PR roots
   via `resolve_pr_root_or_prefix` (`src/bin/ngit/sub_commands/id_resolver.rs`).
2. nevent / note bech32 or full 64-char hex — always an event id
   (`parse_event_id`); must resolve to a cached PR root (or revision root,
   which maps to its PR root).
3. otherwise — try `git rev-parse` as a commit-ish; on success query by `#c`
   (including annotated-tag peeling: query both tag and peeled commit ids).
4. bare short hex that fails rev-parse falls back to PR-prefix resolution; the
   ambiguity error must suggest `#<prefix>` for events.
5. no target — HEAD commit.

Output: one line per workflow run — conclusion, workflow path, trust label,
one-line evidence summary — plus integrity marker and a trailing
`context incomplete` caveat when coverage is partial. Exit code 0; with
`--require-ci-trust=<maintainer-directed|operationally-associated>` exit
non-zero when the rolled-up current result does not meet the floor or is not
green. Supports `--offline` (cache tier only) and JSON output.

### `ngit ci request|stop|trigger <coordinator>` (new commands)

The maintainer controls, all non-interactive and driven by flags alone:

```
ngit ci request <COORDINATOR> [--offline]
ngit ci stop    <COORDINATOR> [--offline]
ngit ci trigger <COORDINATOR> [<COMMIT-ISH>] --workflow <PATH>
                [--ref <GIT-REF>] [--offline]
```

`<COORDINATOR>` is an npub or hex pubkey. `<COMMIT-ISH>` defaults to HEAD.
`--offline` skips the pre-publish repository fetch and the coordinator's
relay-list lookup; it never skips the publish itself. All three require login
through the existing signer machinery and emit the usual JSON document.

### `ngit pr view <id>`

Add a "Checks" section: current-revision runs as in `ci status` (full tier,
cache tier under `--offline`), older revisions collapsed as outdated,
delegated jobs listed with their per-job resolution. The JSON document
carries the same `ci` object as `ci status`, with the superseded revisions
under `outdated`.

### `ngit pr list`

Add a `CI` column computed from the cache tier:

```
✓   passing, trust floor met (operationally-associated or better)
✓?  passing, but only signers with no known context
✗   failing (any trust level — a failure is a prompt to look, not a verdict)
…   running    ~ stale    -   none
```

`cancelled` is not a pass: nothing ran to completion, so it renders `✗`.
`neutral` and `skipped` are passes, as in the rollup — the glyph asks the same
green predicate `--require-ci-trust` does, so `✓` is never a merge the gate
would refuse for its conclusion. One footer line
explains `?` and points at `ngit pr view`; column and footer appear only when
a listed PR has CI. No network beyond the shared fetch; no NIP-05 lookups
from the list path.

### `ngit pr merge <id>` / `ngit merge <id>`

The top-level spelling is a compatibility alias for the canonical command in
the `pr` namespace. With `--require-ci-trust`, both print the Checks summary and
apply the gate above.
Without the flag they perform the local merge without fetching or projecting
CI. Neither spelling publishes an applied-status event; the later Git push
publishes repository state and proposal status together.

### JSON shape (all surfaces)

Event ids are bech32 `nevent`s and pubkeys are bech32 `npub`s, as on every
other ngit JSON surface. Commit ids stay hex: they are Git object ids.

```jsonc
"ci": {
  "state": "running|concluded|stale|none",
  "conclusion": "success|failure|...",        // when concluded
  "revision_matched": true,
  "coverage": "complete|partial",
  "runs": [{
    "workflow": ".ngit/act/workflows/ci.yml",
    "state": "running|concluded|stale",       // this run, not the rollup
    "conclusion": "success",
    "attempt_of": 1,
    "run_id": "<workflow-run-id>",
    "commit": "<commit-id>",
    "coordinator": "<npub>",
    "classification": "maintainer-directed",
    "evidence": [{ "kind": "maintainer-request", "classification": "...",
                    "summary": "...", "authors": ["<npub>"], "scope": "run" }],
    "integrity": { "commit_present": true, "workflow_hash_matches": true },
    "jobs": [{ "job": "build", "conclusion": "success",
               "provider": "<npub>", "classification": "..." }]
  }],
  // Only where the surface groups by revision (`pr view`): the runs of
  // superseded revisions, each a run object plus the `nevent` that supplied
  // it. Never counted in `state`, `conclusion` or the trust rollup.
  "outdated": [{ "revision": "<nevent>", "workflow": "...", "...": "..." }],
  // Only when an event was skipped: its id, kind and the shape rule it broke.
  "skipped": ["skipped kind-9842 event <id>: ..."]
}
```

`ngit pr view` embeds that object as `ci` in its own document. `ngit pr list`
does not — one row is a cell, not a report — and carries per-row fields
instead:

```jsonc
"ci": { "state": "concluded", "conclusion": "success",
        "classification": "maintainer-directed", "trust_floor_met": true,
        "coverage": "partial", "revision_matched": true }
```

A row with no current run reports `state: "none"` with `conclusion`,
`classification` and `coverage` all `null`: no signer to classify, nothing
left unchecked.

`ngit ci status` wraps that object in the document every ngit command emits —
`command_status`, `entity: "ci"`, and a `target` describing what was resolved:

```jsonc
"target": { "kind": "pr", "pr": "<nevent>", "anchor": "<nevent>",
            "revision": "<nevent>" }
"target": { "kind": "commit", "commit": "<commit-id>",
            "commit_ish": "HEAD", "queried": ["<commit-id>", "<tag-object-id>"] }
```

`anchor` is the kind-1618 event CI references in `E`, which differs from `pr`
only for a patch thread later upgraded to a PR. A refused
`--require-ci-trust` sets `command_status: "error"` and adds `error`, keeping
the `ci` object that explains the refusal.

Either merge spelling embeds the same object when a CI trust gate was
requested. `ci_warning` remains `null`, and there is no `event` because the
local merge publishes nothing:

```jsonc
{ "command_status": "ok", "action": "merged", "entity": "pr", "id": "<nevent>",
  "ci": { "...": "..." }, "ci_warning": null }
```

Without a requested gate, a successful command retains its stable command and
PR identity fields while omitting `ci`, `ci_warning`, and `event`:

```jsonc
{ "command_status": "ok", "action": "merged", "entity": "pr", "id": "<nevent>" }
```

A merge handed back for manual conflict resolution has the same shape with
`action: "conflicted"`; it exits successfully because the merge remains in
progress for the user to complete.

A refusal is `command_status: "error"`, `action: "refused"`, an `error`, no `event`,
and `ci_warning: null`.

Integration tests assert on this JSON and exit codes, never on table text.

## Work packages

Each WP is one reviewable unit with its own tests. Build in order; WP2+ depend
on WP1.

- **WP1 — `src/lib/ci/` core** (`kinds`, `events`, `controls`, `trust`)
  *(done)*: parsing, run grouping, control reduction, classification,
  rollups, temporal rules. Pure unit tests ported from the semantics above
  (total-order tie-break, retroactivity, stop scoping, weakest rollup,
  progress expiry).
- **WP2 — provenance + domain + resolve tiers** *(done)*: quote validation,
  NIP-05 domain ladder with TTL cache, cache/full tiers, coverage states. Unit
  tests for the domain ladder (label boundaries, ports, trailing dots),
  coverage, and the identity-lookup scope and bounds (an unauthorized
  control's coordinator is never resolved; the address count is capped with
  repository roots first; the step stops at its deadline, measured on the
  paused tokio test clock so no wall-clock time passes).
- **WP3 — `ngit ci status`** *(done)*: target resolution (order above),
  fetch-filter extension in `get_fetch_filters`, human + JSON output,
  integrity check, `--require-ci-trust`, `--offline`. Integration tests
  (`tests/ci_status.rs`, fixtures from `test_harness::ci`): JSON and exit
  codes for commit-ish, `#prefix`, nevent, and full-hex targets, including
  the ambiguity error.
- **WP4 — `pr view` Checks section + `pr list` CI column** *(done)*: the
  shared projection in `src/bin/ngit/ci_projection.rs`, full-tier Checks with
  outdated grouping and per-job delegation labels, a cache-tier column with
  one footer line. Integration tests (`tests/pr_view_checks.rs`,
  `tests/pr_list_ci.rs`): the current/outdated split, a delegated job and a
  signer with no context, the cache tier under `--offline`, one signer
  classified identically by `pr view` and `ci status`, and a row per glyph
  state.
- **WP5 — merge gating** *(done)*: the Checks summary before the merge and
  `--require-ci-trust` refusing before anything is changed. Integration tests
  (`tests/pr_merge_ci.rs`): the blocked matrix (failing, no known context,
  stale, a stale workflow beside a successful one, no CI) each with the
  unflagged merge as a control and the refusal keeping the runs that explain
  it; the allowed matrix (control-history
  coverage and a validated manual trigger, each isolated from the other's
  route, plus the cache tier under `--offline`); and unflagged merges that do
  not project CI. A `neutral` and a `skipped` conclusion are exercised on
  every surface that reads the green predicate — `ci status`, `pr list`,
  `pr view` and merge — asserting the one verdict.
- **WP6 — maintainer controls** *(done)*: `ngit ci request|stop|trigger`
  publishing 9843/9844/9840 (trigger computes `w` hash from the local blob and
  peel-verifies `c` tags). These create the Level 1 evidence WP1 consumes.
  Integration tests (`tests/ci_control.rs`): the exact published shape of each
  kind, round-tripped through `ci::kinds`; the annotated-tag `c` order; a
  workflow absent at the commit publishing nothing; the non-maintainer
  warning; and both Level-1 loops end to end through ngit's own published
  event — a Service Request covering a later run through the control history,
  and a run quoting a Manual Trigger through validated provenance.

Deferred beyond WP6: seen-in-your-network evidence (Level 3) for logged-in
users, secrets provisioning (29846), NIP-11 strengthening, courtesy CI lines in
`pr checkout`/`pr apply`, and PR-context Manual Triggers (see the WP6
decisions).

Also deferred at WP6 — consolidating the parallel implementations of signer
collection, commit resolution and check rendering, each of which existed in
more than one place and could drift — and since done in this PR as the
behaviour-preserving refactor it was kept out of the CI work packages to be:
every signer set is built by `ci::resolve::collect_signers`, whose
`ControlScope` states the maintainer-authored NIP-05 bounding rule once; the
CI `c`-value rule lives in the `ci_commit` module with explicit strict
(publisher) and tolerant (consumer) modes; and the check lines every surface
shares are printed by `CiReport::print_result_lines`. The typed rust-nostr
replacements (`Nip05Address`, `Tag::coordinate`, `Kind::GitRepoAnnouncement`)
had already been made.

## Test-harness constraints (mandatory)

Per `docs/architecture/test-harness.md` and `AGENTS.md`: no `#[serial]`, no
PTY/interactive prompts (drive via flags), no `std::env::set_var`, no exact
stdout assertions (assert on JSON output, exit codes, and events on relays),
push via `Repo::nostr_push`, and no wall-clock sleeps — CI fixture events are
published directly to the harness relay, so results are queryable immediately.