Repository navigation
Expand file tree
/
Copy patharchitecture.html
More file actions
598 lines (530 loc) · 46.4 KB
/
Copy patharchitecture.html
File metadata and controls
598 lines (530 loc) · 46.4 KB
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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SHIP Architecture</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Public+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f2f4f2; --surface: #ffffff; --ink: #14181a; --ink-soft: #52605c;
--border: #dde3e0; --border-strong: #c7d0cc;
--agent: #0d6e5c; --agent-tint: #e6f2ef;
--infra: #3f5568; --infra-tint: #e7ecf0;
--data: #a15c1f; --data-tint: #f7ecdf;
--human: #b5432f; --human-tint: #fbeae6;
--muted-stroke: #b7c0bc; --muted-text: #8b968f;
--shadow: 0 1px 2px rgba(20,24,26,0.04), 0 6px 16px rgba(20,24,26,0.05);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #12181a; --surface: #1a2225; --ink: #eef2ef; --ink-soft: #a9b6b1;
--border: #2c3a37; --border-strong: #3c4d49;
--agent: #4fd9b8; --agent-tint: #16332c;
--infra: #9fb3c8; --infra-tint: #202b34;
--data: #e0a052; --data-tint: #3a2a15;
--human: #e2735c; --human-tint: #3a2019;
--muted-stroke: #3c4a46; --muted-text: #5f6b67;
--shadow: 0 1px 2px rgba(0,0,0,0.35), 0 8px 20px rgba(0,0,0,0.35);
}
}
:root[data-theme="dark"] {
--bg: #12181a; --surface: #1a2225; --ink: #eef2ef; --ink-soft: #a9b6b1;
--border: #2c3a37; --border-strong: #3c4d49;
--agent: #4fd9b8; --agent-tint: #16332c;
--infra: #9fb3c8; --infra-tint: #202b34;
--data: #e0a052; --data-tint: #3a2a15;
--human: #e2735c; --human-tint: #3a2019;
--muted-stroke: #3c4a46; --muted-text: #5f6b67;
--shadow: 0 1px 2px rgba(0,0,0,0.35), 0 8px 20px rgba(0,0,0,0.35);
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
background: var(--bg); color: var(--ink);
font-family: "Public Sans", -apple-system, "Segoe UI", sans-serif;
line-height: 1.5; padding: 48px 20px 90px;
}
.mono { font-family: "JetBrains Mono", monospace; }
.page { max-width: 1180px; margin: 0 auto; }
header { margin-bottom: 24px; }
.eyebrow {
font-family: "JetBrains Mono", monospace; font-size: 12px; letter-spacing: 0.09em;
text-transform: uppercase; color: var(--agent); font-weight: 600; margin-bottom: 10px;
}
h1 {
font-family: "Fraunces", Georgia, serif; font-size: 32px; font-weight: 600;
margin: 0 0 10px; text-wrap: balance; letter-spacing: -0.01em;
}
h2 {
font-family: "Fraunces", Georgia, serif; font-size: 22px; font-weight: 600;
margin: 0 0 6px; letter-spacing: -0.01em;
}
.dek { color: var(--ink-soft); font-size: 15px; max-width: 78ch; }
.legend {
display: flex; flex-wrap: wrap; gap: 8px 18px; margin: 20px 0 20px;
padding: 14px 18px; background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; box-shadow: var(--shadow); font-size: 12.5px;
}
.legend-item { display: flex; align-items: center; gap: 7px; color: var(--ink-soft); }
.legend-dot { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; }
.diagram-frame {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 18px; box-shadow: var(--shadow); overflow-x: auto;
}
.diagram-frame img { max-width: 100%; height: auto; display: block; margin: 0 auto; border-radius: 6px; }
figcaption { margin-top: 10px; font-size: 13px; color: var(--ink-soft); text-align: center; }
.walkthrough { margin-top: 52px; }
.walkthrough > .dek { margin-bottom: 28px; }
.zone {
border: 1px dashed var(--border-strong);
border-radius: 16px;
padding: 28px 24px 24px;
margin-bottom: 28px;
position: relative;
}
.zone-label {
position: absolute; top: -12px; left: 20px;
background: var(--bg); padding: 0 10px;
font-family: "JetBrains Mono", monospace; font-size: 12px; font-weight: 600;
letter-spacing: 0.05em; text-transform: uppercase; color: var(--ink-soft);
}
.zone-note { font-size: 13px; color: var(--ink-soft); margin: -8px 0 20px; max-width: 68ch; }
.trigger-row { display: flex; gap: 14px; flex-wrap: wrap; }
.trigger {
flex: 1; min-width: 200px; border-radius: 10px; padding: 14px 16px;
border: 1px solid var(--border); background: var(--surface);
}
.trigger.active { border-color: var(--agent); box-shadow: 0 0 0 1px var(--agent) inset; }
.trigger .label { font-weight: 600; font-size: 14px; margin-bottom: 3px; }
.trigger .desc { font-size: 12.5px; color: var(--ink-soft); }
.pipeline { display: flex; flex-direction: column; gap: 0; }
.step-row { display: flex; align-items: stretch; gap: 18px; }
.step-connector { display: flex; flex-direction: column; align-items: center; width: 22px; flex-shrink: 0; }
.step-connector .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--agent); margin-top: 22px; flex-shrink: 0; }
.step-connector .line { width: 2px; flex: 1; background: var(--border-strong); margin-top: 4px; }
.step-row:last-child .step-connector .line { display: none; }
.step-card {
flex: 1; background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 16px 18px; margin-bottom: 16px; box-shadow: var(--shadow); position: relative;
}
.step-card.tool { border-left: 4px solid var(--infra); }
.step-card.agent { border-left: 4px solid var(--agent); }
.step-card.human { border-left: 4px solid var(--human); }
.step-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.step-title { font-weight: 700; font-size: 15.5px; }
.step-kind {
font-family: "JetBrains Mono", monospace; font-size: 10.5px; letter-spacing: 0.04em;
text-transform: uppercase; font-weight: 600; padding: 2px 8px; border-radius: 5px; white-space: nowrap;
}
.step-kind.agent { background: var(--agent-tint); color: var(--agent); }
.step-kind.tool { background: var(--infra-tint); color: var(--infra); }
.step-kind.human { background: var(--human-tint); color: var(--human); }
.step-desc { font-size: 13.5px; color: var(--ink-soft); margin-top: 6px; max-width: 66ch; }
.lens-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
.lens-tag {
font-size: 10.5px; font-family: "JetBrains Mono", monospace; padding: 3px 8px; border-radius: 999px;
border: 1px solid var(--border-strong); color: var(--ink-soft); letter-spacing: 0.02em;
}
.branch-group { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-top: 10px; }
.branch-item { background: var(--infra-tint); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; font-size: 12px; color: var(--ink); }
.branch-item b { display: block; font-size: 12.5px; margin-bottom: 2px; }
.route-split { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 4px; }
.detector-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 10px; margin-top: 10px; }
.detector-item { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
.detector-item .id { font-family: "JetBrains Mono", monospace; font-size: 12px; font-weight: 700; color: var(--agent); }
.detector-item .what { font-size: 12.5px; margin: 3px 0; }
.detector-item .ground { font-size: 11px; color: var(--ink-soft); }
.infra-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
.infra-item { background: var(--infra-tint); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
.infra-item .name { font-family: "JetBrains Mono", monospace; font-size: 12.5px; font-weight: 600; color: var(--infra); }
.infra-item .role { font-size: 11.5px; color: var(--ink-soft); margin-top: 2px; }
footer { margin-top: 24px; font-size: 12.5px; color: var(--ink-soft); font-family: "JetBrains Mono", monospace; }
@media (max-width: 640px) {
.branch-group { grid-template-columns: 1fr; }
.route-split { grid-template-columns: 1fr; }
.detector-grid { grid-template-columns: 1fr; }
h1 { font-size: 26px; }
}
</style>
</head>
<body>
<div class="page">
<header>
<div class="eyebrow">System Architecture</div>
<h1>SHIP</h1>
<div class="dek">The full system in one picture. Walkthrough below.</div>
</header>
<div class="legend">
<div class="legend-item"><span class="legend-dot" style="background:var(--agent)"></span>Agent step</div>
<div class="legend-item"><span class="legend-dot" style="background:var(--infra)"></span>Service / infra</div>
<div class="legend-item"><span class="legend-dot" style="background:var(--data)"></span>Output / data artifact</div>
<div class="legend-item"><span class="legend-dot" style="background:var(--human)"></span>Human-in-the-loop</div>
<div class="legend-item"><span style="border-bottom:2px dashed var(--ink-soft);width:16px;display:inline-block"></span>Retry / failure path</div>
</div>
<figure>
<div class="diagram-frame">
<svg viewBox="0 0 1260 720" role="img" aria-label="Two deployables. ship-webhook, the always-on Lambda behind the GitHub webhook, does only fast work: verify the HMAC signature, check the repo against the connected-repositories table, run Screener's whole-diff precheck, and if anything matches, split the diff into fragments, screen each one individually, and send one SQS message per flagged fragment. If nothing matches, it replies pass immediately at no further cost. ship-fragment-processor, a second Lambda triggered by that queue and scaled independently with a concurrency cap, does the real work one fragment at a time: Detector reasons over the fragment grounded in retrieved regulation text, Triage routes the verdict against a per-category risk threshold, and anything at or above it is stored as a frozen alert in DynamoDB and surfaced on the Gate dashboard, where a human approves or rejects it as a one-way, guarded decision. A fragment that fails outright is retried automatically by SQS and moved to a dead-letter queue after repeated failure; a fragment that is merely slow, or that trips AWS's own request-rate limit, backs off and retries on its own without blocking any other fragment." xmlns="http://www.w3.org/2000/svg" font-family="'Public Sans', sans-serif" fill="none" style="max-width:100%;height:auto">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="currentColor"></path>
</marker>
</defs>
<g stroke="currentColor" fill="none">
<rect x="10" y="10" width="1240" height="310" rx="14" stroke-dasharray="4 4" opacity="0.5"></rect>
<rect x="10" y="340" width="1240" height="360" rx="14" stroke-dasharray="4 4" opacity="0.5"></rect>
</g>
<text x="26" y="30" font-size="11.5" letter-spacing="0.04em" fill="currentColor" opacity="0.65">SHIP-WEBHOOK · ALWAYS-ON LAMBDA, FAST-ACK ONLY</text>
<text x="26" y="360" font-size="11.5" letter-spacing="0.04em" fill="currentColor" opacity="0.65">SHIP-FRAGMENT-PROCESSOR · INDEPENDENTLY-SCALING LAMBDA, ONE JOB PER FRAGMENT</text>
<!-- Lane A: fast-ack webhook -->
<g font-size="12.5">
<rect x="30" y="110" width="140" height="60" rx="8" fill="var(--infra-tint)" stroke="var(--infra)"></rect>
<text x="100" y="134" text-anchor="middle" fill="var(--infra)" font-weight="600">GitHub PR</text>
<text x="100" y="150" text-anchor="middle" fill="currentColor" font-size="10.5">opened / updated</text>
<line x1="170" y1="140" x2="202" y2="140" stroke="currentColor" marker-end="url(#arrow)"></line>
<rect x="205" y="110" width="170" height="60" rx="8" fill="var(--infra-tint)" stroke="var(--infra)"></rect>
<text x="290" y="134" text-anchor="middle" fill="var(--infra)" font-weight="600" font-size="12">Verify + allowlist</text>
<text x="290" y="150" text-anchor="middle" fill="currentColor" font-size="10">HMAC signature, repo check</text>
<line x1="375" y1="140" x2="407" y2="140" stroke="currentColor" marker-end="url(#arrow)"></line>
<rect x="410" y="110" width="160" height="60" rx="8" fill="var(--infra-tint)" stroke="var(--infra)"></rect>
<text x="490" y="134" text-anchor="middle" fill="var(--infra)" font-weight="600" font-size="12">Screener</text>
<text x="490" y="150" text-anchor="middle" fill="currentColor" font-size="10">whole-diff precheck</text>
<line x1="570" y1="140" x2="600" y2="140" stroke="currentColor" marker-end="url(#arrow)"></line>
<polygon points="645,105 690,140 645,175 600,140" fill="var(--infra-tint)" stroke="var(--infra)"></polygon>
<text x="645" y="136" text-anchor="middle" fill="var(--infra)" font-size="10" font-weight="600">any</text>
<text x="645" y="148" text-anchor="middle" fill="var(--infra)" font-size="10" font-weight="600">match?</text>
<path d="M 630 112 L 600 40" stroke="currentColor" stroke-dasharray="3 3" marker-end="url(#arrow)"></path>
<text x="560" y="35" text-anchor="middle" font-size="10" fill="currentColor">no</text>
<rect x="440" y="15" width="160" height="50" rx="8" fill="var(--data-tint)" stroke="var(--data)"></rect>
<text x="520" y="36" text-anchor="middle" fill="var(--data)" font-weight="600" font-size="11.5">Pass</text>
<text x="520" y="50" text-anchor="middle" font-size="10" fill="currentColor">no model call spent</text>
<line x1="690" y1="140" x2="722" y2="140" stroke="currentColor" marker-end="url(#arrow)"></line>
<text x="700" y="128" font-size="10" fill="currentColor">yes</text>
<rect x="725" y="110" width="180" height="60" rx="8" fill="var(--infra-tint)" stroke="var(--infra)"></rect>
<text x="815" y="130" text-anchor="middle" fill="var(--infra)" font-weight="600" font-size="12">Split + per-fragment</text>
<text x="815" y="145" text-anchor="middle" fill="var(--infra)" font-weight="600" font-size="12">Screener scan</text>
<text x="815" y="160" text-anchor="middle" fill="currentColor" font-size="9.5">one file/function at a time</text>
<line x1="905" y1="140" x2="937" y2="140" stroke="currentColor" marker-end="url(#arrow)"></line>
<rect x="940" y="110" width="180" height="60" rx="8" fill="var(--infra-tint)" stroke="var(--infra)"></rect>
<text x="1030" y="130" text-anchor="middle" fill="var(--infra)" font-weight="600" font-size="12">Enqueue</text>
<text x="1030" y="146" text-anchor="middle" fill="currentColor" font-size="10">1 SQS message per</text>
<text x="1030" y="159" text-anchor="middle" fill="currentColor" font-size="10">flagged fragment</text>
<path d="M 1030 170 L 1030 300 L 105 300 L 105 420" stroke="currentColor" marker-end="url(#arrow)"></path>
</g>
<!-- Lane B: fragment processor -->
<g font-size="12.5">
<rect x="30" y="420" width="150" height="60" rx="8" fill="var(--infra-tint)" stroke="var(--infra)"></rect>
<text x="105" y="444" text-anchor="middle" fill="var(--infra)" font-weight="600" font-size="12">SQS</text>
<text x="105" y="460" text-anchor="middle" fill="currentColor" font-size="10">fragment queue</text>
<line x1="180" y1="450" x2="212" y2="450" stroke="currentColor" marker-end="url(#arrow)"></line>
<rect x="215" y="420" width="190" height="60" rx="8" fill="var(--agent-tint)" stroke="var(--agent)"></rect>
<text x="310" y="444" text-anchor="middle" fill="var(--agent)" font-weight="600" font-size="12">Detector</text>
<text x="310" y="460" text-anchor="middle" fill="currentColor" font-size="10">RAG-grounded judgment</text>
<path d="M 265 480 L 265 505" stroke="currentColor" marker-end="url(#arrow)"></path>
<path d="M 355 505 L 355 480" stroke="currentColor" marker-end="url(#arrow)"></path>
<rect x="235" y="508" width="240" height="48" rx="8" fill="var(--data-tint)" stroke="var(--data)"></rect>
<text x="355" y="528" text-anchor="middle" fill="var(--data)" font-weight="600" font-size="11">Regulation corpus</text>
<text x="355" y="544" text-anchor="middle" font-size="9.5" fill="currentColor">GDPR · EU AI Act · OWASP, sourced verbatim</text>
<path d="M 250 480 C 170 540, 170 600, 250 605 C 290 608, 330 570, 335 480" stroke="currentColor" stroke-dasharray="3 3" marker-end="url(#arrow)"></path>
<text x="150" y="600" font-size="10" fill="currentColor">throttled →</text>
<text x="150" y="612" font-size="10" fill="currentColor">adaptive retry</text>
<line x1="405" y1="450" x2="437" y2="450" stroke="currentColor" marker-end="url(#arrow)"></line>
<rect x="440" y="420" width="160" height="60" rx="8" fill="var(--infra-tint)" stroke="var(--infra)"></rect>
<text x="520" y="444" text-anchor="middle" fill="var(--infra)" font-weight="600" font-size="12">Triage</text>
<text x="520" y="460" text-anchor="middle" fill="currentColor" font-size="10">per-category threshold</text>
<line x1="600" y1="450" x2="630" y2="450" stroke="currentColor" marker-end="url(#arrow)"></line>
<polygon points="675,415 720,450 675,485 630,450" fill="var(--infra-tint)" stroke="var(--infra)"></polygon>
<text x="675" y="446" text-anchor="middle" fill="var(--infra)" font-size="9.5" font-weight="600">≥ threshold?</text>
<path d="M 705 425 L 760 400" stroke="currentColor" marker-end="url(#arrow)"></path>
<text x="740" y="392" text-anchor="middle" font-size="10" fill="currentColor">no</text>
<rect x="765" y="370" width="170" height="55" rx="8" fill="var(--data-tint)" stroke="var(--data)"></rect>
<text x="850" y="392" text-anchor="middle" fill="var(--data)" font-weight="600" font-size="11.5">Log, continue</text>
<text x="850" y="407" text-anchor="middle" font-size="10" fill="currentColor">no alert created</text>
<path d="M 675 485 L 675 555" stroke="currentColor" marker-end="url(#arrow)"></path>
<text x="685" y="520" font-size="10" fill="currentColor">yes</text>
<path d="M 380 480 C 360 560, 310 600, 260 650" stroke="currentColor" stroke-dasharray="3 3" marker-end="url(#arrow)"></path>
<text x="300" y="660" text-anchor="middle" font-size="10" fill="currentColor">fails repeatedly</text>
<rect x="30" y="655" width="230" height="45" rx="8" fill="var(--surface)" stroke="var(--muted-stroke)" stroke-dasharray="3 3"></rect>
<text x="145" y="672" text-anchor="middle" fill="var(--muted-text)" font-weight="600" font-size="11">Dead-letter queue</text>
<text x="145" y="687" text-anchor="middle" font-size="9.5" fill="var(--muted-text)">visible for manual triage</text>
<rect x="590" y="560" width="180" height="60" rx="8" fill="var(--data-tint)" stroke="var(--data)"></rect>
<text x="680" y="584" text-anchor="middle" fill="var(--data)" font-weight="600" font-size="12">Freeze: store alert</text>
<text x="680" y="600" text-anchor="middle" font-size="10" fill="currentColor">DynamoDB, idempotent write</text>
<line x1="770" y1="590" x2="802" y2="590" stroke="currentColor" marker-end="url(#arrow)"></line>
<rect x="805" y="560" width="190" height="60" rx="8" fill="var(--human-tint)" stroke="var(--human)"></rect>
<text x="900" y="584" text-anchor="middle" fill="var(--human)" font-weight="600" font-size="12">Gate dashboard</text>
<text x="900" y="600" text-anchor="middle" font-size="10" fill="currentColor">token-gated review console</text>
<line x1="995" y1="590" x2="1027" y2="590" stroke="currentColor" marker-end="url(#arrow)"></line>
<polygon points="1075,555 1120,590 1075,625 1030,590" fill="var(--human-tint)" stroke="var(--human)"></polygon>
<text x="1075" y="586" text-anchor="middle" fill="var(--human)" font-size="9" font-weight="600">approve /</text>
<text x="1075" y="597" text-anchor="middle" fill="var(--human)" font-size="9" font-weight="600">reject?</text>
<rect x="1000" y="640" width="220" height="55" rx="8" fill="var(--data-tint)" stroke="var(--data)"></rect>
<text x="1110" y="662" text-anchor="middle" fill="var(--data)" font-weight="600" font-size="11.5">Resolved</text>
<text x="1110" y="678" text-anchor="middle" font-size="10" fill="currentColor">one-way, guarded</text>
<path d="M 1075 625 L 1110 638" stroke="currentColor" marker-end="url(#arrow)"></path>
</g>
</svg>
</div>
<figcaption>The two deployables and how they connect: the always-on webhook that only ever does cheap work, and the independently-scaling processor that does one flagged fragment's real review per invocation.</figcaption>
</figure>
<div class="walkthrough">
<h2>How it works</h2>
<div class="dek">The diagram above shows the mechanism; this section walks through why each piece exists.</div>
<div class="zone">
<div class="zone-label">Entry point</div>
<div class="trigger-row">
<div class="trigger active">
<div class="label">GitHub webhook: a pull request opens or updates</div>
<div class="desc">The only trigger. No polling, no scheduled scan — SHIP reacts to the same event GitHub already fires for every PR.</div>
</div>
</div>
</div>
<div class="zone">
<div class="zone-label">ship-webhook · fast-ack Lambda</div>
<div class="zone-note">Everything in this Lambda is cheap, local, and bounded in milliseconds — no model call happens here, so GitHub's webhook delivery never has to wait on one.</div>
<div class="pipeline">
<div class="step-row">
<div class="step-connector"><div class="dot"></div><div class="line"></div></div>
<div class="step-card tool">
<div class="step-head">
<span class="step-title">Verify + allowlist</span>
<span class="step-kind tool">tool</span>
</div>
<div class="step-desc">HMAC-SHA256 signature check against the configured webhook secret (fails closed if that secret is ever missing — there is no silent "allow unsigned" default), then the named repo against the connected-repositories table (one GetItem, point-lookup, fails closed if the table can't be read). A payload naming any repo not connected through Gate is rejected before it can spend this service's GitHub token or model budget.</div>
</div>
</div>
<div class="step-row">
<div class="step-connector"><div class="dot"></div><div class="line"></div></div>
<div class="step-card tool">
<div class="step-head">
<span class="step-title">Screener: whole-diff precheck</span>
<span class="step-kind tool">tool</span>
</div>
<div class="step-desc">Plain regex/AST pattern matching, zero cost, zero network calls. If nothing in the whole diff matches any trigger term, the PR passes here and nothing downstream ever runs.</div>
</div>
</div>
<div class="step-row">
<div class="step-connector"><div class="dot"></div><div class="line"></div></div>
<div class="step-card tool">
<div class="step-head">
<span class="step-title">Split + per-fragment scan</span>
<span class="step-kind tool">tool</span>
</div>
<div class="step-desc">The diff is split on file boundaries, then on top-level function/class boundaries within each file, so a PR touching several unrelated concerns doesn't get judged as one jumbled blob. Each fragment is screened individually — this is also where a fragment is isolated down to just the matched lines and their immediate context, keeping what gets queued small and focused.</div>
</div>
</div>
<div class="step-row">
<div class="step-connector"><div class="dot"></div></div>
<div class="step-card tool">
<div class="step-head">
<span class="step-title">Enqueue</span>
<span class="step-kind tool">tool</span>
</div>
<div class="step-desc">One SQS message per flagged fragment — not one message for the whole PR. This is the design choice the rest of this document explains: making each fragment its own unit of work is what everything downstream depends on.</div>
</div>
</div>
</div>
</div>
<div class="zone">
<div class="zone-label">Why fragments are dispatched independently</div>
<div class="zone-note">The design decision this whole second Lambda exists to implement.</div>
<p class="step-desc" style="max-width:none">
A simpler design — one Lambda invocation reviewing every flagged fragment
in a PR, one after another — works fine for a PR with one or two issues.
It stops working once a PR has enough flagged issues that reviewing all
of them, one at a time, takes longer than that single Lambda invocation
is allowed to run: AWS Lambda has a hard, non-negotiable ceiling on how
long one invocation may run, and nothing resets that clock partway
through. A live test against this exact failure mode confirmed it
directly — a PR with several genuinely different flagged issues had its
review silently cut short mid-way, with no error surfaced anywhere; the
issues reviewed before the cutoff were recorded correctly, the rest
simply never got a turn.
</p>
<p class="step-desc" style="max-width:none">
Making each fragment its own independent, queued job removes the shared
clock entirely: every fragment gets its own full time budget, and
fragments belonging to the same PR run concurrently rather than waiting
in line behind each other. A PR with several flagged issues now takes
about as long as its slowest single issue, not the sum of all of them.
</p>
</div>
<div class="zone">
<div class="zone-label">ship-fragment-processor · one job per fragment</div>
<div class="zone-note">Triggered by the queue above, scaled independently from the webhook Lambda, with a concurrency cap so parallel reviews stay within the account's real model request-rate limit rather than overwhelming it.</div>
<div class="pipeline">
<div class="step-row">
<div class="step-connector"><div class="dot"></div><div class="line"></div></div>
<div class="step-card agent">
<div class="step-head">
<span class="step-title">Detector: RAG-grounded judgment</span>
<span class="step-kind agent">agent</span>
</div>
<div class="step-desc">A Strands Agent that must call its retrieval tool against the sourced regulation corpus before judging anything — every citation it produces traces back to retrieved text, never an unaided claim. Returns a structured verdict: matched or not, which category, a 1–10 risk score, a plain-English explanation, the exact citation, and a draft fix.</div>
<div class="lens-tags"><span class="lens-tag">RAG-grounded, never unaided</span><span class="lens-tag">runs on Bedrock AgentCore Runtime</span></div>
</div>
</div>
<div class="step-row">
<div class="step-connector"><div class="dot"></div><div class="line"></div></div>
<div class="step-card tool">
<div class="step-head">
<span class="step-title">Triage: per-category threshold</span>
<span class="step-kind tool">tool</span>
</div>
<div class="step-desc">Pure conditional routing, no judgment of its own. The freeze threshold is set per category rather than one shared number: a category where a confirmed violation is structurally irreversible or breaks a required safety guarantee (raw data reaching an external service, an automated decision with zero human checkpoint) freezes at a lower score than a category that's more a matter of degree.</div>
</div>
</div>
<div class="step-row">
<div class="step-connector"><div class="dot"></div></div>
<div class="step-card human">
<div class="step-head">
<span class="step-title">Gate: where most alerts resolve</span>
<span class="step-kind human">human</span>
</div>
<div class="step-desc">A token-gated dashboard (deliberately a separate credential from the webhook's own — compromising one can't silently disable the other) listing every frozen alert with its file, category, risk score, plain-English summary, citation, and suggested patch. The token is only ever pasted once: it establishes a signed-in session cookie, so every page after that is a plain link, not a secret sitting in a URL. Approve/Reject is a one-way, guarded transition: a duplicate webhook delivery or a retried job cannot silently reopen or overwrite a decision a human already made. The same decision is posted back to the PR as a comment and folded into a recomputed <code class="mono">ship/compliance</code> commit status — resolving the last blocking finding is what turns the check green. Gate also has a history view (every past disposition, with the reason a human gave) and a connected-repos view (connecting a repository is a form submission here, not a redeploy). A spoken confirmation through Relay can resolve one too, under a narrower condition — see below.</div>
</div>
</div>
</div>
</div>
<div class="zone">
<div class="zone-label">Failure handling</div>
<div class="zone-note">Two independent mechanisms, for two different failure shapes.</div>
<div class="route-split">
<div class="branch-item" style="background:var(--data-tint)"><b>Slow, or rate-limited</b>A fragment that hits the model provider's own request-rate limit backs off and retries automatically (adaptive retry, configured once at the shared client rather than a hand-guessed delay) — it does not fail, it just takes longer, and it never blocks any other fragment's own review.<div class="lens-tags"><span class="lens-tag">adaptive backoff</span></div></div>
<div class="branch-item" style="background:var(--data-tint)"><b>Genuinely broken</b>A fragment that fails outright (a malformed message, an unrecoverable error) is retried a bounded number of times by SQS itself, then moved to a dead-letter queue instead of being retried forever or silently dropped — visible for manual triage, not lost.<div class="lens-tags"><span class="lens-tag">dead-letter queue</span></div></div>
</div>
<p class="step-desc" style="max-width:none;margin-top:14px">Every alert write is idempotent regardless of which path a fragment took to get there: the alert's own ID is derived from the specific violation (repo, PR, file, category, and the fragment text itself), not a random ID. A retried or redelivered fragment that reaches the same real violation overwrites the same row rather than creating a duplicate — and a write can never re-freeze or overwrite an alert a human has already resolved.</p>
</div>
<div class="zone">
<div class="zone-label">Detectors</div>
<div class="zone-note">Deliberately scoped to what a single PR diff can actually prove — no infrastructure state, no cross-file history, nothing that would require watching a repo over time. Each grounded in real, sourced text, not a model's general knowledge of the regulation.</div>
<div class="detector-grid">
<div class="detector-item"><span class="id">PIIE-001</span><div class="what">Raw, direct PII (SSN, account number, full profile) reaching an external sink with no masking</div><div class="ground">GDPR Article 32</div></div>
<div class="detector-item"><span class="id">PIIE-002</span><div class="what">The same kind of raw PII, written to a log stream</div><div class="ground">GDPR Article 32</div></div>
<div class="detector-item"><span class="id">PIIE-003</span><div class="what">Raw PII stored in a cache/session store with no encryption</div><div class="ground">GDPR Article 32</div></div>
<div class="detector-item"><span class="id">TLGP-002</span><div class="what">An AI-produced decision applied as final with no human checkpoint anywhere in the fragment</div><div class="ground">EU AI Act Article 14</div></div>
<div class="detector-item"><span class="id">ALBP-001</span><div class="what">A protected characteristic (or a clear proxy) directly driving a scoring calculation</div><div class="ground">EU AI Act Art. 10 + Annex III §5(b)</div></div>
<div class="detector-item"><span class="id">TLGP-001</span><div class="what">A dangerous capability (shell exec, unscoped DB write) granted to an AI agent with no gate</div><div class="ground">OWASP LLM06:2025</div></div>
</div>
<p class="step-desc" style="max-width:none;margin-top:14px">Screener's job is to notice a <em>candidate</em> — a keyword, an import, a call shape — cheaply and without judgment. Detector's job is to decide whether it's real. The two are graded together: every detector above is verified against genuine violations <em>and</em> deliberate look-alikes engineered to trip a naive pattern match (a non-agent backup job that happens to call a dangerous function, a profile field that's merely displayed rather than scored, PII that's already hashed before use). A detector that flags the look-alike as a violation is not considered working, no matter how well it catches the real case.</p>
<p class="step-desc" style="max-width:none">The pipeline underneath is mechanically generic enough to flag other kinds of risk too, but staying scoped to AI-specific patterns is deliberate: it's the whole differentiation from a general-purpose static-analysis tool that has never heard of an LLM call. Widening scope to catch things like SQL injection would dilute that, not strengthen it.</p>
</div>
<div class="zone">
<div class="zone-label">Relay · the MCP server behind the Alexa Skill</div>
<div class="zone-note">A completely separate Lambda from the review pipeline above. Relay carries no judgment of its own — Detector already reasoned, Triage already routed, Gate already recorded whatever a human decided. Relay only reads that and routes it to a surface.</div>
<div class="pipeline">
<div class="step-row">
<div class="step-connector"><div class="dot"></div><div class="line"></div></div>
<div class="step-card tool">
<div class="step-head">
<span class="step-title">release_status</span>
<span class="step-kind tool">tool, voice-safe</span>
</div>
<div class="step-desc">The only tool ever read aloud. Reads a precomputed summary (<code class="mono">ship-status</code>, one <code class="mono">GetItem</code>) rather than aggregating on request — Alexa+ allows roughly half a second round-trip, and aggregating over every alert on every ask would blow that budget as findings accumulate. Hard-capped to one sentence with no line breaks: a finding list physically cannot be spoken, enforced by the server, not a prompt asked to behave.</div>
</div>
</div>
<div class="step-row">
<div class="step-connector"><div class="dot"></div><div class="line"></div></div>
<div class="step-card tool">
<div class="step-head">
<span class="step-title">blocked_pull_requests / finding_detail</span>
<span class="step-kind tool">tool, screen-only</span>
</div>
<div class="step-desc">Citations, file paths, code, and every finding — rendered on whatever screen asked, never spoken. Calling either also records the alert as recently shown, the fact the approval gate below checks.</div>
</div>
</div>
<div class="step-row">
<div class="step-connector"><div class="dot"></div></div>
<div class="step-card tool">
<div class="step-head">
<span class="step-title">display_on</span>
<span class="step-kind tool">tool, action</span>
</div>
<div class="step-desc">Routes the current findings to a named surface. A real push over that device's open WebSocket connection, reported honestly when the named device isn't connected rather than pretending it worked — see Cross-device push below.</div>
</div>
</div>
</div>
<p class="step-desc" style="max-width:none;margin-top:14px">The ASGI app is built fresh inside the handler on every single Lambda invocation, not once at import time — a genuine SDK/Lambda incompatibility, not a style choice. The MCP Python SDK's Streamable HTTP session manager can enter its own lifespan exactly once per instance; a warm container reusing a module-level app object crashes the <em>second</em> request against it with "SessionManager .run() can only be called once per instance," independent of stateful/stateless mode. Confirmed by direct reproduction before relying on the fix, not assumed. One consequence worth stating plainly: this Lambda has no session continuity of its own to build on — every invocation starts a session manager that has never seen a client before. That fact is what shaped the approval design below.</p>
</div>
<div class="zone">
<div class="zone-label">Voice can confirm a decision. It can never make one blind.</div>
<div class="zone-note">request_risk_acceptance — revised mid-build after a real architectural correction, not the original design.</div>
<p class="step-desc" style="max-width:none">The first version was simpler and wrong: no tool would ever accept a risk by voice, full stop. The actual failure SHIP exists to catch isn't "a decision happened over voice," it's "a decision happened without a human genuinely engaging with what they were deciding." A person who asked to see the findings, had them rendered on a real screen, and then says "override it, here's why" has done exactly what human oversight requires — refusing that and forcing a trip to a web form adds friction without adding safety. What still must never work is "ignore it, go ahead" with no review at all, the actual TLGP-002 shape this project polices in other people's code.</p>
<div class="route-split" style="margin-top:14px">
<div class="branch-item" style="background:var(--human-tint)"><b>Reviewed, then confirmed</b>blocked_pull_requests or finding_detail was called for this exact alert_id within the last ten minutes, and a reason was given. Performed for real: resolve_alert() writes the resolution to ship-alerts, same durability Gate's own resolve gives it.<div class="lens-tags"><span class="lens-tag">performed = true</span></div></div>
<div class="branch-item" style="background:var(--infra-tint)"><b>Blind, or no reason</b>Either condition missing: refused, and this tool never performs anything — returns where the decision must be made instead. The review gate is reported first, since that's the one the "ignore it, go ahead" case has to fail on.<div class="lens-tags"><span class="lens-tag">performed = false</span></div></div>
</div>
<p class="step-desc" style="max-width:none;margin-top:14px">The gate is keyed on the alert and a time window, not on an MCP session — a real correction, not the original design. The first version keyed on <code class="mono">Mcp-Session-Id</code>, reasoning it was a stable per-conversation identifier. Live-tested against the actually-deployed system, not assumed correct from a local check: it never arrived, because a session manager built fresh on every invocation has no process for that id to have continuity with. <code class="mono">ship-recent-reviews</code> (alert_id → when last shown, a ten-minute window) is the honest signal that's actually available in this architecture, in place of a session identity that structurally cannot exist here.</p>
</div>
<div class="zone">
<div class="zone-label">Cross-device push</div>
<div class="zone-note">Push, not polling — a deliberate choice, not the default.</div>
<div class="pipeline">
<div class="step-row">
<div class="step-connector"><div class="dot"></div><div class="line"></div></div>
<div class="step-card tool">
<div class="step-head">
<span class="step-title">API Gateway WebSocket API</span>
<span class="step-kind tool">infra</span>
</div>
<div class="step-desc">connect / disconnect / register routes, handled by a small dedicated Lambda (device_gateway_handler.py) kept deliberately separate from Relay so Relay's own IAM role stays scoped to exactly what answering a question requires.</div>
</div>
</div>
<div class="step-row">
<div class="step-connector"><div class="dot"></div></div>
<div class="step-card tool">
<div class="step-head">
<span class="step-title">ship-device-connections</span>
<span class="step-kind tool">infra</span>
</div>
<div class="step-desc">Which device is reachable under which name. A display page (any browser, a TV's, an iPad's, a smart fridge's) opens ship-display.html, names itself once, and sits idle with no polling until Relay pushes a finding to it by name.</div>
</div>
</div>
</div>
<p class="step-desc" style="max-width:none;margin-top:14px">A genuine compliance event happens on the order of weeks, not seconds. A display polling every few seconds to catch that would spend nearly all its traffic finding nothing changed; an idle WebSocket connection costs nothing until there's actually something to say. A stale connection self-heals rather than erroring: a <code class="mono">GoneException</code> from a disconnected socket removes that row instead of raising.</p>
</div>
<div class="zone">
<div class="zone-label">Self-improvement loop</div>
<div class="zone-note">scripts/pattern_miner.py — periodic, off Detector's hot path, same shape as Healing Loop.</div>
<p class="step-desc" style="max-width:none">The first design considered was few-shot: feed Detector the raw text of recently dismissed findings directly. Rejected before building — after a month or two of dismissals there are too many raw examples to fit in a prompt, and picking a recent subset is arbitrary. Instead, a batch job extracts a small, bounded set of <em>generalized</em> patterns from accumulated dismissals, merging new evidence into existing patterns rather than appending, so the set stays small regardless of how much history exists.</p>
<div class="route-split" style="margin-top:14px">
<div class="branch-item" style="background:var(--agent-tint)"><b>No human approval gate per pattern</b>Considered and rejected directly: a pattern is often an abstraction tied to field-level data associations, not a business rule a human can confidently judge in isolation. Asking for sign-off on every one is real, recurring toil for no real safety gain.<div class="lens-tags"><span class="lens-tag">automatic, not manual</span></div></div>
<div class="branch-item" style="background:var(--agent-tint)"><b>Two automatic guards instead</b>MIN_SUPPORT — a pattern only becomes retrieval-eligible once independent dismissals confirm it, not one person's single call. Retrieved as context, never a rule — Detector's grounded, citation-based judgment still runs on every fragment regardless of what a pattern says.<div class="lens-tags"><span class="lens-tag">ship-learned-patterns</span></div></div>
</div>
</div>
<div class="zone">
<div class="zone-label">Operational visibility</div>
<div class="zone-note">repo_store.stuck — built from a real incident, not a hypothetical one.</div>
<p class="step-desc" style="max-width:none">Live-caught: an SQS event source mapping was disabled by hand, and the webhook kept returning <code class="mono">200 "accepted, queued for review"</code> for every delivery, silently reviewing nothing, for days. The failure that mattered wasn't a crash to catch — it was a system that looked completely healthy from every angle that was actually checked. ship-repos now tracks <code class="mono">last_event_at</code> alongside <code class="mono">last_reviewed_at</code>; a gap between the two past a 20-minute grace window (generous against the worst real Detector latency observed, ~440s on a cold AgentCore container) surfaces as a visible "stuck" badge on Gate's connected-repos view — the first time anyone looks, not after a monitoring system fires.</p>
</div>
<div class="zone">
<div class="zone-label">AWS infrastructure</div>
<div class="infra-grid">
<div class="infra-item"><div class="name">Lambda · ship-webhook</div><div class="role">Always-on, fast-ack only, no model calls</div></div>
<div class="infra-item"><div class="name">Lambda · ship-fragment-processor</div><div class="role">Independently-scaling, one flagged fragment per invocation</div></div>
<div class="infra-item"><div class="name">Lambda · ship-relay</div><div class="role">The MCP server, SnapStart-enabled, a fresh ASGI app per invocation</div></div>
<div class="infra-item"><div class="name">Lambda · ship-alexa-skill</div><div class="role">The Alexa Skill: turns what Alexa hears into calls to Relay over MCP</div></div>
<div class="infra-item"><div class="name">Lambda · device-gateway</div><div class="role">WebSocket connect/disconnect/register, kept separate from Relay's own IAM role</div></div>
<div class="infra-item"><div class="name">SQS</div><div class="role">Fragment queue + dead-letter queue, concurrency-capped consumer</div></div>
<div class="infra-item"><div class="name">API Gateway WebSocket API</div><div class="role">Real, server-initiated push to a named display device</div></div>
<div class="infra-item"><div class="name">Bedrock AgentCore Runtime</div><div class="role">Deployed Detector agent, callable remotely from either review-pipeline Lambda</div></div>
<div class="infra-item"><div class="name">Bedrock · Amazon Nova Lite</div><div class="role">Primary reasoning model — picked over Claude Haiku 4.5 on a measured benchmark: same accuracy, 20× the request quota</div></div>
<div class="infra-item"><div class="name">Bedrock Titan Embeddings</div><div class="role">Regulation-corpus retrieval, and the same mechanism for learned-pattern retrieval</div></div>
<div class="infra-item"><div class="name">DynamoDB · ship-alerts</div><div class="role">Idempotent alert storage, Gate's source of truth</div></div>
<div class="infra-item"><div class="name">DynamoDB · ship-repos</div><div class="role">Connected repositories, the webhook's allowlist, plus received-vs-reviewed health</div></div>
<div class="infra-item"><div class="name">DynamoDB · ship-status</div><div class="role">Precomputed release summary — Relay's voice path is one GetItem regardless of finding count</div></div>
<div class="infra-item"><div class="name">DynamoDB · ship-device-connections</div><div class="role">Which device is reachable under which name, for the push path</div></div>
<div class="infra-item"><div class="name">DynamoDB · ship-recent-reviews</div><div class="role">Which alert was shown on a screen and when, the voice-approval time window</div></div>
<div class="infra-item"><div class="name">DynamoDB · ship-alexa-users</div><div class="role">Which Alexa users SHIP may notify</div></div>
<div class="infra-item"><div class="name">DynamoDB · ship-learned-patterns</div><div class="role">Generalized patterns mined from dismissed findings</div></div>
<div class="infra-item"><div class="name">FastAPI + Mangum</div><div class="role">Webhook route and the Gate dashboard, one deployable app</div></div>
</div>
<div class="zone-note" style="margin-top:14px">Two fallback model backends sit alongside Bedrock, switchable with one environment variable: Google Gemini, for when AWS credits run out, and a local Ollama model, for a fully offline path.</div>
</div>
</div>
<footer>SHIP · built for Build, Ship, Shape: the Amazon Developer Hackathon (Alexa+ track) · <a href="https://github.com/pandayv/ship-engine" style="color:inherit">github.com/pandayv/ship-engine</a></footer>
</div>
</body>
</html>