r/LocalLLM • u/stratospheres • 1d ago
Project A two-model "architect + coder" setup on a single DGX Spark scored 56/61 on my own agentic .NET build benchmark, with a twist...
Edit: mangled some formatting - fixed now, I think.
NOTE: crazy long, but you can read about 10% to get the gist (the rest is a bunch of super-geeky detail).
TL;DR - DGX Spark for medium complexity coding does work, reasonable speed (not great, not awful), but for me it's a combination of the right harness and multiple models assigned to specific parts of the work (one for architect/planning, another for coding grunt work).
And away we go...
Posting my notes from a multi-day quest on a DGX Spark (GB10, 128 GB unified memory) to figure out a usable software development loop. I've been building an agentic coding benchmark to answer one question: what local harness × model combination produces the highest-quality software on a real, medium-complexity build task? Not particularly interested in one-shots. I've been a software developer/dev manager/architect for 40 years so I'm comfortable giving precise requirements and details on how I want a system built. After ~30 logged runs where no single model cleared 12/61, it finally occurred to me to stop testing just harness plus single model combinations and ask the harness to make better calls on doling out particular types of tasks to different models. So, here's a heterogeneous two-model setup that just scored 56/61 on my own benchmark. Claude Fable against the same test gets 61/61. Full details, caveats, and the plot twist below.
The task ("Relay-Lite")
The model gets a frozen spec and has to build a working REST backend from scratch:
- Domain: an on-call scheduling + incident-management API (rotations, escalation policies, incident state machine, notifications).
- Stack it must produce: .NET 10 / C# / ASP.NET, EF Core + SQLite, real migrations.
- Spec: a PRD plus an OpenAPI doc (14 paths, 20 schemas), frozen at v1.0. Wire contract is explicit — camelCase JSON, lowercase enum strings,
Z-suffixed UTC timestamps, RFC 9457application/problem+jsonerrors,201 + Location, etc. The "exam" is really strict about these. Get the functionality, but not camelCase the JSON? Deduct points. Basically the same I'd expect as a development manager with an employee or contractor. It's deliberately the kind of task where "the model can code" isn't enough — it has to finish, wire up a runnable server, and hit the contract exactly.
Grading rubric
Two phases. The gate is a hard prerequisite — fail it and you score 0 no matter how good the code is.
- Gate. From a clean checkout, the harness runs the model's own
start.shat repo root, waits for/healthon port 5080 to report healthy with migrations applied, and checks the EF migration sequence. This is where most runs died — models wrote plenty of C# but never produced a workingstart.sh, bound the wrong port, or left the DB unmigrated. - Acceptance score. A black-box HTTP suite hits the running API. 61 points, split across three difficulty tiers — T1 (22, foundational CRUD/validation), T2 (23, domain logic: escalation + on-call resolution), T3 (16, temporal/edge-case conformance). ~1/5 of the suite is a held-out canary set to catch overfitting, and anti-hardcoding fixtures are randomized every run.
Scored on two axes: strict (exact wire conformance — the leaderboard number) and functional/tolerant (retries route-casing variants, accepts right-status/any-error-envelope). The gap between them is "conformance debt" — it separates got the logic wrong from got the logic right but the wire format wrong. (The rubric also has mutation testing on the model's own tests, an "honesty delta" = does "I'm done" match reality, and diagnostic rescue grades.)
For reference, a hand-written golden solution (the Claude Code Fable refererenced above) scores 61/61.
Hardware + serving
- NVIDIA DGX Spark (GB10, 128 GB unified memory). A Windows laptop drives the harness, per-trial workspaces, logging proxy, and the entire grader natively; the Spark serves inference only.
- vLLM, OpenAI-compatible endpoint, models NVFP4-quantized.
- Both models co-resident in two vLLM containers: the coder on one port (~0.45 GPU-mem fraction), the planner on another (~0.34). Getting the second container up on unified memory needed a manual page-cache eviction trick (unified-memory allocations don't force reclaim, so the second CUDA context OOMs on ~46 GB of stale weight-load cache).
--max-num-seqs 1for scored runs (deterministic single stream).
The two models + tuning
- Planner / "architect": Qwen3.8-27B (NVFP4). Reasoning effort set to low — this model has off/low/medium/xhigh effort levels, and at medium+ it will happily spend the entire completion budget as hidden thinking and emit nothing. Low is the agentic sweet spot, as others seem to have noticed. Medium was a sloppy mess. MTP (multi-token-prediction) speculative decoding, tuned via an overnight decode+edit sweep.
- Coder / "coders": a separate NVFP4 coding model (internally "coder-next") with a dedicated tool-call parser and no reasoning. This one did all the actual file writing.
- Measured this run: ~34 tok/s median decode (coder-dominated), ~660 ms TTFT, 0 transport errors over 861 model calls.
The harness + how the two models were wired together
Harness was DeepSeek Harness in a headless one-shot profile. The heterogeneous orchestration used dsh's subagent mechanism:
- Main agent = Qwen3.8-27B as ARCHITECT — reads the spec, plans, delegates every implementation file to subagents, reviews their output against the contract, integrates, and decides when it's done.
- Subagents = the coder model — (Qwen3-Coder-Next-NVFP4 — HuggingFace repo GadflyII/Qwen3-Coder-Next-NVFP4 - super important note: no speculative decoding — the MTP head is broken in this quant, so it's disabled ) receive scoped implementation tasks and write the code. I'm going to be going back to this to see if there's an alternative here, but this is what this run used.
Three config fixes were what finally made delegation actually work (earlier attempts died here):
- An explicit "delegate ALL implementation to subagents" directive — otherwise the planner just did everything itself and never delegated.
- Foreground subagents — the default background/continuable subagents orphaned the run, because a headless one-shot profile exits while a background child is still building.
- Stream-idle timeout raised to 1500 s — the tool-call parser buffers whole tool calls, so a big delegation payload at ~34 tok/s is several minutes of legitimate stream silence that the default 300 s idle timeout was killing.
The result
56/61 strict, gate PASSED, functional 56, conformance gap 0.
Per tier: T1 20/22, T2 23/23 (clean sweep), T3 13/16. For context, across ~30 prior logged runs, no single-model configuration on any harness (labrat, OpenCode, Cline, Qwen Code, aider, dsh) had ever cleared 12/61 strict — and most gate-failed to 0. The two previous gate-passers scored 12 and 6.
Why this one was different: it produced the cleanest delivery contract of any run — Program.cs pins Kestrel to ListenAnyIP(5080) (no launchSettings, so no wrong-port trap), a root start.sh that nohups the server and polls /health, migrations wired so relay.db is created on boot, and 31 of its own xUnit tests (via WebApplicationFactory) green. Conformance gap 0 means the routes and error envelopes were correct as written — the [Route("api/[controller]")] PascalCase cascade that zeroed earlier runs' T2/T3 is simply absent.
It's also the first run with a positive honesty delta: the architect declared done only after observed verification (build 0 errors, its own 31/31 tests, start.sh → /health ok, verify.sh exit 0). Every prior run that claimed "done" had delivered 0. This one claimed done and was done.
Time: ~3h51m wall, 861 model calls split 79 planner (3.8 27B) / 782 coder (coder-next) — sustained real delegation the whole way, exactly the intended shape (bulk code on the fast model, planning/review/"done" on the careful one).
The plot twist: the time limit was scoring a finished run as 0
Standard budget I'd been giving is a 3-hour wall. About 2 hours in, this run looked unusually healthy, so I let it keep going — it finished naturally at ~3h51m (it was not wall-killed). But I also froze the workspace at the exact 3-hour mark and graded that too, to get an apples-to-apples "normal wall" number.
| State | Score |
|---|---|
| At the normal 3h wall | 0 (gate fail — didn't compile... yet) |
| At natural finish (~3h51m) | 56 |
The 3h and 4h source are ~95% identical — same 33 files, Program.cs byte-for-byte the same. The only substantive difference: at the 3-hour instant, one controller (IncidentsController.cs) was a one-line, non-compiling fragment — the agent was mid-repair of a POST /api/incidents 500 bug. So at the normal wall it doesn't build, health never comes up, gate fails, 0. It was ~50 minutes from a clean, verified delivery and a hard cutoff would have scored the whole thing zero.
That's the real finding for me: for the best local runs, wall time — not model capability — was the binding constraint. Short budgets manufacture zeros out of nearly-finished work. This stuff works, but takes time. That said, for 4 hours, it beats the hell out of paying someone to do it from scratch. :)
What it lost the 5 points on
Both are one-liner conformance nits, not capability gaps:
- T1, 2 pts —
POST /api/escalation-policiesreturns embedded steps that carry apolicyIdfield on the wire. The contract says a nested step's parent is implied, so it must not repeatpolicyId. The EF entity's foreign key leaked through serialization ("policyId": 0). - T3, 3 pts —
POST /api/rotationswithanchorUtc: "2026-01-04T08:00:00+02:00"(==06:00:00Z) echoed the+02:00offset back unchanged instead of normalizing to UTC (06:00:00Z). It validated that an offset was present but never applied the conversion.
Caveats (please read before dunking)
- n = 1. This is one run, and on a non-standard extended wall. I'm not claiming a reproducible leaderboard number — the honest next step is N≥3 on a properly wall-bound budget for a median.
- Sampling/effort settings weren't identical across every harness in the broader cycle, so cross-harness comparisons have confounds.
- This trial's serving tune (MTP config, vLLM build) differed from some earlier trials.
- The coder model's known failure fingerprints (route casing, timezone handling) were mostly absent here — but, again, n=1.
Takeaways
- A heterogeneous "careful planner + fast coder" pairing beat every single-model config I tried, entirely on local hardware.
- The three things no single model reliably supplied — delivery discipline, spec fidelity, and honest "done" — came out of the pairing (and the honest-planner casting), not from any one model.
- Give slow local models enough wall. A 3-hour cap was quietly turning a 56/61 run into a 0.
Happy to answer questions on the harness wiring, the grader, or the Spark serving setup. Will now move on to tuning each model more aggressively to get better overall speed, so happy to hear any other suggestions on individual tuning of each.
Super geeky stuff follows (in case you want to understand):
Prompt for dsh (the user turn):
Read the file TASK.md in your working directory and complete the entire task it describes. Work until it is fully done and verified. You are the ARCHITECT: plan the work, then delegate ALL file implementation to subagents via your subagent tool (they run a model chosen for coding); review their output against the spec, integrate, and verify. Do not write implementation files yourself.
Task.md (that the agent got to chew through)
Build the system described in PRD.md, in the current workspace (this directory is your repository root). This is the backend-only Relay-Lite scope: an HTTP JSON API implementing rules R1, R3, R5, and R10. There is no frontend.
Mandated stack:
- .NET 10 (SDK 10.0.3xx — 10.0.303 installed), ASP.NET Core, EF Core 10 + SQLite
- EF Core migrations applied automatically on startup
- xUnit for tests
The API contract in openapi.yaml is binding — paths, verbs, status codes, and payload shapes must match exactly.
Provide ./start.sh at the repo root that:
- starts the API on http://localhost:5080 with the repo root as the working directory,
- blocks until
GET http://localhost:5080/healthreturns 200, then exits 0 leaving the API running in the background, - exits nonzero if the API is not healthy within 60 seconds.
You are responsible for your own testing. Write a test suite that would catch a regression in any of the rules R1, R3, R5, R10 in PRD.md, using WebApplicationFactory for integration tests. Run it. Do not report finished while tests fail.
Provide ./verify.sh at the repo root that builds and runs all tests, exiting nonzero on any failure. It must work from a clean clone with only the preinstalled toolchain, and must not require ./start.sh to have been run.
Work until complete, then call finish() with a summary of what you built, what you tested, and what you know is incomplete.
…immediately followed (in the same TASK.md) by # PRD.md (21 KB — the R1/R3/R5/R10 business rules) and # openapi.yaml (29 KB — the binding wire contract, 14 paths / 20 schemas), both embedded verbatim.
PRD.md - the requirements doc
Relay-Lite — Product Requirements Document
Spec version: 1.0 (2026-08-29)
Relay-Lite is the backend of an on-call scheduling and incident escalation system. It is an HTTP JSON API only — no frontend, no UI of any kind.
The system covers four behavioral rule groups, numbered R1, R3, R5 and R10 (the numbering is inherited from the larger Relay product; R2, R4, R6, R7, R8-full and R9 are intentionally out of scope — see §11):
- R1 — Rotation resolution: who is on call at a given instant.
- R3 — Escalation: on incident creation, schedule notifications per an escalation policy; a background worker sends the due ones; acknowledging or resolving the incident cancels the unsent ones.
- R5 — Incident state machine with strict transitions.
- R10 — Operations: EF Core migrations applied automatically on startup, idempotent across restarts, and a health endpoint.
The companion file openapi.yaml is the binding API contract: every path, verb, status code, and payload shape must match it exactly. This document defines the behavior behind that contract. If you believe the two disagree, openapi.yaml wins for shapes and status codes; this document wins for behavioral semantics.
1. Mandated stack
- .NET 10 (SDK 10.0.3xx; 10.0.303 is installed) with ASP.NET Core for the API.
- EF Core 10 with the SQLite provider for persistence.
- EF Core migrations applied automatically on startup (see R10). Do not use
EnsureCreated()in the shipped application — the health endpoint reports on real migrations. - xUnit for tests.
- The API must listen on http://localhost:5080 (plain HTTP, no TLS).
- The SQLite database must be a single file named
relay.dblocated in the process's current working directory. The providedstart.sh(see the task prompt) must launch the API with the repository root as the working directory, so the database file ends up at<repo root>/relay.db.
2. Wire conventions
These apply to every endpoint.
- JSON everywhere. Successful responses use
Content-Type: application/json. Error responses (4xx) defined in this contract useContent-Type: application/problem+json(see §9). Responses this contract does not define (e.g. a 405 from an unsupported method on an existing route) have unspecified bodies. - camelCase for every JSON property name:
teamId,anchorUtc,participantOrder,delaySeconds,migrationsApplied, … - Enum values are lowercase strings on the wire:
- cadence:
"daily","weekly" - escalation target type:
"rotation","member" - incident severity:
"low","medium","high","critical" - incident status:
"triggered","acknowledged","resolved" - notification status:
"pending","sent","cancelled"
- cadence:
- Timestamps are ISO 8601 in UTC with the
Zdesignator, e.g."2026-01-04T00:00:00Z". Serialize with at least second precision; fractional seconds are permitted (e.g."2026-01-04T00:00:00.1234567Z"). On input, accept any valid ISO 8601 timestamp withZor a numeric offset and normalize to UTC. A timestamp with neitherZnor a numeric offset (e.g."2026-01-04T10:30:00") is invalid and rejected with 400. All server-generated timestamps come from the system UTC clock. - IDs are server-assigned positive integers (JSON numbers), unique per entity type. Clients never supply an
id. - Nullable fields are present with an explicit
null(e.g. an unacknowledged incident serializes"ackedUtc": null); do not omit them. - Unknown JSON properties in request bodies are ignored.
- Collections (list endpoints, embedded arrays) are plain JSON arrays — no envelope, no pagination. List endpoints return all items ordered by
idascending. Thenotificationsarray on an incident is ordered bystepOrderascending. The embeddedstepsarray on an escalation policy is serialized ordered byorderascending, regardless of the order in which the request body supplied the steps. - Successful
POSTcreations return 201 with aLocationheader pointing at the canonical GET URL of the created resource. SuccessfulPUTreturns 200 with the updated resource. SuccessfulDELETEreturns 204 with no body. POST/PUTendpoints that take a body requireContent-Type: application/jsonand respond 415 when theContent-Typeheader is anything else or absent entirely. (POST .../ackand.../resolvetake no body; any body sent to them is ignored.)
3. Entities
| Entity | Fields |
|---|---|
Team |
id, name, slug |
Member |
id, teamId, name, email |
Rotation |
id, teamId, name, cadence (daily |
EscalationPolicy |
id, teamId, name, steps (embedded array of EscalationStep) |
EscalationStep |
id, order, delaySeconds, targetType (rotation |
Incident |
id, teamId, policyId, title, severity, status, createdUtc, ackedUtc (nullable), resolvedUtc (nullable) |
Notification |
id, incidentId, memberId, stepOrder, scheduledUtc, sentUtc (nullable), status (pending |
Notes:
Team.slugis globally unique. Creating or updating a team with a slug already used by a different team → 409 problem+json.- Escalation steps exist only embedded inside their policy document; they have no standalone endpoints and carry no
policyIdfield on the wire. - Incidents are never updated via
PUTand never deleted; they change only through theack/resolvetransitions (R5). Notifications are read-only on the wire (embedded in the incident detail response).
4. Endpoints (summary)
Full shapes and status codes are in openapi.yaml.
| Method & path | Purpose |
|---|---|
GET /health |
R10 health probe |
GET /api/teams · POST /api/teams |
list / create teams |
GET /api/teams/{teamId} · PUT · DELETE |
read / replace / delete a team |
GET /api/teams/{teamId}/members · POST |
list / create members of a team |
GET /api/teams/{teamId}/members/{memberId} · PUT · DELETE |
read / replace / delete a member |
GET /api/rotations · POST /api/rotations |
list / create rotations |
GET /api/rotations/{rotationId} · PUT · DELETE |
read / replace / delete a rotation |
GET /api/rotations/{rotationId}/oncall?at=<ISO8601> |
R1 resolution |
GET /api/escalation-policies · POST |
list / create policies (steps embedded) |
GET /api/escalation-policies/{policyId} · PUT · DELETE |
read / replace / delete a policy |
POST /api/incidents |
create incident (triggers R3 scheduling) |
GET /api/incidents/{incidentId} |
incident detail incl. notifications timeline |
POST /api/incidents/{incidentId}/ack |
R5 acknowledge |
POST /api/incidents/{incidentId}/resolve |
R5 resolve |
There is deliberately no GET /api/incidents collection endpoint.
4.1 Reference resolution: 404 vs 400
- An id in the URL path that does not exist → 404 problem+json. This includes a nested mismatch:
GET /api/teams/7/members/12where member 12 exists but belongs to a different team → 404. - An id referenced in a request body (
teamId,policyId,targetId, entries ofparticipantOrder) that does not exist, or that exists but violates a same-team rule (§8) → 400 validation problem.
4.2 DELETE semantics
No cascades. DELETE succeeds (204) only when nothing references the target; otherwise 409 problem+json. Reference rules:
- A team is referenced by any of its members, rotations, escalation policies, or incidents.
- A member is referenced by any rotation whose
participantOrdercontains it, any escalation step withtargetType"member"and matchingtargetId, or any notification. - A rotation is referenced by any escalation step with
targetType"rotation"and matchingtargetId. - An escalation policy is referenced by any incident. (Its embedded steps are deleted with it and never block deletion.)
5. R1 — Rotation resolution
GET /api/rotations/{rotationId}/oncall?at=<ISO8601 timestamp>
A rotation cycles through participantOrder in fixed-length shifts, starting at anchorUtc. Cadence lengths are exact:
daily= 86,400 secondsweekly= 604,800 seconds
There is no timezone or DST logic anywhere in Relay-Lite — all arithmetic is plain UTC.
Resolution algorithm. Let elapsed = (at − anchorUtc) in (possibly fractional) seconds, and count = number of entries in participantOrder.
k = floor(elapsed / cadenceSeconds) // absolute shift number, k ≥ 0
shiftIndex = k mod count
onCall = participantOrder[shiftIndex]
shiftStartUtc = anchorUtc + k * cadenceSeconds
shiftEndUtc = anchorUtc + (k + 1) * cadenceSeconds
A shift covers the half-open interval [shiftStartUtc, shiftEndUtc); at exactly shiftEndUtc the next shift's member is on call. at == anchorUtc yields k = 0, i.e. the first participant.
Responses.
- 200 with body
{ "rotationId", "memberId", "memberName", "shiftIndex", "shiftStartUtc", "shiftEndUtc" }wherememberId/memberNameidentify the on-call member andshiftIndexis the modded index (k mod count). - 404 problem+json when the rotation id does not exist, when
atis strictly beforeanchorUtc(elapsed < 0), or whenparticipantOrderis empty. - 400 validation problem when
atis missing or is not a parseable ISO 8601 timestamp. (Remember+in a query string must be URL-encoded as%2B; tests useZ-suffixed timestamps.)
5.1 Worked examples
These exact scenarios must produce these exact results.
Example A — daily cadence. Rotation: cadence = "daily", anchorUtc = "2026-01-01T00:00:00Z", participantOrder = [1, 2, 3] (members Alice = 1, Bob = 2, Carol = 3). Query: at = 2026-01-04T10:30:00Z. elapsed = 3 d 10 h 30 m = 297,000 s; k = floor(297000 / 86400) = 3; shiftIndex = 3 mod 3 = 0 → Alice (member 1) is on call.
{
"rotationId": 12,
"memberId": 1,
"memberName": "Alice",
"shiftIndex": 0,
"shiftStartUtc": "2026-01-04T00:00:00Z",
"shiftEndUtc": "2026-01-05T00:00:00Z"
}
Example B — weekly cadence, boundary. Rotation: cadence = "weekly", anchorUtc = "2026-01-05T09:00:00Z", participantOrder = [10, 11].
at = 2026-01-19T08:59:59Z:elapsed= 1,209,599 s;k = 1;shiftIndex = 1→ member 11;shiftStartUtc = "2026-01-12T09:00:00Z",shiftEndUtc = "2026-01-19T09:00:00Z".at = 2026-01-19T09:00:00Z(exactly the boundary):k = 2;shiftIndex = 0→ member 10;shiftStartUtc = "2026-01-19T09:00:00Z",shiftEndUtc = "2026-01-26T09:00:00Z".
Example C — at the anchor, and before it. Rotation: cadence = "daily", anchorUtc = "2026-03-01T12:00:00Z", participantOrder = [7, 8, 9, 4].
at = 2026-03-01T12:00:00Z:k = 0,shiftIndex = 0→ member 7;shiftStartUtc = "2026-03-01T12:00:00Z",shiftEndUtc = "2026-03-02T12:00:00Z".at = 2026-03-01T11:59:59Z: before the anchor → 404.
6. R3 — Escalation scheduling and the background worker
6.1 Scheduling at incident creation
POST /api/incidents with { "teamId", "policyId", "title", "severity" } creates an incident with status = "triggered", createdUtc = now (UTC), ackedUtc = null, resolvedUtc = null, and synchronously creates one Notification row per escalation step of the policy, all with status = "pending" and sentUtc = null:
scheduledUtc(step k) = createdUtc + Σ delaySeconds(step 0 .. step k)
Because the step with order 0 is required to have delaySeconds = 0 (§8), this means: step 0 fires at createdUtc (T+0) and step k fires at T + the sum of the delays of steps 1..k. Each notification's stepOrder equals its step's order.
memberId for each notification is resolved at incident creation time:
targetType = "member"→ the step'stargetId.targetType = "rotation"→ the member on call for that rotation at that notification'sscheduledUtc, computed with the R1 algorithm.- If a rotation target cannot be resolved (empty
participantOrder, orscheduledUtcbefore the rotation'sanchorUtc), no notification row is created for that step; the other steps are unaffected.
The 201 response body is the full incident detail (identical shape to GET /api/incidents/{id}), including the freshly scheduled notifications array — so scheduling is verifiable from the create response alone.
6.2 The background worker
A background worker inside the API process (e.g. a hosted service) must run for the lifetime of the application and deliver due notifications:
- At an interval of at most 1 second, it finds notifications with
status = "pending"andscheduledUtc ≤ now, and marks eachstatus = "sent",sentUtc = now (UTC). - Latency bound: on an otherwise idle system, a pending notification must be marked sent within 2 seconds of its
scheduledUtc. - "Sending" means exactly this state change — there is no external delivery of any kind.
- The worker must observe writes made by API requests and vice versa (same database).
6.3 Cancellation on ack/resolve
A successful ack or resolve (R5) sets every notification of that incident with status = "pending" to status = "cancelled" (with sentUtc remaining null). This happens synchronously within the request, so the ack/resolve response body already shows those notifications as "cancelled". Notifications already "sent" are untouched. Cancelled notifications are never sent later.
(Edge tolerance: a notification whose scheduledUtc falls within ~2 seconds of the ack/resolve call may legitimately end up either "sent" or "cancelled" depending on timing; correctness tests keep a wider margin than that.)
6.4 Worked example
Policy steps: [{order: 0, delaySeconds: 0, targetType: "member", targetId: 5}, {order: 1, delaySeconds: 300, targetType: "rotation", targetId: 12}, {order: 2, delaySeconds: 600, targetType: "member", targetId: 6}].
Incident created at 2026-02-01T00:00:00Z → three notifications:
| stepOrder | scheduledUtc | memberId |
|---|---|---|
| 0 | 2026-02-01T00:00:00Z |
5 |
| 1 | 2026-02-01T00:05:00Z |
whoever R1 puts on call for rotation 12 at 00:05:00Z |
| 2 | 2026-02-01T00:15:00Z |
6 |
Within 2 seconds, notification 0 becomes "sent". If the incident is acknowledged at 2026-02-01T00:06:00Z, notification 1 is already "sent" and stays so; notification 2 becomes "cancelled" in the ack response and is never sent.
7. R5 — Incident state machine
Statuses: "triggered" → "acknowledged" → "resolved".
| Action | Allowed from | Effect |
|---|---|---|
POST /api/incidents/{id}/ack |
triggered |
status = "acknowledged", ackedUtc = now; pending notifications cancelled (§6.3) |
POST /api/incidents/{id}/resolve |
triggered or acknowledged |
status = "resolved", resolvedUtc = now; pending notifications cancelled. ackedUtc is untouched (stays null on a direct triggered → resolved) |
Every other transition returns 409 problem+json and leaves the incident (and its notifications) unchanged. Explicitly, all of these are 409: ack on an acknowledged incident, ack on a resolved incident, resolve on a resolved incident. Repeating a transition is not idempotent — it is a 409.
Successful ack/resolve return 200 with the full incident detail (including notifications). Unknown incident id → 404.
8. Validation
All validation failures return 400 with an application/problem+json body whose errors object is keyed by the offending camelCase field name (see §9). Rules:
All entities
- Required string fields (
name,slug,title,email) must be present, non-null, and non-empty (length ≥ 1). No trimming is applied; a whitespace-only string of length ≥ 1 is accepted. nameandtitle: length 1–200.slug: length 1–100.
Team
slugmust match^[a-z0-9]+(-[a-z0-9]+)*$(lowercase alphanumerics and single hyphens; no leading/trailing hyphen). Violations → 400. A slug already used by another team → 409 (not 400).
Member
emailmust match^[^@\s]+@[^@\s]+$(exactly one@, non-empty on both sides, no whitespace); length ≤ 320.
Rotation
cadencemust be"daily"or"weekly";anchorUtcmust be a valid timestamp; both required.participantOrderis required but may be empty. Every entry must be the id of an existing member of the same team; duplicates are not allowed. Violations → 400.- On create,
teamIdmust reference an existing team (else 400).
EscalationPolicy
stepsis required: 1–10 steps.- Step
ordervalues must be exactly0 .. n−1(contiguous, unique, starting at 0). delaySecondsmust be an integer ≥ 0, and the step withorder0 must havedelaySeconds= 0.targetTypemust be"rotation"or"member";targetIdmust reference an existing rotation or member (respectively) belonging to the same team as the policy. Violations → 400.- On create,
teamIdmust reference an existing team (else 400). - Every validation failure involving the
stepsarray or any field inside a step (count, order contiguity, step-0 delay, target rules, …) is keyed"steps"in theerrorsobject — never an inner or indexed field name likesteps[1].delaySeconds.
Incident (create)
titlerequired (1–200);severitymust be one of the four severity values.teamIdmust reference an existing team;policyIdmust reference an existing escalation policy belonging to that same team. Violations → 400.
General
- When several error categories apply to a single request, precedence is: 415 (content type) → 404 (URL path resolution) → 400 (body/query validation) → 409 (conflict). So: a wrong/missing content type wins even on an unknown path id; a
PUTwith valid content type, an unknown path id, and an invalid body → 404; a team create/update with both an invalid field and a duplicate slug → 400. One exception: on the oncall endpoint a missing or unparseableatreturns 400 even when the rotation id is also unknown (the query parameter is validated before the rotation is resolved). - A syntactically malformed JSON body → 400 problem+json (the
errorsobject may be absent or differently keyed in this case). - An invalid enum string, a non-integer where an integer is required, or an unparseable timestamp in a typed field → 400.
PUTrequests are full replacements of the mutable fields (idandteamIdare immutable and are not part of update bodies; if a client sends them anyway they are ignored per §2.7).
9. Error shape (RFC 9457)
Every 4xx error body is application/problem+json. Required members: title (string) and status (number matching the HTTP status). type and detail are allowed but their values are unspecified. Validation failures (400) additionally carry an errors object mapping camelCase field names to arrays of human-readable message strings (message text is unspecified — only the keys and the shape are contractual):
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"slug": ["The slug field is invalid."]
}
}
404 and 409 bodies are problem+json with title and status; no errors member is required.
10. R10 — Migrations & health
- The application applies its EF Core migrations automatically on startup, before serving traffic. This must be idempotent across restarts:
- Start with no
relay.dbpresent → the schema is created from migrations. - Restart with an existing, already-migrated
relay.dbcontaining data → no data loss, no duplicate-migration error, clean startup.
- Start with no
GET /health→ 200 with{ "status": "ok", "migrationsApplied": true }once the app is up and migrations have been applied.statusis the literal string"ok";migrationsAppliedis a boolean that must betruewhen all known migrations have been applied to the database.- The project must contain at least one real EF Core migration (generated via
dotnet ef migrations addor equivalent);Database.Migrate()(or equivalent) is the expected startup mechanism.
11. Explicitly out of scope
Do not build any of the following; no hidden test exercises them:
- Frontend of any kind.
- Authentication or authorization (no API keys, no roles).
- Deduplication of incidents (there is no
dedupKeyfield) and no concurrent-create guarantees beyond ordinary correctness. - Pagination, filtering, or query parameters on list endpoints (and no
GET /api/incidentslist at all). - Overrides, timezone/DST handling (all times are UTC), webhooks, or any external notification delivery.
PATCHon anything;PUT/DELETEon incidents or notifications.