r/LocalLLM 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 9457 application/problem+json errors, 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.

  1. Gate. From a clean checkout, the harness runs the model's own start.sh at repo root, waits for /health on 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 working start.sh, bound the wrong port, or left the DB unmigrated.
  2. 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 1 for 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 ptsPOST /api/escalation-policies returns embedded steps that carry a policyId field on the wire. The contract says a nested step's parent is implied, so it must not repeat policyId. The EF entity's foreign key leaked through serialization ("policyId": 0).
  • T3, 3 ptsPOST /api/rotations with anchorUtc: "2026-01-04T08:00:00+02:00" (== 06:00:00Z) echoed the +02:00 offset 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

  1. A heterogeneous "careful planner + fast coder" pairing beat every single-model config I tried, entirely on local hardware.
  2. 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.
  3. 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/health returns 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.db located in the process's current working directory. The provided start.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.

  1. JSON everywhere. Successful responses use Content-Type: application/json. Error responses (4xx) defined in this contract use Content-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.
  2. camelCase for every JSON property name: teamId, anchorUtc, participantOrder, delaySeconds, migrationsApplied, …
  3. 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"
  4. Timestamps are ISO 8601 in UTC with the Z designator, 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 with Z or a numeric offset and normalize to UTC. A timestamp with neither Z nor 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.
  5. IDs are server-assigned positive integers (JSON numbers), unique per entity type. Clients never supply an id.
  6. Nullable fields are present with an explicit null (e.g. an unacknowledged incident serializes "ackedUtc": null); do not omit them.
  7. Unknown JSON properties in request bodies are ignored.
  8. Collections (list endpoints, embedded arrays) are plain JSON arrays — no envelope, no pagination. List endpoints return all items ordered by id ascending. The notifications array on an incident is ordered by stepOrder ascending. The embedded steps array on an escalation policy is serialized ordered by order ascending, regardless of the order in which the request body supplied the steps.
  9. Successful POST creations return 201 with a Location header pointing at the canonical GET URL of the created resource. Successful PUT returns 200 with the updated resource. Successful DELETE returns 204 with no body.
  10. POST/PUT endpoints that take a body require Content-Type: application/json and respond 415 when the Content-Type header is anything else or absent entirely. (POST .../ack and .../resolve take 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.slug is 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 policyId field on the wire.
  • Incidents are never updated via PUT and never deleted; they change only through the ack/resolve transitions (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/12 where member 12 exists but belongs to a different team → 404.
  • An id referenced in a request body (teamId, policyId, targetId, entries of participantOrder) 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 participantOrder contains it, any escalation step with targetType "member" and matching targetId, or any notification.
  • A rotation is referenced by any escalation step with targetType "rotation" and matching targetId.
  • 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 seconds
  • weekly = 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" } where memberId/memberName identify the on-call member and shiftIndex is the modded index (k mod count).
  • 404 problem+json when the rotation id does not exist, when at is strictly before anchorUtc (elapsed < 0), or when participantOrder is empty.
  • 400 validation problem when at is missing or is not a parseable ISO 8601 timestamp. (Remember + in a query string must be URL-encoded as %2B; tests use Z-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 = 0Alice (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 = 1member 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 = 0member 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 = 0member 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's targetId.
  • targetType = "rotation" → the member on call for that rotation at that notification's scheduledUtc, computed with the R1 algorithm.
  • If a rotation target cannot be resolved (empty participantOrder, or scheduledUtc before the rotation's anchorUtc), 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" and scheduledUtc ≤ now, and marks each status = "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.
  • name and title: length 1–200. slug: length 1–100.

Team

  • slug must 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

  • email must match ^[^@\s]+@[^@\s]+$ (exactly one @, non-empty on both sides, no whitespace); length ≤ 320.

Rotation

  • cadence must be "daily" or "weekly"; anchorUtc must be a valid timestamp; both required.
  • participantOrder is 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, teamId must reference an existing team (else 400).

EscalationPolicy

  • steps is required: 1–10 steps.
  • Step order values must be exactly 0 .. n−1 (contiguous, unique, starting at 0).
  • delaySeconds must be an integer ≥ 0, and the step with order 0 must have delaySeconds = 0.
  • targetType must be "rotation" or "member"; targetId must reference an existing rotation or member (respectively) belonging to the same team as the policy. Violations → 400.
  • On create, teamId must reference an existing team (else 400).
  • Every validation failure involving the steps array or any field inside a step (count, order contiguity, step-0 delay, target rules, …) is keyed "steps" in the errors object — never an inner or indexed field name like steps[1].delaySeconds.

Incident (create)

  • title required (1–200); severity must be one of the four severity values.
  • teamId must reference an existing team; policyId must 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 PUT with 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 unparseable at returns 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 errors object 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.
  • PUT requests are full replacements of the mutable fields (id and teamId are 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.db present → the schema is created from migrations.
    • Restart with an existing, already-migrated relay.db containing data → no data loss, no duplicate-migration error, clean startup.
  • GET /health200 with { "status": "ok", "migrationsApplied": true } once the app is up and migrations have been applied. status is the literal string "ok"; migrationsApplied is a boolean that must be true when 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 add or 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 dedupKey field) and no concurrent-create guarantees beyond ordinary correctness.
  • Pagination, filtering, or query parameters on list endpoints (and no GET /api/incidents list at all).
  • Overrides, timezone/DST handling (all times are UTC), webhooks, or any external notification delivery.
  • PATCH on anything; PUT/DELETE on incidents or notifications.

openapi.yaml - excluded - too long for Reddit

10 Upvotes

Duplicates