Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
07a977d
docs: AI salience layer v1 design spec (self-hosted)
pavelmaksimov25 Jul 7, 2026
b90e7e9
docs: AI salience layer implementation plan
pavelmaksimov25 Jul 15, 2026
1c61bb8
feat: salience domain contract and deterministic advisor
pavelmaksimov25 Jul 16, 2026
059d697
fix: route config.yaml mappings and digest through the routing wire c…
pavelmaksimov25 Jul 16, 2026
5c37b22
fix: null-section consistency and digest error prefix in the wire codec
pavelmaksimov25 Jul 16, 2026
08623ff
refactor: target resolution result carries changed files
pavelmaksimov25 Jul 16, 2026
03285e2
feat: open-message composer options for salience decisions
pavelmaksimov25 Jul 16, 2026
604ea6e
refactor: open handler consults the salience advisor
pavelmaksimov25 Jul 16, 2026
1a26f7a
refactor: close and review handlers consult the salience advisor
pavelmaksimov25 Jul 16, 2026
0e494fa
refactor: digest reporter consults the salience advisor
pavelmaksimov25 Jul 16, 2026
37b3e09
test: restore composer salience tests alongside the digest list test
pavelmaksimov25 Jul 16, 2026
2c7d534
feat: per-tier ai overrides in mappings
pavelmaksimov25 Jul 16, 2026
eb5db20
feat: ai config block with AI_API_KEY and boot validation
pavelmaksimov25 Jul 16, 2026
00001e2
feat: deterministic pr signals for the salience advisor
pavelmaksimov25 Jul 16, 2026
713b9ff
feat: salience guard pipeline text stages
pavelmaksimov25 Jul 16, 2026
dfce779
test: pin minimizeTitle contract
pavelmaksimov25 Jul 16, 2026
1a9b7a5
fix: exclude gateway api key from marshaling
pavelmaksimov25 Jul 16, 2026
8b56beb
feat: model advisor with structured schemas, prompts, and clamps
pavelmaksimov25 Jul 16, 2026
033ee91
fix: reject trailing content after the decision json object
pavelmaksimov25 Jul 16, 2026
4c90d03
feat: resilient advisor with timeout, circuit breaker, and cache
pavelmaksimov25 Jul 16, 2026
c505b77
fix: default the resilient advisor logger when nil
pavelmaksimov25 Jul 16, 2026
dcdb1d0
feat: gemini model gateway
pavelmaksimov25 Jul 16, 2026
c5e8396
feat: openai-compatible model gateway
pavelmaksimov25 Jul 16, 2026
a88e1cd
feat: wire the salience advisor into the runtime
pavelmaksimov25 Jul 16, 2026
fb2d80a
fix: lint architecture rules for the salience domain
pavelmaksimov25 Jul 16, 2026
cad549a
feat: doctor ai section with provider probe and rate-limit headroom
pavelmaksimov25 Jul 17, 2026
0ef06aa
docs: ai salience layer documentation
pavelmaksimov25 Jul 17, 2026
d061ff7
fix: bump go toolchain to 1.25.12 for the crypto/tls advisory
pavelmaksimov25 Jul 17, 2026
1516ece
docs: ai key in env example and current toolchain pin in claude.md
pavelmaksimov25 Jul 17, 2026
3615dd9
fix: guard salience open content after minimization
pavelmaksimov25 Jul 17, 2026
80ad379
fix: render digest channel mentions in the advisor prompt
pavelmaksimov25 Jul 17, 2026
348458b
docs: correct doctor ai section and output reference
pavelmaksimov25 Jul 17, 2026
722c975
test: strengthen salience gating and hash-invariance coverage
pavelmaksimov25 Jul 17, 2026
579c4d7
fix: harden salience sanitizer and correct stale doc comments
pavelmaksimov25 Jul 17, 2026
5a8aa57
refactor(salience): type the fallback advisor as the domain port
pavelmaksimov25 Jul 19, 2026
1c2db25
refactor(salience): source decision-schema enums from domain constants
pavelmaksimov25 Jul 19, 2026
7f31106
test(salience): pin nested envelope-marker injection is neutralized
pavelmaksimov25 Jul 19, 2026
9cd0c05
refactor(diagnostics): replace tagless switch with two ifs in the AI …
pavelmaksimov25 Jul 19, 2026
d92fd35
fix(salience): place the PR author inside the untrusted prompt envelope
pavelmaksimov25 Jul 19, 2026
afc390e
refactor(salience): merge model_advisor.go into advisor.go
pavelmaksimov25 Jul 19, 2026
eff5e44
refactor(salience): remove the optional AI thread_note
pavelmaksimov25 Jul 19, 2026
51c41d4
refactor(salience): document the untrusted-envelope boundaries in the…
pavelmaksimov25 Jul 19, 2026
2a0fe78
fix(salience): post to every candidate channel; never let the AI drop…
pavelmaksimov25 Jul 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ SLACK_BOT_TOKEN=xoxb-replace-me
# route is not registered.
# SLACK_SIGNING_SECRET=

# --- AI layer (optional) ---
# Model-provider API key for the optional AI layer (docs/ai.md). Required for
# ai.provider: gemini; optional for openai_compatible (keyless local endpoints).
# AI_API_KEY=

# --- Reverse proxy (docker compose) ---
# Public DNS name that points at this host. Caddy uses it as the virtual-host name and obtains a
# Let's Encrypt certificate via the HTTP-01 challenge. Required when using compose.yaml.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ permissions:
contents: read

env:
GO_VERSION: "1.25.11"
GO_VERSION: "1.25.12"
GOVULNCHECK_VERSION: "v1.3.0"

jobs:
Expand Down
100 changes: 96 additions & 4 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,21 +113,42 @@ linters:
- github.com/mptooling/notifycat/internal/kernel
- github.com/mptooling/notifycat/internal/digest/domain
- github.com/mptooling/notifycat/internal/routing/domain
- github.com/mptooling/notifycat/internal/salience/domain
deny:
- pkg: github.com/mptooling/notifycat/internal
desc: digest/domain imports only the shared kernel, the routing domain, and the standard library — never application, infrastructure, store, slack, or github
desc: digest/domain imports only the shared kernel, the routing and salience domains, and the standard library — never application, infrastructure, store, slack, or github
# digest/application production may reference the salience domain (the
# Advisor port), but only tests may import salience/application — the
# fakes delegate to the deterministic advisor; production advisor
# construction belongs to the composition root.
digest-application:
list-mode: lax
files:
- "**/internal/digest/application/**"
- "!**/internal/digest/application/**_test.go"
allow:
- github.com/mptooling/notifycat/internal/kernel
- github.com/mptooling/notifycat/internal/digest/domain
- github.com/mptooling/notifycat/internal/digest/application
- github.com/mptooling/notifycat/internal/routing/domain
- github.com/mptooling/notifycat/internal/salience/domain
deny:
- pkg: github.com/mptooling/notifycat/internal
desc: digest/application imports only its own domain layer, the routing domain, and the shared kernel — never infrastructure, store, slack, or github
desc: digest/application imports only its own domain layer, the routing and salience domains, and the shared kernel — never infrastructure, store, slack, or github
digest-application-tests:
list-mode: lax
files:
- "**/internal/digest/application/**_test.go"
allow:
- github.com/mptooling/notifycat/internal/kernel
- github.com/mptooling/notifycat/internal/digest/domain
- github.com/mptooling/notifycat/internal/digest/application
- github.com/mptooling/notifycat/internal/routing/domain
- github.com/mptooling/notifycat/internal/salience/domain
- github.com/mptooling/notifycat/internal/salience/application
deny:
- pkg: github.com/mptooling/notifycat/internal
desc: digest/application tests may additionally use salience/application for the deterministic-advisor fakes
notification-domain:
list-mode: lax
files:
Expand All @@ -136,21 +157,40 @@ linters:
- github.com/mptooling/notifycat/internal/kernel
- github.com/mptooling/notifycat/internal/notification/domain
- github.com/mptooling/notifycat/internal/routing/domain
- github.com/mptooling/notifycat/internal/salience/domain
deny:
- pkg: github.com/mptooling/notifycat/internal
desc: notification/domain imports only the shared kernel, the routing domain, and the standard library — never application, infrastructure, store, slack, or github
desc: notification/domain imports only the shared kernel, the routing and salience domains, and the standard library — never application, infrastructure, store, slack, or github
# Same test carve-out as digest-application: only tests may import
# salience/application (deterministic-advisor fakes).
notification-application:
list-mode: lax
files:
- "**/internal/notification/application/**"
- "!**/internal/notification/application/**_test.go"
allow:
- github.com/mptooling/notifycat/internal/kernel
- github.com/mptooling/notifycat/internal/notification/domain
- github.com/mptooling/notifycat/internal/notification/application
- github.com/mptooling/notifycat/internal/routing/domain
- github.com/mptooling/notifycat/internal/salience/domain
deny:
- pkg: github.com/mptooling/notifycat/internal
desc: notification/application imports only its own domain layer, the routing domain, and the shared kernel — never infrastructure, store, slack, or github
desc: notification/application imports only its own domain layer, the routing and salience domains, and the shared kernel — never infrastructure, store, slack, or github
notification-application-tests:
list-mode: lax
files:
- "**/internal/notification/application/**_test.go"
allow:
- github.com/mptooling/notifycat/internal/kernel
- github.com/mptooling/notifycat/internal/notification/domain
- github.com/mptooling/notifycat/internal/notification/application
- github.com/mptooling/notifycat/internal/routing/domain
- github.com/mptooling/notifycat/internal/salience/domain
- github.com/mptooling/notifycat/internal/salience/application
deny:
- pkg: github.com/mptooling/notifycat/internal
desc: notification/application tests may additionally use salience/application for the deterministic-advisor fakes
review-domain:
list-mode: lax
files:
Expand Down Expand Up @@ -198,6 +238,49 @@ linters:
deny:
- pkg: github.com/mptooling/notifycat/internal
desc: diagnostics/application imports its own domain, the validation domain/application, and the routing domain — never infrastructure, config, store, slack, http, or security
salience-domain:
list-mode: lax
files:
- "**/internal/salience/domain/**"
allow:
- github.com/mptooling/notifycat/internal/kernel
- github.com/mptooling/notifycat/internal/salience/domain
deny:
- pkg: github.com/mptooling/notifycat/internal
desc: salience/domain imports only the shared kernel and the standard library — never application, infrastructure, store, slack, or a provider client
salience-application:
list-mode: lax
files:
- "**/internal/salience/application/**"
allow:
- github.com/mptooling/notifycat/internal/kernel
- github.com/mptooling/notifycat/internal/salience/domain
- github.com/mptooling/notifycat/internal/salience/application
deny:
- pkg: github.com/mptooling/notifycat/internal
desc: salience/application imports only its own domain layer and the shared kernel — never infrastructure, a provider SDK, or the network
# Provider sibling independence is a spec guarantee: gemini and
# openaicompat must not share code, so neither may import the other.
salience-infrastructure-gemini:
list-mode: lax
files:
- "**/internal/salience/infrastructure/gemini/**"
allow:
- github.com/mptooling/notifycat/internal/salience/domain
- github.com/mptooling/notifycat/internal/salience/infrastructure/gemini
deny:
- pkg: github.com/mptooling/notifycat/internal
desc: the gemini provider imports only the salience domain and the standard library — never its openaicompat sibling or another domain
salience-infrastructure-openaicompat:
list-mode: lax
files:
- "**/internal/salience/infrastructure/openaicompat/**"
allow:
- github.com/mptooling/notifycat/internal/salience/domain
- github.com/mptooling/notifycat/internal/salience/infrastructure/openaicompat
deny:
- pkg: github.com/mptooling/notifycat/internal
desc: the openaicompat provider imports only the salience domain and the standard library — never its gemini sibling or another domain
errcheck:
exclude-functions:
- fmt.Fprint
Expand Down Expand Up @@ -231,6 +314,15 @@ linters:
linters:
- gosec
- unparam
# The composition-root package is deliberately named "runtime"
# (referenced as runtime.Module); it is never imported alongside the
# stdlib runtime package. Excluded here rather than via //nolint because
# revive attributes the package-name finding to an arbitrary file in the
# package, so a per-file directive breaks when files are added.
- path: internal/runtime/
linters:
- revive
text: "avoid package names that conflict"

formatters:
enable:
Expand Down
9 changes: 5 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,11 @@ A hexagonal Go app needs two pieces of connective tissue that are deliberately *

Notifycat has **no user accounts, sessions, logins, roles, or permissions**, so there is no authentication or authorization *domain* — modelling them as domains would produce empty, ruleless folders. The only authentication that exists today is **inbound webhook message authentication**: proving, by HMAC signature, that a request genuinely came from GitHub or Slack. That is a cross-cutting security concern, so it lives in **`platform/security`** as an explicit `SignatureVerifier` port with one adapter per provider scheme — not inside a business domain. The inbound adapters in `notification` and `review` consume that port and only parse the verified body; they never re-implement verification. Outbound credentials (the Slack bot token, the GitHub token) are `Secret`s from `platform/config`, passed to the `platform/slack` / `platform/github` clients.

**Authorization does not exist today** — single tenant, no access control beyond "is this webhook authentic." If the multi-tenant SaaS direction lands (see the productization plan), *then* authentication (tenant/user identity, GitHub-App OAuth installs, API keys) and authorization (tenant isolation, who may configure which mappings) become genuine bounded contexts — new `internal/identity` and `internal/access` domains alongside these seven, owning their ports in the domain layer and plugging into the same fx composition. That is deliberately out of scope for this refactor.
**Authorization does not exist today** — single tenant, no access control beyond "is this webhook authentic." If the multi-tenant SaaS direction lands (see the productization plan), *then* authentication (tenant/user identity, GitHub-App OAuth installs, API keys) and authorization (tenant isolation, who may configure which mappings) become genuine bounded contexts — new `internal/identity` and `internal/access` domains alongside these eight, owning their ports in the domain layer and plugging into the same fx composition. That is deliberately out of scope for this refactor.

## Domain map

Seven domains over the shared kernel and platform. The core domain is **notification**; everything else supports it.
Eight domains over the shared kernel and platform. The core domain is **notification**; everything else supports it.

```mermaid
flowchart TB
Expand Down Expand Up @@ -86,13 +86,14 @@ flowchart TB

| Domain | Business capability | Absorbs today's packages |
| --- | --- | --- |
| **notification** | Receive a GitHub PR event and keep one Slack message per (PR, channel) in sync — post on open, update/react on state change, delete on draft. Includes bot-PR classification/formatting and AI-reviewer suppression. | `pullrequest` (dispatcher, open/close/draft + reaction handlers), `botpr`, `aireview`, `githubhook` parsing (verification → `platform/security`), the messages repository from `store` |
| **notification** | Receive a GitHub PR event and keep one Slack message per (PR, channel) in sync — post on open, update/react on state change, delete on draft. Includes bot-PR classification/formatting and AI-reviewer suppression. OpenHandler, CloseHandler, and the reaction handlers (ApproveHandler, CommentedHandler, RequestChangeHandler) consult `saliencedomain.Advisor` when AI is enabled. | `pullrequest` (dispatcher, open/close/draft + reaction handlers), `botpr`, `aireview`, `githubhook` parsing (verification → `platform/security`), the messages repository from `store` |
| **review** | The interactive "Start review" flow: a Slack button click records a reviewer and appends an in-review marker; a submitted GitHub review finishes sessions and clears the in-review state; reviewers are shown on close. | `startreview`, `slackhook` parsing (verification → `platform/security`), the `code_reviews` repository from `store`, the review-session logic in `pullrequest` |
| **routing** | Resolve a repo (and optionally a PR's changed files) to the Slack channel(s) and behavioral config that apply, across global/org/repo tiers and monorepo path rules. | `mappings` (provider, parsing, tiers, defaults, lock), the per-PR `Router` from `pullrequest`, the changed-files reader over `platform/github` |
| **validation** | Validate mapping entries against Slack (channel exists, bot present) and GitHub (repo exists); cache results in `config.lock`. Powers the startup gate, the doctor, and `notifycat-config validate`. | `validate` |
| **digest** | Periodically post a digest of stuck (stale) PRs per the enabled cron schedules. | `digest` |
| **digest** | Periodically post a digest of stuck (stale) PRs per the enabled cron schedules. The digest reporter consults `saliencedomain.Advisor` when AI is enabled. | `digest` |
| **maintenance** | Background housekeeping: delete stale message rows past their TTL; reconcile closed PRs. | `cleanup`, `reconcile` |
| **diagnostics** | Operator tooling: the preflight doctor, the `notifycat-config` CLI (list/validate), and the smoke-test delivery. | `doctor`, `mappingcli`, `smoke` |
| **salience** | Optional AI decision layer: decides notification salience — per-channel loudness, mentions subset, emoji, format, emphasis, bounded notes, digest ordering — behind a no-error `Advisor` port with deterministic fallback; operator-facing name is "AI". Providers under `salience/infrastructure/{gemini,openaicompat}`. | `salience` (new) |

`internal/app` (the old hand-written composition root) has been deleted: its wiring is per-domain fx modules, and its lifecycle orchestration is `fx.Lifecycle` hooks composed in `internal/runtime` and each `cmd/*/main.go`.

Expand Down
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ underlying `go ...` invocations work without `just` if needed.
| Single test | `go test -race ./internal/notification/... -run TestApproveHandler` |
| Single package | `go test -race ./internal/notification/...` |
| Lint only | `just lint` (requires `golangci-lint` locally) |
| Vuln scan | `just vuln` (runs in Docker against the pinned `golang:1.25.10-alpine` — slower; CI also runs this) |
| Vuln scan | `just vuln` (runs in Docker against the pinned `golang:1.25.12-alpine` — slower; CI also runs this) |
| Build all binaries | `just build` |
| Run server locally | `just serve` |
| Apply migrations | `just migrate` (or `just docker-migrate` against `./data`) |
| Build & run Docker image | `just docker-build` then `just docker-serve` |

Go toolchain is pinned at **1.25.10**. CI runs all of the
Go toolchain is pinned at **1.25.12**. CI runs all of the
`just check` steps (`go vet`, `golangci-lint`, `govulncheck`,
`go test -race`, `go build`). `just` is dev-only — it is not in the
runtime image or Go modules.
Expand All @@ -46,7 +46,7 @@ Notifycat is a single-process HTTP server with a SQLite sidecar. It receives Git

### Domain structure

Seven domains live under `internal/<domain>/`, each with three layers (`domain/`, `application/`, `infrastructure/`) and a `module.go` exporting an `fx.Module`:
Eight domains live under `internal/<domain>/`, each with three layers (`domain/`, `application/`, `infrastructure/`) and a `module.go` exporting an `fx.Module`:

| Domain | Responsibility |
| --- | --- |
Expand All @@ -57,6 +57,7 @@ Seven domains live under `internal/<domain>/`, each with three layers (`domain/`
| `digest` | Periodic stuck-PR digest per cron schedule |
| `maintenance` | Background housekeeping: delete stale message rows; reconcile closed PRs |
| `diagnostics` | Operator tooling: `notifycat-doctor`, `notifycat-config`, smoke test |
| `salience` | Optional AI decision layer: decides notification salience — per-channel loudness, mentions subset, emoji, format, emphasis, bounded notes, digest ordering — behind a no-error `Advisor` port with deterministic fallback; operator-facing name is "AI" |

The shared kernel (`internal/kernel`) holds pure value objects (`PR`, `Event`, `Sender`, `Review`) and GitHub event/action/review-state enums — stdlib only. The shared platform (`internal/platform/`) holds domain-agnostic clients: `config`, `persistence` (GORM/SQLite), `slack`, `github`, `httpx`, and `security` (HMAC `SignatureVerifier` with `GitHubVerifier`/`SlackVerifier` adapters).

Expand Down Expand Up @@ -85,6 +86,8 @@ POST /webhook/slack/interactions
→ review/application start-review use case
```

When AI is enabled, OpenHandler, CloseHandler, and the reaction handlers (ApproveHandler, CommentedHandler, RequestChangeHandler) consult `saliencedomain.Advisor` before sending to the Messenger port; the digest reporter does the same.

Every silent no-op logs `ignored webhook event` with a `reason` field (`no_handler`, `no_mapping`, `no_stored_message`, `already_sent`). The operations doc has the full reason table — that contract matters for debugging deliveries that return 200 but don't update Slack.

### Mappings & startup validation
Expand Down
Loading