System Architecture

SHIP

The full system in one picture. Walkthrough below.
1. Review
2. State and status
3. Voice and decisions
Hand-over between pipelines

Open the diagram full size

SHIP in three pipelines. Review: GitHub sends a pull request to the webhook, which verifies the signature and checks the repository, then screens the diff, splits it into fragments and queues them. A fragment processor takes each fragment, Detector (a Strands agent on Bedrock AgentCore) judges it against sourced regulation text, and a finding at or above the threshold is written as an alert. State and status: a review ledger counts the fragments as they report, and when all have reported the pull request's ship/compliance status is recomputed and posted to GitHub. A person's decision, from the web dashboard or by voice, updates the alert and triggers the same recalculation. Voice and decisions: Alexa reaches Relay, which reads the precomputed summary and pushes the findings to a screen over a WebSocket. The screen reports what it displayed. A decision is applied only if the finding was shown in the last ten minutes and a reason was given; otherwise the command is refused.
SHIP in three pipelines. Review finds the risk and writes an alert. State and status keeps count and keeps the pull request check honest. Voice and decisions lets a person decide, and only after the findings were shown on a screen and a reason was given. Dashed lines are the two hand-overs: each reviewed fragment reports to the ledger, and a person's decision returns to the alert it changes.

How it works

The diagram above shows the mechanism; this section walks through why each piece exists.
Entry point
GitHub webhook: a pull request opens or updates
The only trigger for a review. No polling, no scheduled scan. SHIP reacts to the pull_request event GitHub already fires (opened, synchronize, reopened, edited).
ship-webhook · fast-ack Lambda
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.
Verify + allowlist tool
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. Once a commit is accepted, the webhook posts the ship/compliance status as pending to the commit GitHub just reported, so a required check is never satisfied by absence. If the repository can't be verified, or the diff can't be read, it posts error instead.
Screener: whole-diff precheck tool
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.
Split + per-fragment scan tool
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.
Enqueue tool
One SQS message per flagged fragment, not one message for the whole PR. Making each fragment its own unit of work is what everything downstream depends on. Before queueing, the webhook writes a review ledger for the pull request (an item keyed review#<repo>#<pr> in ship-status) listing the fragment keys this commit's review must hear back from. A fragment too large to queue stays in that list, never reports, and posts an error status.
Why fragments are dispatched independently
The design decision this whole second Lambda exists to implement.

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 ceiling of 900 seconds on how long one invocation may run, and nothing resets that clock partway through. A review that hits the ceiling stops with no error surfaced anywhere; the fragments reviewed before the cutoff are recorded and the rest never get a turn.

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.

ship-fragment-processor · one job per fragment
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.
Detector: RAG-grounded judgment agent
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.
RAG-grounded, never unaidedruns on Bedrock AgentCore Runtime
Triage: per-category threshold tool
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.
Gate: where most alerts resolve human
A token-gated dashboard (a separate credential from the webhook's, so compromising one can't disable the other) listing every finding still waiting for a decision, grouped by pull request and numbered the way Relay numbers them: blockers first, then by detector theme, then by risk score. Each finding shows file, category, risk score, plain-English summary, citation and suggested patch. The token is pasted once and becomes an httponly session cookie that holds the shared token and lasts a week, so later pages are plain links. A person decides with a written reason. Allow accepts the risk: the finding stops blocking. Block says the finding is real: it leaves this list because it is decided, but the pull request stays blocked until a later commit no longer contains the flagged code. For a blocking finding the buttons read "Allow merge" and "Keep blocked". For a review-only finding they read "Allow (clear the flag)" and "Block the merge". They post to /dashboard/{id}/allow and /dashboard/{id}/block. The transition is one-way and guarded: a redelivered webhook or a retried job cannot reopen or overwrite a decision. The decision is posted to the PR as a comment ("Allowed by {who}: risk accepted" or "Blocked by {who}: this needs a code fix") and the commit status is recomputed. Gate also has a history view (every past decision with its reason, tagged Allowed or Blocked; blocked findings that still hold a merge are marked) and a connected-repos view (connecting a repository is a form submission here, not a redeploy). A person can also decide by voice through Relay, under the rules further down.
Failure handling
Two independent mechanisms, for two different failure shapes.
Slow, or rate-limitedA 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.
adaptive backoff
Failing outrightA 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. It stays visible for manual triage. Because it never reports to the review ledger, the pull request's check stays pending. Nothing consumes the dead-letter queue yet, so no explicit error status is posted for it.
dead-letter queue

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.

The check on the pull request
ship/compliance is a commit status. A required check can only be satisfied by a status, so every accepted commit ends in one. Make it required in branch protection and it stops the merge button.
pendingPosted the moment the webhook accepts a commit. It is also what a green result becomes while sibling fragments are still being judged, and what stays in place when SHIP can't read its own state.
successNothing waits for a decision, or only flagged (non-blocking) findings do, or findings were allowed. Posted only after every queued fragment has reported.
failureA blocking finding waits for a decision, or a blocked finding's flagged code is still in the PR. Posted at once, without waiting for the other fragments.
errorA review that could not run: the diff could not be read, the repository allowlist could not be checked, the commit could not be queued, or a fragment was too large to queue.

One function, derive_commit_status, maps stored alerts to a state, and every path that posts a verdict uses it: the webhook, the fragment processor, Gate and Relay's voice decisions. Concurrent writers therefore converge on the same answer, and ordering stops mattering. The status goes to the pull request's current head commit, read from GitHub. The commit stored on an alert is only the fallback if that lookup fails, so a decision made long after the commit it was found on can't leave a stale status on the head.

The review ledger keeps a green result honest. Each fragment ticks itself off when it finishes, whether or not it found anything, and a duplicate SQS delivery counts once. A newer commit replaces the ledger, and a result for an older commit is ignored. If the ledger can't be read, the check stays pending and the reason is logged. A blocked finding is released by the next commit: the webhook compares that commit's flagged fragments with each held block and releases the ones whose fragment is gone. If that step fails, the finding keeps blocking.

Detectors
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.
PIIE-001
Raw, direct PII (SSN, account number, full profile) reaching an external sink with no masking
GDPR Article 32
PIIE-002
The same kind of raw PII, written to a log stream
GDPR Article 32
PIIE-003
Raw PII stored in a cache/session store with no encryption
GDPR Article 32
TLGP-002
An AI-produced decision applied as final with no human checkpoint anywhere in the fragment
EU AI Act Article 14
ALBP-001
A protected characteristic (or a clear proxy) directly driving a scoring calculation
EU AI Act Art. 10 + Annex III §5(b)
TLGP-001
A dangerous capability (shell exec, unscoped DB write) granted to an AI agent with no gate
OWASP LLM06:2025

Screener's job is to notice a candidate — 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 and 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.

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.

Relay · the MCP server behind the Alexa Skill
A separate Lambda from the review pipeline. Relay judges no code: Detector already reasoned, Triage already routed, and a person already recorded what they concluded. Relay reads that, routes it to a surface, and enforces the rules for deciding by voice. It is built for a 500 ms answer budget (the Alexa+ requirement noted in its code), which is why it never calls a model.
release_status tool, voice-safe
The only tool read aloud. It reads the precomputed summary (ship-status, one GetItem) instead of aggregating, so the answer takes the same time at any number of findings. The sentence is capped at 180 characters with no line breaks, enforced by the server: a list of findings cannot be spoken. Examples: "2 blockers found. Ready for your decision." followed by an offer that names only screens that exist ("Want it on the laptop?", "Want it on the laptop or the tablet?", or "Want it on a screen?" for three or more, and no offer when none exist); "No blocking issues. Good to move ahead."; "Nothing needs a decision, but 1 finding that was blocked still holds the merge until the code is fixed." If the summary can't be read it says "I can't check right now. Open the review console." and never reports all clear.
blocked_pull_requests / finding_detail tool, screen-only
Citations, file paths, code and every finding, rendered on whatever screen asked and never spoken. blocked_pull_requests returns the findings grouped by pull request, numbered from 1 within each: blockers first, then by detector theme, then by risk score. On the screen each pull request is one table. An open blocking finding is marked "Needs a decision" and a non-blocking one "Flagged". A staged decision shows on its row as "Pending: allow" or "Pending: block" with its reason, and a note appears when blocked findings still hold a pull request. Calling either tool records the finding as recently shown, together with the number it carried, which is what the decision rules below check.
display_on tool, action
Routes the findings to a named screen. An open page receives a push over its WebSocket, and the findings count as shown only if the post succeeded ("Putting it on your laptop."). If no page is open but the screen was registered for alerts, Relay holds the findings for that name and sends a notification ("I've sent a notification to your laptop. Open it to review."). If neither is possible: "I don't see a laptop connected right now." The name here means the caller renders its own copy. A success reply carries routing: the number and id of each open finding, no content.
connected_screens / findings_routing tool, read-only
connected_screens lists the screen names that are open now or registered for alerts, so nobody is offered a screen that isn't there. findings_routing lists each open finding's id, its number now, whether it was shown in the last ten minutes, and the number it had on the screen that showed it (shown_position). Neither returns finding text, and reading routing records nothing as shown. The skill's guided review builds its list of findings to ask about from findings_routing, so it covers only findings Relay recorded as seen.
stage_decisions / proceed / cancel_pending tool, action
stage_decisions validates a batch of decisions for one pull request (finding id, allow or block, reason) and stores it as pending. The decision must be exactly that word, and anything else is refused. It applies nothing. The guided review stages after every decision, and the set decided so far replaces the earlier batch for that pull request. proceed re-checks each finding and applies the batch, then reports an outcome per finding (applied, skipped, already decided, not found, unreadable, error) and speaks them: "Done. Applied all 3." or "Applied 2 of 3. 1 already decided in the console." If nothing staged can be read, it applies nothing and says "I couldn't read what was staged, so I applied nothing. Please say your decisions again." A finding that failed for a temporary reason stays staged for another proceed. cancel_pending drops the batch. A staged batch expires after ten minutes. Each applied decision is recorded on the finding, commented on the PR, and followed by a recomputed status and summary.
request_risk_acceptance tool, action
Allows one finding in a single step, under the same two server rules (shown on a screen in the last ten minutes, and a reason). If either is missing it performs nothing and returns where to decide. It cannot block a finding: that goes through stage_decisions. It is for other MCP clients; the Alexa Skill's model is never given it.

The ASGI app is built fresh inside the handler on every Lambda invocation, not once at import time. The MCP Python SDK's Streamable HTTP session manager can enter its lifespan once per instance, and a warm container reusing a module-level app crashes the second request with "SessionManager .run() can only be called once per instance". Responses are plain JSON rather than an event stream, which a Lambda Function URL in buffered mode requires. One consequence: every invocation starts a session manager that has never seen a client, so no MCP session id can carry state between calls. That shaped the decision design below.

Voice can confirm a decision. It can never make one blind.
The server checks the same facts for every decision tool. The skill adds checks of its own, whether the person answers its questions one at a time or says a whole decision in one sentence.

What SHIP exists to catch is not "a decision happened over voice". It is "a decision happened without a person engaging with what they were deciding". A person who asked to see the findings, had them rendered on a screen and then says why has done what human oversight requires. Sending them to a web form instead adds friction and no safety. What must never work is "ignore it, go ahead" with no review at all, which is the TLGP-002 shape this project flags in other people's code.

Reviewed, said why, confirmedEvery finding in the batch is open, was shown on a screen in the last ten minutes, and has a reason of at most 500 characters. proceed re-checks each one and writes it as a conditional update that fails if someone decided the finding first.
performed > 0
Unseen, unexplained or unclearOne finding that is unseen, already decided, unknown, without a reason, or given a decision other than allow or block refuses the whole batch with a plain sentence and changes nothing. Silently applying part of it would decide something the person didn't say. An unreadable record of what was shown counts as not shown.
performed = 0

What the skill checks before Relay is called. In the guided review, plain code reads each answer and no model is involved. It asks only about findings Relay recorded as seen, and takes a decision only from a decision word in the answer, so a bare yes never decides anything. The reason is the person's own answer, and one with fewer than two content words is asked for once more and then taken as said. Each decision is staged as it is made, and only the person's yes at the end of the pull request applies the batch. For a full sentence in one go, the model reaches four tools (release_status, display_on, stage_decisions, proceed) and never sees finding text. A decision is staged only for a pull request that was shown to this person, and only as the exact word allow or block. The reason must be built from words the person said (at least two content words beyond the command, a number or a screen name, and at least 60 percent of them present in what was said this turn or in the last three exchanges). The model says "the second one"; code turns that into a finding id using the number each finding had on the screen that displayed it, and Relay only ever receives ids. A number two seen findings both claim is dropped, not guessed. Staging and proceeding never happen in one turn, and only the person's plain confirmation applies a staged batch. Saying no, cancel or stop drops it. Relay does not check that a reason is in the person's own words or that a confirmation was spoken; a client that calls Relay directly supplies both itself.

The record of what was shown is keyed on the finding with a ten-minute window (ship-recent-reviews), not on an MCP session, because a session manager built fresh on every invocation has nothing for a session id to continue. The record also stores the number the finding carried on that screen. Findings that arrive or get decided afterwards therefore can't move "the second one" onto a different finding. See docs/voice-resolution-workflow.md for the flow and the exact sentences.

Screens: push to an open page, notify a closed one
Push, not polling, and a notification is not a display.
API Gateway WebSocket API infra
Routes $connect, $disconnect, register, subscribe and ack, handled by a small dedicated Lambda (device_gateway_handler.py) kept separate from Relay so Relay's IAM role stays narrow. API Gateway doesn't relay a route's return value to the client, so every reply is posted back over the Management API, the same path Relay uses to push.
ship-device-connections infra
Which open page answers to which name. Any browser opens ship-display.html, names itself once (1 to 30 letters or digits, remembered by the browser) and sits idle with no polling. It registers again every five minutes, which keeps it inside API Gateway's 10-minute idle limit. A row older than two hours or silent for fifteen minutes is treated as gone, and a push that finds a dead connection removes its row.
ship-push-subscriptions / ship-pending-display infra
A page registers its browser's push address once, after the person allows notifications. From then on display_on can reach that screen with the page closed: it stores the findings for that name for ten minutes and sends a short Web Push notification signed with a VAPID key held in Parameter Store (ship-sw.js shows it). The notification carries a count and never a finding. Tapping it opens the page, which receives the held findings when it registers. A push address the browser reports gone is removed.
Seen means displayed rule
A finding pushed to an open page counts as shown when the post succeeds. Held findings count as shown only when the page displays them and sends an ack, which it does only while the page is visible, and only for findings that were actually held for that name. A notification alone never counts.

A genuine compliance event happens on the order of weeks, not seconds. A display polling every few seconds would spend nearly all its traffic finding nothing changed. An idle WebSocket costs nothing until there is something to say. Every send is time-capped because the skill is waiting on Relay: each WebSocket post has a 1.0 s connect and 1.5 s read timeout, at most six connections per name are tried, and the whole push stops after 2 s. A notification send takes at most 2 s and the set stops after 2.5 s.

Alexa Skill · the voice of SHIP
A custom skill ("ship review") hears the person and calls the same MCP server an Alexa+ add-on would use.
Plain intents tool
Status, show-on-a-screen, start-the-review, turn-on-alerts, yes, no, help and stop are answered by plain code, fast. StartReviewIntent ("let's go through them") starts the walk-through, and AnswerIntent carries the reply to any question the skill asked. The SCREEN slot includes all, with "both", "all of them" and "everywhere" as synonyms. Free text uses one intent per verb (AllowIntent, BlockIntent, mark, show, what) because Alexa drops a carrier phrase from the slot it hands the skill. The skill puts the verb back as "allow" or "block" before the model sees the sentence. Approve, accept, clear, dismiss and ignore are understood as allow, and reject and confirm as block. The skill performs the MCP handshake (initialize, notifications/initialized, tools/list) once per warm container. Alexa gives a skill 8 seconds: calls to Relay time out at 5, and the skill stops waiting at 7 and speaks an apology.
Guided review: Alexa asks, code reads tool
review_flow.py holds the whole conversation. After the status ("9 blockers found across 2 PRs. Ready for your decision. Want it on the laptop or the tablet?") the person answers in words and the findings go to that screen. Alexa asks "Want to go through them?" (or, after a notification, waits for "ready" and checks with Relay that the findings were displayed). Then for each finding on the person's screen: "Number 1 on pull request 1. Allow, block, or skip?" The answer may carry its reason ("allow it, it's only test data"). Without one Alexa asks "What's the reason?" with examples. Each decision is staged on Relay at once, so the screen shows it as pending. At the end of a pull request: "That's all for pull request 1: 1 allowed, 1 blocked. Say yes to apply." A yes calls proceed, Alexa says Relay's sentence and offers the next pull request. Skip, next, go back, undo, stop, help, repeat, a finding by number and "all of them" are understood. Two misses in a row add "Say stop to leave." The spoken questions carry numbers and never finding text.
Every question elicits the answer tool
Alexa understands a sentence only if it matches one of the skill's sample phrases, so a free answer to a question would fall to "I didn't understand" and the question would come back. The skill sends every question with a Dialog.ElicitSlot directive for AnswerIntent, whose one slot is an AMAZON.SearchQuery. Alexa then hands back whatever the person says as plain words, and code reads them with a wide vocabulary instead of a model, so the meaning of an answer is predictable. While a review is under way, yes, no, stop, help and the free-text intents are treated as answers too, and the fallback intent repeats the question. The conversation state is small and JSON-safe, kept in the session under flow.
Free speech: the model proposes agent
Anything the guided review does not handle, such as a full decision said in one sentence, goes to Amazon Nova Lite through the Bedrock Converse API (temperature 0, 3 s read timeout). It sees the person's words, the last three exchanges and a note saying which screens exist, which findings the person has already seen (repo, pull request, numbers) and whether a decision is staged. If the model is slow or fails, simple rules answer status and show requests.
Code decides tool
In the guided review, code reads every answer itself and a bare yes never decides anything. For the model's proposals, plain code checks: the four-tool limit, a screen the person named or used last, a pull request that was shown, a reason in the person's own words, numbers mapped to ids, and a plain confirmation for proceed. What is spoken back is Relay's sentence, so the model can't claim an allow Relay refused. The model's own words are spoken only when it used no tool.
Echo alert tool
When a finding is newly recorded, the fragment processor sends an AMAZON.MessageAlert.Activated proactive event from "SHIP", carrying the count of open findings, to every Alexa user the skill has saved (ship-alexa-users). Alexa has no generic alert type, so it reads as unread messages. The person allows notifications once in the Alexa app; "turn on alerts" sends them the permission card. Each finding notifies once, and a retried job doesn't repeat it. The skill's Login with Amazon credentials live in Parameter Store.
Self-improvement loop
scripts/pattern_miner.py — periodic, off Detector's hot path, same shape as Healing Loop. No schedule is set up in the repo; it runs by hand. It learns from findings a reviewer allowed. A blocked finding, confirmed real, is never mined. A pattern reaches Detector only after 3 independent decisions, and then as context, never as a rule.

Feeding Detector the raw text of recent human decisions doesn't scale: after a month or two there are too many to fit in a prompt, and picking a recent subset is arbitrary. Instead, a batch job extracts a small, bounded set of generalized patterns from accumulated allowed findings, merging new evidence into existing patterns rather than appending, so the set stays small regardless of how much history exists.

No human approval gate per patternA pattern is often an abstraction tied to field-level data associations, not a business rule a person can judge in isolation. Asking for sign-off on every one is recurring toil with little safety gain.
automatic, not manual
Two automatic guards insteadMIN_SUPPORT: a pattern becomes retrieval-eligible only after independent decisions confirm it, not one person's single call. And a pattern is retrieved as context, never a rule: Detector's grounded, citation-based judgment still runs on every fragment.
ship-learned-patterns
Operational visibility
A pipeline that looks healthy from every angle checked is the failure to design against.

Silent stalls. A disabled SQS event source mapping would leave the webhook returning 200 "accepted, queued for review" for every delivery while reviewing nothing. ship-repos tracks last_event_at alongside last_reviewed_at. A gap between the two past a 20-minute grace window (generous against the ~440 s worst Detector latency observed on a cold AgentCore container) shows as a "stuck" badge on Gate's connected-repos view.

Audit lines. Relay writes one line to CloudWatch for every decision and every refusal: the tool, the outcome, the pull request, the finding ids (first 12 characters), a short reason code, small counts and the Lambda request id. They never carry the reason a person gave or any finding text.

Delivery that survives retries. The PR comment and the Echo alert for a finding are each claimed under a short lease and marked only after a successful send. A retry finishes whichever step is missing without repeating the one already done, and a finding a person has already decided gets no new comment.

AWS infrastructure
Lambda · ship-webhook
Always-on, fast-ack only, no model calls
Lambda · ship-fragment-processor
Independently scaling, one flagged fragment per invocation; also sends the Echo alert
Lambda · ship-relay
The MCP server, SnapStart-enabled, a fresh ASGI app per invocation
Lambda · ship-alexa-skill
The Alexa Skill: the guided review in plain code, speech to Relay calls, with Nova Lite for open-ended requests
Lambda · ship-device-gateway
WebSocket connect, disconnect, register, subscribe and ack, kept separate from Relay's IAM role
SQS
Fragment queue + dead-letter queue, concurrency-capped consumer
API Gateway WebSocket API
Server-initiated push to a named, open display page
Alexa proactive events
Chimes the Echo when a finding is recorded
Web Push
Notifies a registered screen whose page is closed; signed with a VAPID key
SSM Parameter Store
SecureString: the VAPID private key and the skill's Login with Amazon credentials
Bedrock AgentCore Runtime
Deployed Detector agent, callable remotely from either review-pipeline Lambda
Bedrock · Amazon Nova Lite
Primary reasoning model, picked over Claude Haiku 4.5 on a measured benchmark: same accuracy, 20× the request quota. Also the skill's free-speech model
Bedrock Titan Embeddings
Regulation-corpus retrieval, and the same mechanism for learned-pattern retrieval
DynamoDB · ship-alerts
Idempotent alert storage and decisions, Gate's source of truth
DynamoDB · ship-repos
Connected repositories, the webhook's allowlist, plus received-vs-reviewed health
DynamoDB · ship-status
Precomputed release summary (Relay's voice path is one GetItem), and one review#… ledger item per pull request
DynamoDB · ship-recent-reviews
Which finding was shown on a screen, when, and under what number
DynamoDB · ship-pending-dispositions
Decisions staged by voice, one batch per pull request, ten-minute life
DynamoDB · ship-device-connections
Which open page answers to which name
DynamoDB · ship-push-subscriptions
Browsers registered for alerts, by screen name
DynamoDB · ship-pending-display
Findings held for a closed page, ten-minute life
DynamoDB · ship-alexa-users
Which Alexa users SHIP may notify
DynamoDB · ship-learned-patterns
Generalized patterns mined from human decisions
FastAPI + Mangum
Webhook route and the Gate dashboard, one deployable app
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. Reference IAM policies for each Lambda role are in infra/iam/; the repo does not apply them.