diff --git a/.claude/rules/configuration.md b/.claude/rules/configuration.md index 4d3272b..5e6d0e2 100644 --- a/.claude/rules/configuration.md +++ b/.claude/rules/configuration.md @@ -21,7 +21,6 @@ Proposed names (descriptive: once implemented, the code is the reference). | `Checkpoints:BlobConnectionString`, `Checkpoints:Container` | workers | Azurite locally | | `Consumer:InitialBackoff`, `Consumer:MaxBackoff`, `Consumer:MaxUnclassifiedAttempts` | workers | handler loop; max back-off 30 s, 5 attempts (`resilience.md`) | | `Teamtailor:BaseUrl`, `Teamtailor:ApiKey`, `Teamtailor:ApiVersion` | workers | API version `20240904` | -| `Teamtailor:SignatureKey` | Ingress.Api | must equal `FakeTeamtailor:SignatureKey` | | `Teamtailor:QualifiedStageName` | workers | `Qualified` locally. Only Matching.Worker maps stages, but `AddTeamtailorApi` validates it on start in both | | `Teamtailor:Resilience:*` | workers | `HttpStandardResilienceOptions` of the API client (e.g. `Retry:MaxRetryAttempts` = 2); unset keys keep the library defaults, but the section itself must exist in every `*:Resilience` row or the host fails to build | | `Crm:BaseUrl` | Sync.Worker | | diff --git a/.claude/rules/messaging.md b/.claude/rules/messaging.md index 2cb9a74..81765ee 100644 --- a/.claude/rules/messaging.md +++ b/.claude/rules/messaging.md @@ -39,9 +39,7 @@ Transport metadata goes in `EventData.Properties`, never in the envelope. The on ## Ingress.Api -1. Read the raw body (enable buffering). Verify signature v2 through the verifier interface from - `Infrastructure.Teamtailor` (see teamtailor rule). Invalid → 401. - MVP: signature verification is done last; until then the endpoint skips it. +1. Read the raw body once: the event id is computed over the bytes as sent. 2. Parse the webhook through the parser interface from `Infrastructure.Teamtailor`, build the envelope, insert into `ingress.Outbox` in one local transaction, return 200. 3. If the insert fails → 503. Teamtailor may not retry; this loss is accepted (see known limitations). diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 187501c..1110283 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -12,7 +12,7 @@ paths: | `TalentSync.Matching.Domain.Tests` | Unit, no I/O | Suggestion validation, `ConsultantProfile` behaviour | | `TalentSync.Matching.Infrastructure.Tests` | Unit, no network | Matching Platform client: request body, status mapping (4xx → Rejected), HTTP pipeline. CV masking. Enrichment adapter over `FakeChatClient`: outcome per mode, timeout, open circuit, shutdown | | `TalentSync.Sync.Application.Tests`, `TalentSync.Matching.Application.Tests` | Unit, no I/O | Use cases: each fetch result and port result → use case result. Ports replaced by small hand-written stubs | -| `TalentSync.Infrastructure.Teamtailor.Tests` | Unit, no network | Signature v2, webhook parsing, stage mapping, response mapping (404 → Deleted, 4xx → Rejected). Internals via `InternalsVisibleTo` | +| `TalentSync.Infrastructure.Teamtailor.Tests` | Unit, no network | Webhook parsing, stage mapping, response mapping (404 → Deleted, 4xx → Rejected). Internals via `InternalsVisibleTo` | | `TalentSync.Infrastructure.Messaging.Tests` | Unit, no I/O | Consumer skeleton: inbox and parking lot mapping, failure classification, handler loop | | `TalentSync.Infrastructure.Observability.Tests` | Unit, no network | `AddObservability` with the SDK enabled on a test server (the request span is a root), `SpanExportPolicy` in front of an in-memory exporter. Internals via `InternalsVisibleTo` | | `TalentSync.Sync.Infrastructure.Tests` | Unit, no network | CRM client: status mapping (404 on delete = done, 4xx → Rejected), HTTP pipeline. Internals via `InternalsVisibleTo` | @@ -79,4 +79,4 @@ pass by editing a report header or the test. The evaluation itself never runs in ## Packages Ask before adding any package, including test helpers such as `Microsoft.Extensions.TimeProvider.Testing` -(`FakeTimeProvider` for the signature replay window). +(`FakeTimeProvider`). diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e13d3c7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# dotnet format --verify-no-changes runs in CI (Build job). end_of_line is left unset on purpose: the index holds LF, +# Windows checkouts get CRLF through core.autocrlf, and both must pass. charset is unset too: dotnet ef writes +# migrations with a BOM, so every new migration would fail the check. +root = true + +[*] +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{csproj,props,targets,slnx,xml,json,yml,yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa7f2bf..a04d7b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,9 @@ jobs: - name: Build run: dotnet build --no-restore -c Release + - name: Check formatting + run: dotnet format --verify-no-changes --no-restore + # Builds again instead of passing bin/ between jobs: a restore and build take under a minute, artifacts of # every project's output would be slower and fragile. test: diff --git a/CLAUDE.md b/CLAUDE.md index d6f897b..9f5d455 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,7 @@ Key names, owners and gotchas: `.claude/rules/configuration.md` (loads when `app In scope, minimal version of everything: docker compose (Event Hubs emulator, Azurite, SQL Server), the three simulators, Ingress with outbox and relay, Sync.Worker (inbox, ACL, CRM upsert, resilience, parking lot), Matching.Worker (Qualified rule, LLM enrichment -with stored results and fallback, profile upsert). Teamtailor signature v2 verification is in scope but done last. +with stored results and fallback, profile upsert). Observability (ADR 010): OpenTelemetry traces, metrics and logs, Aspire Dashboard locally. Matching.Worker ignores `candidate.*` events in the MVP (rule D6). @@ -130,8 +130,7 @@ architecture tests. - `dotnet test` (exit code 8 = no tests ran, e.g. a project without tests yet or a filter that matched nothing) - One scenario: `dotnet test --project tests/TalentSync.IntegrationTests --filter-method "*.S2_*"` - Unit tests only: `dotnet test --project tests/TalentSync.Matching.Domain.Tests` -- EF migration, example for Sync (descriptive: verify once the first `DbContext` exists; needs the `dotnet-ef` - tool and `Microsoft.EntityFrameworkCore.Design`, ask before adding them): +- EF migration, example for Sync (`dotnet-ef` comes from the tool manifest `dotnet-tools.json`, run `dotnet tool restore` once): `dotnet ef migrations add --project src/Sync/TalentSync.Sync.Infrastructure --startup-project src/Sync/TalentSync.Sync.Worker --context SyncDbContext` - Anthropic key (I set it myself): `dotnet user-secrets set "Llm:Anthropic:ApiKey" "" --project src/Matching/TalentSync.Matching.Worker`, then `dotnet user-secrets set "Llm:Provider" "Anthropic" --project src/Matching/TalentSync.Matching.Worker` diff --git a/README.md b/README.md index 9dc7c93..39777f2 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,6 @@ matching platform, with LLM enrichment of consultant profiles. A portfolio project: a simplified model of a real-world integration. Not production code, but designed to behave correctly under failure: duplicate deliveries, retries, crashes and downstream outages. -> **Status: work in progress.** Documentation and acceptance scenarios first, code in small steps. - ## How it works ```mermaid @@ -30,24 +28,80 @@ Key decisions: - **Notify-then-fetch**: events carry only IDs; workers fetch current state from the API. - **Outbox** in Ingress; **at-least-once** delivery; **idempotent upserts** keyed by `candidateId`. - **Parking lot** instead of a dead-letter queue, which Event Hubs doesn't have. -- **LLM results stored** by candidate and CV hash before the profile upsert, so retries replay the same decision. +- **LLM results stored** by candidate, CV hash and prompt version before the profile upsert, so retries replay the + same decision. More: [architecture](docs/architecture.md), [diagrams](docs/diagrams.md), [domain](docs/domain.md), [acceptance scenarios](docs/acceptance-scenarios.md). Stack: .NET 10, Azure Event Hubs SDK (no messaging framework), SQL Server 2025 + EF Core, -`Microsoft.Extensions.Http.Resilience` (Polly v8), `Microsoft.Extensions.AI`, xUnit, Docker Compose. +`Microsoft.Extensions.Http.Resilience` (Polly v8), `Microsoft.Extensions.AI` with the Anthropic SDK (Claude), +OpenTelemetry + Aspire Dashboard, xUnit v3 + Testcontainers, Docker Compose, GitHub Actions. + +## Observability + +One webhook is one trace across the three hosts, through the outbox and Event Hubs +([ADR 010](docs/adr/010-observability.md)). Below: a job application reaches the qualified stage. Ingress stores +the webhook and the relay publishes it. Matching.Worker checks its inbox, fetches the application and the candidate, +finds no stored enrichment, calls Claude, stores the result, upserts the profile, writes the inbox and checkpoints. +Sync.Worker ignores `job_application.*` events, so its branch is only a checkpoint. + +![Trace of a qualified job application in the Aspire Dashboard](docs/images/trace-application-qualified.jpeg) + +## Testing + +- **Acceptance scenarios** ([docs/acceptance-scenarios.md](docs/acceptance-scenarios.md)) are the definition of done: + Given/When/Then examples of the domain rules and invariants, written before the code. Each automated scenario has + exactly one integration test named after its ID, e.g. `S4` (CRM outage delays but does not lose events) or `M3` + (LLM timeout yields a `NotEnriched` profile). The tests run the three hosts and the simulators in process against + SQL Server, Azurite and the Event Hubs emulator in Testcontainers, and break the simulators on purpose. +- **Unit tests** per project: the domain model, use cases, failure classification in the consumer skeleton, HTTP + clients and their resilience, the Teamtailor anti-corruption layer. +- **CI** (GitHub Actions) builds and runs everything on every PR, with a test report and a coverage summary. +- **Prompt evaluation** ([ADR 009](docs/adr/009-prompt-evaluation.md)): the enrichment prompt is scored offline + against a golden set of fictional CVs (`eval/cases/`, including a prompt injection and a Polish CV) for validity, + recall, precision, hallucinations, seniority and stability. Run by hand, never in CI, because it calls the real + model; reports are in [`eval/reports/`](eval/reports/). CI only checks that a report exists for the current prompt. + +## Decisions + +The reasoning is in [architecture decision records](docs/adr/README.md): + +| ADR | Decision | +|---|---| +| [001](docs/adr/001-project-structure.md) | Project structure by context, then by layer; a project wherever the compiler should enforce a rule | +| [002](docs/adr/002-event-hubs-over-service-bus.md) | Event Hubs over Service Bus: replay and independent consumers, at the cost of a DLQ | +| [003](docs/adr/003-notify-then-fetch.md) | Notify-then-fetch: the event is a signal, the API state is the truth | +| [004](docs/adr/004-transactional-outbox.md) | Transactional outbox in Ingress, one relay | +| [005](docs/adr/005-idempotency.md) | Idempotent effects, per-worker inbox, checkpoint after processing, stored LLM results | +| [006](docs/adr/006-partition-key-candidate-id.md) | Partition key = `candidateId`: ordering per candidate | +| [007](docs/adr/007-llm-enrichment.md) | LLM enrichment: own port, PII masking, validation, timeout and fallback to `NotEnriched` | +| [008](docs/adr/008-source-of-truth-and-sync-direction.md) | One-way sync from Teamtailor, ownership per field | +| [009](docs/adr/009-prompt-evaluation.md) | Offline evaluation of the enrichment prompt | +| [010](docs/adr/010-observability.md) | OpenTelemetry, one trace per webhook through the outbox and Event Hubs | ## Running locally -Requires .NET 10 SDK and Docker. Target commands while the setup is being built: +Requires .NET 10 SDK and Docker. + +Tests need nothing else: the integration tests start SQL Server, Azurite and the Event Hubs emulator with +Testcontainers and run the hosts and simulators in process. ```bash -docker compose -f deploy/docker-compose.yml up -d -dotnet build dotnet test ``` +For a manual run, set the SQL password once in `deploy/.env` (copy `deploy/.env.example`) and give the three hosts +the connection string in user secrets: + +```bash +for p in Ingress/TalentSync.Ingress.Api Sync/TalentSync.Sync.Worker Matching/TalentSync.Matching.Worker; do + dotnet user-secrets set "ConnectionStrings:Sql" \ + "Server=localhost,1433;Database=TalentSync;User Id=sa;Password=;TrustServerCertificate=True" \ + --project src/$p +done +``` + `Llm:Provider` is `Fake` by default, so the system runs without an LLM key. For Claude, keep the key in user secrets and switch the provider (every real call is billed): @@ -56,10 +110,11 @@ dotnet user-secrets set "Llm:Anthropic:ApiKey" "" --project src/Matching/Ta dotnet user-secrets set "Llm:Provider" "Anthropic" --project src/Matching/TalentSync.Matching.Worker ``` -To click through the whole flow by hand (success, CRM outage, candidate without a CV), start the containers and the -three hosts with `deploy/run-local.sh` (Git Bash; on Windows each host opens in its own Windows Terminal tab), then use +To click through the whole flow by hand (success, CRM outage, candidate without a CV), run `deploy/run-local.sh` +(Git Bash). It starts the containers, builds, applies the EF migrations and starts the three hosts (on Windows each +in its own Windows Terminal tab). Then use [`docs/e2e.http`](docs/e2e.http). Traces, metrics and logs of the three hosts are in the Aspire Dashboard at -http://localhost:18888 (ADR 010). +[http://localhost:18888](http://localhost:18888) (ADR 010). ## Simplifications @@ -70,6 +125,15 @@ http://localhost:18888 (ADR 010). - Undocumented Teamtailor behaviour is an explicit assumption, isolated in the adapter: candidate id in job application webhooks, `404` = deleted resource, stage via `include=stage`. +## Known limitations + +Accepted for the MVP and listed in [architecture](docs/architecture.md#known-limitations-to-be-covered-by-adrs), e.g.: + +- If SQL Server is down when a webhook arrives, Ingress returns 503 and Teamtailor may not retry, so the event is + lost. A reconciliation job against the API would close this gap. +- One outbox relay replica: several would publish a candidate's events out of order. +- A profile is not refreshed when the candidate's CV changes, only on the next qualification. + ## Working with a coding agent The code is written in small steps with a coding agent. `CLAUDE.md` and `.claude/rules/` hold its instructions, @@ -79,4 +143,6 @@ a test pass. --- -Not affiliated with Teamtailor; built from its public documentation. \ No newline at end of file +Not affiliated with Teamtailor; built from its public documentation. + +Shared for review as a portfolio project; no license is granted for reuse. \ No newline at end of file diff --git a/deploy/run-local.sh b/deploy/run-local.sh index b4a439d..b81f909 100644 --- a/deploy/run-local.sh +++ b/deploy/run-local.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Starts everything for a local run: docker compose (infrastructure and simulators), then Ingress.Api, Sync.Worker -# and Matching.Worker. On Windows each host gets its own Windows Terminal tab (stop it with Ctrl+C there); +# Starts everything for a local run: docker compose (infrastructure and simulators), build, EF migrations, then +# Ingress.Api, Sync.Worker and Matching.Worker. On Windows each host gets its own Windows Terminal tab (stop it with Ctrl+C there); # elsewhere they run in this terminal and Ctrl+C stops all three. # Containers keep running: docker compose -f deploy/docker-compose.yml stop set -euo pipefail @@ -23,6 +23,16 @@ docker compose -f deploy/docker-compose.yml up -d --wait --force-recreate --rene echo "Building..." dotnet build TalentSync.slnx --nologo --verbosity quiet +# Before the hosts start, so neither the relay nor a worker meets a missing table. Applies only pending migrations. +# The connection string comes from each host's user secrets (README, "Running locally"). +echo "Applying migrations..." +dotnet tool restore >/dev/null +dotnet ef database update --no-build --project src/Ingress/TalentSync.Ingress.Api --context IngressDbContext +dotnet ef database update --no-build --project src/Sync/TalentSync.Sync.Infrastructure \ + --startup-project src/Sync/TalentSync.Sync.Worker --context SyncDbContext +dotnet ef database update --no-build --project src/Matching/TalentSync.Matching.Infrastructure \ + --startup-project src/Matching/TalentSync.Matching.Worker --context MatchingDbContext + if command -v wt.exe >/dev/null 2>&1; then for i in "${!titles[@]}"; do # MSYS_NO_PATHCONV: Git Bash would otherwise turn `/k` into a Windows path. diff --git a/docs/acceptance-scenarios.md b/docs/acceptance-scenarios.md index cc14f4c..866253b 100644 --- a/docs/acceptance-scenarios.md +++ b/docs/acceptance-scenarios.md @@ -163,7 +163,7 @@ Scenario: M8 Candidate update is ignored by Matching ## Ingress -The test sends the request to Ingress itself. I3–I4 are done last in the MVP (signature v2). +The test sends the request to Ingress itself. ```gherkin # Illustrates: invariant 10, ADR 004 (transactional outbox) @@ -180,18 +180,6 @@ Scenario: I2 Event Hubs outage delays but does not lose events Then Ingress responds 200 When Event Hubs becomes available Then the hub receives the event with that EventId - -# Illustrates: Teamtailor signature v2 -Scenario: I3 Invalid signature is rejected - When a webhook with a TT-Signature computed with the wrong key is sent to Ingress - Then Ingress responds 401 - And ingress.Outbox has no row for that EventId - -# Illustrates: Teamtailor signature v2 (replay window) -Scenario: I4 Stale timestamp is rejected - When a correctly signed webhook with a timestamp 6 minutes in the past is sent to Ingress - Then Ingress responds 401 - And ingress.Outbox has no row for that EventId ``` ## Observability diff --git a/docs/adr/003-notify-then-fetch.md b/docs/adr/003-notify-then-fetch.md index a0f4177..32e7a3b 100644 --- a/docs/adr/003-notify-then-fetch.md +++ b/docs/adr/003-notify-then-fetch.md @@ -21,8 +21,6 @@ The webhook is treated as a signal, and the Teamtailor API as the source of trut `GET /v1/job-applications/{id}?include=stage`) and acts on that, not on the webhook. - A 404 from the API maps to `Deleted`, never to an error. -This is **not** a workaround for an unsigned payload: signature v2 signs the whole body, and Ingress verifies it. - ## Consequences - Stale or reordered events are harmless: each worker acts on the current API state, whatever order the events diff --git a/docs/adr/004-transactional-outbox.md b/docs/adr/004-transactional-outbox.md index 812daf5..320dde1 100644 --- a/docs/adr/004-transactional-outbox.md +++ b/docs/adr/004-transactional-outbox.md @@ -14,7 +14,7 @@ API, the CRM or the Matching Platform; its only job is to accept the webhook and Ingress persists the event before answering, and a separate relay publishes it. -- The webhook endpoint verifies the signature, parses the webhook, builds the envelope (IDs only) and inserts it +- The webhook endpoint parses the webhook, builds the envelope (IDs only) and inserts it into `ingress.Outbox` in one local transaction, then returns 200. If the insert fails, it returns 503. - The outbox relay is a `BackgroundService` inside Ingress.Api. In a loop it selects unpublished rows `WITH (UPDLOCK, READPAST, ROWLOCK)` ordered by `Id`, publishes each with partition key `candidateId` through a @@ -38,4 +38,3 @@ Ingress persists the event before answering, and a separate relay publishes it. - **Publishing to Event Hubs directly from the webhook endpoint.** Rejected: the webhook would then depend on Event Hubs being available, and an event that fails to publish is answered with an error that Teamtailor may not retry. - TODO: confirm this rationale. diff --git a/docs/architecture.md b/docs/architecture.md index fb604b9..db23e95 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,7 +13,7 @@ Three deployables, split by runtime behaviour, not by business capability. They | Container | Responsibility | Does not | Owns | |---|---|---|---| -| **Ingress.Api** | Verify signature, persist a notification envelope, return 200. Relay publishes outbox → Event Hubs | Call any API, interpret stages, hold domain logic | `ingress.*` | +| **Ingress.Api** | Persist a notification envelope, return 200. Relay publishes outbox → Event Hubs | Call any API, interpret stages, hold domain logic | `ingress.*` | | **Sync.Worker** | `candidate.*` → fetch candidate → upsert or delete CRM contact | Handle application events | `sync.*`, checkpoints of `sync-worker` | | **Matching.Worker** | `job_application.*` → qualified? → fetch candidate + CV → enrichment (stored) → upsert profile | Decide matches, block on LLM failure, handle `candidate.*` (D6) | `matching.*`, checkpoints of `matching-worker` | | SQL Server | Three schemas, one owner each | Act as a channel between workers | | @@ -28,6 +28,7 @@ Three deployables, split by runtime behaviour, not by business capability. They README.md for humans: overview, running locally, simplifications TalentSync.slnx solution, folders mirror the directories below global.json SDK pin, test runner = Microsoft.Testing.Platform +dotnet-tools.json local tool manifest: dotnet-ef (migrations, used by deploy/run-local.sh) Directory.Build.props net10.0, nullable, implicit usings, warnings as errors Directory.Packages.props central package versions (CPM) src/ @@ -39,7 +40,7 @@ src/ TalentSync.Infrastructure.Teamtailor ACL: TeamtailorRecruitmentSource (port adapter) over TeamtailorApiClient (HTTP, status mapping) and TeamtailorMapper, internal JSON:API DTOs, 429 handling, stage mapping from config, 404 → Deleted, 4xx / unmappable → Rejected, - signature v2, webhook parsing, public AddTeamtailorWebhooks() (Ingress) + webhook parsing, public AddTeamtailorWebhooks() (Ingress) and AddTeamtailorApi() (workers). → Recruitment.Domain TalentSync.Infrastructure.Observability AddObservability(): OpenTelemetry traces, metrics and logs over OTLP, subscribes to every TalentSync.* ActivitySource and Meter, HTTP and SQL @@ -71,7 +72,7 @@ src/ composition root, group "matching-worker". Web host: only /health/live (liveness for an orchestrator, no dependency checks) simulators/ fake external systems, NOT part of the product (see simulators/CLAUDE.md). - FakeTeamtailor No references to src/. Signature v2 is implemented independently. + FakeTeamtailor No references to src/. FakeCrm FakeMatchingPlatform tests/ @@ -80,7 +81,7 @@ tests/ TalentSync.Matching.Application.Tests use case: qualified / not qualified / deleted / rejected, stored or new enrichment (stored before the upsert), failure → NotEnriched TalentSync.Matching.Infrastructure.Tests Matching Platform client: request body, status mapping, resilience - TalentSync.Infrastructure.Teamtailor.Tests signature v2, webhook parsing, stage mapping, 404 → Deleted, 4xx → Rejected + TalentSync.Infrastructure.Teamtailor.Tests webhook parsing, stage mapping, 404 → Deleted, 4xx → Rejected TalentSync.Infrastructure.Messaging.Tests consumer skeleton: inbox / parking lot mapping, failure classification, handler loop TalentSync.Infrastructure.Observability.Tests request span is a root with the SDK enabled, SpanExportPolicy TalentSync.Sync.Infrastructure.Tests CRM client: status mapping, 404 on delete = done, resilience @@ -92,9 +93,10 @@ eval/ offline evaluation of the enrichment reports/ committed reports; local/ is gitignored run-eval.sh runs the evaluation locally; provider, runs, output and model as variables at the top deploy/ docker-compose.yml (infra + simulators + Aspire Dashboard), simulator.Dockerfile, - eventhubs/Config.json, run-local.sh (containers + the three hosts) + eventhubs/Config.json, run-local.sh (containers, migrations, the three hosts) docs/ domain.md, architecture.md, acceptance-scenarios.md, diagrams.md, - adr/ (index: adr/README.md), e2e.http (end-to-end demo by hand) + adr/ (index: adr/README.md), e2e.http (end-to-end demo by hand), + images/ (screenshots for the README) .github/workflows/ci.yml on PRs and main: Build job, then Test job ("Test results" check, coverage summary) .claude/rules/ topic rules, some load only for matching paths .claude/settings.json permissions and hooks enforced by the harness @@ -128,9 +130,8 @@ Deliberate asymmetries: Ingress has no domain and is one project; Sync has no bu - `Infrastructure.*` projects reference the layer whose ports they implement. - Only hosts (composition roots) reference `Infrastructure.Teamtailor`. Its public surface is `AddTeamtailorWebhooks(IServiceCollection, IConfiguration)` for Ingress, `AddTeamtailorApi(IServiceCollection, - IConfiguration)` for the workers (registers `IRecruitmentSource`), plus two interfaces that Ingress needs, registered by - `AddTeamtailorWebhooks`: a webhook signature verifier and a webhook parser, and the result type the parser returns - (proposed names: `IWebhookSignatureVerifier`, `IWebhookParser`, `WebhookNotification`). + IConfiguration)` for the workers (registers `IRecruitmentSource`), plus what Ingress needs, registered by + `AddTeamtailorWebhooks`: the webhook parser `IWebhookParser` and the result type it returns. Implementations and JSON:API DTOs are `internal`. This is the only place that defines the public surface. - Each worker's `DbContext` lives in its own `*.Infrastructure`, so the other worker cannot see its schema. - Ingress does not reference `Infrastructure.Messaging`: it only produces. @@ -145,7 +146,6 @@ Deliberate asymmetries: Ingress has no domain and is one project; Sync has no bu | Consumer invariants (inbox, checkpoint, never throw, parking lot) are implemented once | `Infrastructure.Messaging` | | The skeleton classifies failures without knowing adapters | Permanent failures are `Rejected` values translated by the host; transient ones are BCL / Polly / Azure exceptions | | Application does not depend on Infrastructure | Project references (compiler) | -| A bug in our signature verifier is not mirrored in the signer | FakeTeamtailor implements signature v2 independently | ## Service registration (normative) diff --git a/docs/diagrams.md b/docs/diagrams.md index 0e32fd9..d6cb66f 100644 --- a/docs/diagrams.md +++ b/docs/diagrams.md @@ -9,7 +9,7 @@ Keep these diagrams true when a container or the flow changes. flowchart TB tt["FakeTeamtailor
webhooks + JSON:API"] subgraph ing["Ingress.Api"] - ep["POST /webhooks/teamtailor
signature v2"] + ep["POST /webhooks/teamtailor"] outbox[("ingress.Outbox")] relay["Outbox relay"] end @@ -72,8 +72,9 @@ sequenceDiagram The same flow is one trace in the Aspire Dashboard (ADR 010). Each webhook request is its root; the outbox and Event Hubs hops are carried by `ingress.Outbox.TraceParent` and the `traceparent` event property. Both consumer -groups appear as branches, Sync.Worker's skipping the event. Span names as the dashboard shows them; the right-hand -column is a comment: +groups appear as branches, Sync.Worker's skipping the event. Span names as exported (the dashboard displays messaging +spans as `MSG eventhubs ats-events` and HTTP spans as `HTTP GET 200`); the right-hand column is a comment. +Screenshot: [`images/trace-application-qualified.jpeg`](images/trace-application-qualified.jpeg). ``` POST /webhooks/teamtailor ingress-api @@ -87,6 +88,7 @@ POST /webhooks/teamtailor ingress-api │ ├─ SELECT [matching].[EnrichmentResults] │ ├─ enrich enriched; only if no stored result │ │ └─ chat claude-haiku-5-5 model, tokens; "chat" with the Fake provider + │ │ └─ POST api.anthropic.com Anthropic provider only │ ├─ INSERT matching.EnrichmentResults │ ├─ PUT matching-platform /profiles │ ├─ INSERT matching.Inbox diff --git a/docs/e2e.http b/docs/e2e.http index f9a88bc..8c18543 100644 --- a/docs/e2e.http +++ b/docs/e2e.http @@ -1,7 +1,7 @@ # End-to-end demo, clicked through by hand (VS Code REST Client or Visual Studio). # -# Before: docker compose -f deploy/docker-compose.yml up -d, then run Ingress.Api, Sync.Worker and Matching.Worker -# from the IDE or with `dotnet run`. Llm:Provider = Anthropic calls the real model (billed); Fake works too. +# Before: the one-time setup in README "Running locally" (deploy/.env, connection string), then deploy/run-local.sh. Llm:Provider = Anthropic calls the real +# model (billed); Fake works too. Each webhook a request triggers is one trace in the Aspire Dashboard: http://localhost:18888 # # Candidate ids must be new on every run: bump @run, or restart the simulators (their state is in memory). diff --git a/docs/images/trace-application-qualified.jpeg b/docs/images/trace-application-qualified.jpeg new file mode 100644 index 0000000..ffb8989 Binary files /dev/null and b/docs/images/trace-application-qualified.jpeg differ diff --git a/simulators/CLAUDE.md b/simulators/CLAUDE.md index 478d137..99e2a73 100644 --- a/simulators/CLAUDE.md +++ b/simulators/CLAUDE.md @@ -38,8 +38,6 @@ Two separate surfaces, plus the test endpoints above: A state change that changes nothing (same stage, same values) sends no webhook, like Teamtailor. - `apply` with an existing `candidateId` adds an application to that candidate (no `candidate.create`); without one a new candidate is created. Tests pass their own ids (`c-s1`, ...) so `/calls` counts never mix between scenarios. -- Webhooks are signed with signature v2 using `FakeTeamtailor:SignatureKey` (must equal `Teamtailor:SignatureKey` - in Ingress). Own implementation, never shared with `src/`. - Anomaly modes: - `rate-limit` (needs `seconds`): `/v1` returns 429 with `X-Rate-Limit-*` headers; `X-Rate-Limit-Reset` = seconds until the mode ends, rounded up. @@ -49,7 +47,6 @@ Two separate surfaces, plus the test endpoints above: - `drop-webhook`: state changes of the candidate apply, but no webhook is sent (a delivery Teamtailor never made). Scenario S8 uses it for a candidate that has no CRM contact. - The header check runs first: a wrong key still gets 401 while an anomaly mode is on. - - No signature modes: invalid and stale signatures are covered by scenarios I3 and I4, which call Ingress directly. - `/v1` requires `Authorization: Token token=...` and `X-Api-Version`; reject without them (400/401) so the ACL is tested. - Stages: `New`, `Screening`, `Interview`, `Qualified`, `Rejected`. - Deleted candidates return 404 from `/v1`. diff --git a/simulators/FakeTeamtailor/Teamtailor/WebhookSender.cs b/simulators/FakeTeamtailor/Teamtailor/WebhookSender.cs index acbc53c..3b9b0b0 100644 --- a/simulators/FakeTeamtailor/Teamtailor/WebhookSender.cs +++ b/simulators/FakeTeamtailor/Teamtailor/WebhookSender.cs @@ -10,7 +10,6 @@ public sealed record WebhookDelivery(string EventName, int? StatusCode); /// /// Posts { "payload": { "event_name", "data" } } to Ingress; data comes from . -/// Not signed yet: signature v2 comes last, together with the verifier in Ingress. /// public sealed class WebhookSender(HttpClient http, IConfiguration configuration, AnomalyRules anomalies, ILogger logger) { diff --git a/src/Ingress/TalentSync.Ingress.Api/Webhooks/TeamtailorWebhookEndpoint.cs b/src/Ingress/TalentSync.Ingress.Api/Webhooks/TeamtailorWebhookEndpoint.cs index b92f3b0..7df593d 100644 --- a/src/Ingress/TalentSync.Ingress.Api/Webhooks/TeamtailorWebhookEndpoint.cs +++ b/src/Ingress/TalentSync.Ingress.Api/Webhooks/TeamtailorWebhookEndpoint.cs @@ -32,14 +32,11 @@ private static async Task HandleAsync( { var logger = loggerFactory.CreateLogger(typeof(TeamtailorWebhookEndpoint)); - // Raw bytes, read once: the event id (and later signature v2) is computed over the body as sent, - // never over re-serialised JSON. + // Raw bytes, read once: the event id is computed over the body as sent, never over re-serialised JSON. using var buffer = new MemoryStream(); await request.Body.CopyToAsync(buffer, cancellationToken); var rawBody = buffer.ToArray(); - // TODO(MVP, last step): verify TT-Signature v2 here and return 401 when it is invalid. - switch (parser.Parse(rawBody)) { case WebhookParseResult.Parsed parsed: diff --git a/src/Matching/TalentSync.Matching.Infrastructure/Persistence/EnrichmentResultStore.cs b/src/Matching/TalentSync.Matching.Infrastructure/Persistence/EnrichmentResultStore.cs index 1f8e09f..6a03bab 100644 --- a/src/Matching/TalentSync.Matching.Infrastructure/Persistence/EnrichmentResultStore.cs +++ b/src/Matching/TalentSync.Matching.Infrastructure/Persistence/EnrichmentResultStore.cs @@ -39,10 +39,7 @@ public async Task SaveAsync( await db.SaveChangesAsync(cancellationToken); return enrichment; } - catch (DbUpdateException exception) when (exception.InnerException is SqlException - { - Number: UniqueConstraintViolation or UniqueIndexViolation, - }) + catch (DbUpdateException exception) when (exception.InnerException is SqlException { Number: UniqueConstraintViolation or UniqueIndexViolation }) { // Another processor enriched the same CV with the same prompt first (e.g. after a partition moved mid-event). Its answer wins, // so both upserts send the same suggestion. diff --git a/src/Shared/TalentSync.Infrastructure.Messaging/Persistence/ConsumerStore.cs b/src/Shared/TalentSync.Infrastructure.Messaging/Persistence/ConsumerStore.cs index ed57b2d..4ceae12 100644 --- a/src/Shared/TalentSync.Infrastructure.Messaging/Persistence/ConsumerStore.cs +++ b/src/Shared/TalentSync.Infrastructure.Messaging/Persistence/ConsumerStore.cs @@ -29,10 +29,7 @@ public async Task MarkHandledAsync(InboxEntry entry, CancellationToken cancellat { await db.SaveChangesAsync(cancellationToken); } - catch (DbUpdateException exception) when (exception.InnerException is SqlException - { - Number: UniqueConstraintViolation or UniqueIndexViolation, - }) + catch (DbUpdateException exception) when (exception.InnerException is SqlException { Number: UniqueConstraintViolation or UniqueIndexViolation }) { // Invariant 12: the unique index, not a lookup, decides that the event was already handled. } diff --git a/src/Shared/TalentSync.Infrastructure.Teamtailor/DependencyInjection.cs b/src/Shared/TalentSync.Infrastructure.Teamtailor/DependencyInjection.cs index 6909cc9..ce47723 100644 --- a/src/Shared/TalentSync.Infrastructure.Teamtailor/DependencyInjection.cs +++ b/src/Shared/TalentSync.Infrastructure.Teamtailor/DependencyInjection.cs @@ -20,7 +20,7 @@ namespace TalentSync.Infrastructure.Teamtailor; public static class DependencyInjection { /// Webhook parsing for Ingress. - /// Not read yet. The signature verifier will bind the Teamtailor section from it. + /// Not read. public static IServiceCollection AddTeamtailorWebhooks(this IServiceCollection services, IConfiguration configuration) { ArgumentNullException.ThrowIfNull(configuration);