# NodeOS Public API

REST API for external AI agents and developer tools (KIRO, Cursor, Codex,
Claude Code, OpenAI Agents).

> **Interactive reference:** <https://nodeos.dk/docs> (Redoc rendered from
> `/openapi.yaml`).
>
> **Machine spec:** <https://nodeos.dk/openapi.yaml>
>
> **Plaintext (for AI agents without JS):** <https://nodeos.dk/api/public/v1/docs>
> returns this markdown file as `text/markdown`, or JSON when called with
> `Accept: application/json`.

---

## 0. Agent Starter Kit

A ready-to-copy integration kit for wiring any project (and its AI agent) to
this API: <https://nodeos.dk/docs/agent-kit/>

| File | Purpose |
| --- | --- |
| [`README.md`](https://nodeos.dk/docs/agent-kit/README.md) | Quick start and requirements |
| [`nodeos-client.mjs`](https://nodeos.dk/docs/agent-kit/nodeos-client.mjs) | Standalone CLI + ESM module, zero dependencies |
| [`nodeos-client.js`](https://nodeos.dk/docs/agent-kit/nodeos-client.js) | Same file, for projects with `"type": "module"` |
| [`steering-template.md`](https://nodeos.dk/docs/agent-kit/steering-template.md) | Generic agent workflow rules to adapt per project |
| [`.env.example`](https://nodeos.dk/docs/agent-kit/.env.example) | Environment template (placeholders only) |

Requires Node.js 18+. The client is an ES module — use the `.mjs` filename or
set `"type": "module"` in your `package.json`, otherwise Node fails with
`SyntaxError: Cannot use import statement outside a module`.

---



## 1. Quickstart

```bash
curl -H "Authorization: Bearer nw_live_..." \
  https://nodeos.dk/api/public/v1/me
```

- **Base URL:** `https://nodeos.dk`
- **Auth header:** `Authorization: Bearer nw_live_...`
- **Get an API key:** Portal → Settings → API & integrations.

> ⚠️ **Use the apex, NOT `www.`.** `https://www.nodeos.dk` 302-redirects to
> `https://nodeos.dk` and most HTTP clients (curl, fetch, axios) strip the
> `Authorization` header on cross-host redirects per RFC 9110. Requests to
> `www.` will arrive unauthenticated.

---

## 2. Authentication & scopes

An API key is **scoped to one customer/project** (organization). Optionally it
can be narrowed further to a subset of areas under that customer.

Call `GET /api/public/v1/me` to introspect the calling key — returns its id,
organization, scopes, allowed areas, expiry and last-used timestamps.

| Scope             | Allows                                             |
| ----------------- | -------------------------------------------------- |
| `tasks.read`      | `GET /tasks`, `GET /tasks/{id}`                    |
| `tasks.create`    | `POST /tasks`, `POST /tasks/bulk`                  |
| `tasks.update`    | `PATCH /tasks/{id}`, `POST /tasks/batch`           |
| `tasks.delete`    | Reserved — no destructive task endpoint is exposed today |
| `status.update`   | `PATCH /tasks/{id}/status`                         |
| `comments.read`   | `GET /tasks/{id}/comments`                         |
| `comments.create` | `POST /tasks/{id}/comments`                        |
| `attachments.read`   | `GET /tasks/{id}/files`                         |
| `attachments.upload` | `POST /tasks/{id}/files`                        |
| `projects.read`   | `GET /projects`, `GET /projects/{id}`              |
| `projects.manage` | `POST /projects`, `PATCH`, `DELETE /projects/{id}` |
| `integrations.manage` | Manage the calling integration's own configuration |
| `webhooks.read`   | `GET /webhooks`, `GET /webhooks/{id}`, deliveries  |
| `webhooks.manage` | `POST`, `PATCH`, `DELETE /webhooks`, `POST /test`  |
| `work.read`       | `GET /work-sessions`, `/work-sessions/{id}`, events |
| `work.write`      | `POST /work-sessions`, `/lifecycle`, `/events`     |
| `evidence.read`   | `GET /evidence`, `GET /evidence/{id}`, `GET /tasks/{id}/delivery`, `/delivery-integrity` |
| `evidence.write`  | `POST /evidence`, `PATCH /evidence/{id}`, `POST /evidence/{id}/supersede` |
| `evidence.verify` | `POST /evidence/{id}/verify`, `/reject`. **Explicit only** — verification is a human act and is never implied by `admin.full`. |
| `docs.read`       | `GET /docs/*` (Documentation Sync). **Explicit only.** |
| `docs.sync`       | Documentation review assessment endpoints. **Explicit only.** |
| `memory.read`     | `GET /memory/search` (Development Memory). **Explicit only.** |
| `admin.full`      | Bypasses all scope checks within the org — **except** the explicit-only scopes above. |

**Explicit-only scopes** (`evidence.verify`, `docs.read`, `docs.sync`,
`memory.read`) are never granted by an access level and never implied by
`admin.full`. They exist only when present on the key itself.

Access levels (`access_level` on the key) provide common bundles:

| Level | Scopes |
| --- | --- |
| `read_only` | `projects.read`, `tasks.read`, `comments.read`, `attachments.read`, `work.read`, `evidence.read` |
| `read_write` | all of `read_only` plus `tasks.create`, `tasks.update`, `comments.create`, `status.update`, `attachments.upload`, `work.write`, `evidence.write` |
| `full_access` | `admin.full` |


---

## 3. Conventions

### Success

```json
{ "success": true, "data": <payload>, "meta": { ... optional } }
```

### Error

```json
{ "success": false, "error": { "code": "validation_error", "message": "..." } }
```

### Response headers

Every API response includes:

| Header             | Example | Meaning                                |
| ------------------ | ------- | -------------------------------------- |
| `X-API-Version`    | `v1`    | URL-prefix version. Bumps on breaking. |
| `X-NodeOS-Version` | `1.5.0` | Backend build version.                 |

### Status / priority aliases

English aliases are accepted on write and translated to the Danish enum
values stored internally. Responses always use the Danish enums.

| Field    | English alias       | Stored as            |
| -------- | ------------------- | -------------------- |
| status   | `new`               | `Ny`                 |
| status   | `clarifying`        | `Afventer afklaring` |
| status   | `approved`          | `Godkendt`           |
| status   | `planned`           | `Planlagt`           |
| status   | `in_progress`       | `Under udvikling`    |
| status   | `ready_for_review`  | `Klar til test`      |
| status   | `customer_approved` | `Godkendt af kunde`  |
| status   | `done` / `released` | `Released`           |
| status   | `parked`            | `Parkeret`           |
| status   | `rejected`          | `Afvist`             |
| priority | `low`               | `Lav`                |
| priority | `medium`            | `Medium`             |
| priority | `high`              | `Høj`                |
| priority | `critical`          | `Kritisk`            |
| type     | `bug`               | `Fejl`               |
| type     | `feature`           | `Udviklingsønske`    |
| type     | `change`            | `Ændringsønske`      |
| type     | `decision`          | `Beslutning`         |
| type     | `task`              | `Teknisk opgave`     |
| type     | `support`           | `Support`            |

> **Levering vs. officiel release:** `Released` er sandheden for, at en sag er leveret. Et officielt release er en valgfri gruppering af en eller flere sager og er ikke en forudsætning for levering. Status og release-tilknytning er uafhængige.

`visibility` is `internal` or `customer`. New comments default to `internal`.

---

## 4. Endpoints

Full request/response shapes live in
[`openapi.yaml`](https://nodeos.dk/openapi.yaml) and are rendered at
[`/docs`](https://nodeos.dk/docs).

### Health

- `GET /api/public/health` — no auth. Returns `{ status, version, timestamp, database }`.

### Introspection

- `GET /api/public/v1/me` — returns the calling key's `key_id`, scopes,
  organization, allowed areas, expiry.

### Projects

- `GET /api/public/v1/projects` — list areas this key can access.

### Tasks

- `GET /api/public/v1/tasks?area_id=&status=&search=&include=&limit=&offset=`
  - `search` (alias `q`) — case-insensitive substring match on `title` and
    `description`, plus exact tag match. Use this before `POST /tasks` to
    detect duplicates server-side instead of paging the full list.
  - `include=completion` (v1.12.0) — attach a compact
    `{quality_score, color}` object per task. Requires `limit ≤ 100`.
- `POST /api/public/v1/tasks`
- `GET /api/public/v1/tasks/{id}`
- `PATCH /api/public/v1/tasks/{id}`
- `PATCH /api/public/v1/tasks/{id}/status`

From v1.12.0 the list response returns the same fields as the detail
response — including the economics and reporter fields listed below — so
audits and dashboards can be built from a single call.

Returned tasks include these metadata fields in addition to `title`,
`description`, `status`, `priority`, `type`, and `area_id`:

| Field                        | Type       | Description                                                                                                                                                                                   |
| ---------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `customer_summary`           | `string`   | Kundevendt problembeskrivelse (hvad oplever kunden). Nullable.                                                                                                                                |
| `customer_solution`          | `string`   | Kundevendt løsningsbeskrivelse (hvad blev gjort, i klart sprog). Nullable.                                                                                                                    |
| `customer_value`             | `string`   | Kundevendt værdi/effekt af leverancen. Nullable.                                                                                                                                              |
| `release_note`               | `string`   | Kort changelog-linje brugt på Leverancer-siden. Nullable.                                                                                                                                     |
| `technical_notes`            | `string`   | Intern teknisk analyse (root cause, SQL, filer, commits). **Kun synlig for interne brugere** — udelades fra kundevendte visninger. Nullable.                                                  |
| `customer_reply`             | `string`   | Foreslået eller sendt svar til henvenderen. Bruges primært på Support/Fejl. Feltet dokumenterer teksten — det beviser ikke, at svaret er sendt. **Kun synlig for interne brugere.** Nullable. |
| `customer_impact`            | `string`   | Kundens oplevede påvirkning. Intern/økonomi-relateret. Nullable.                                                                                                                              |
| `payment_responsibility`     | `enum`     | `customer` / `owner` / `shared` / `not_decided` / `not_billable`.                                                                                                                             |
| `billable_status`            | `enum`     | `Ikke vurderet` / `Fakturerbar` / `Ikke fakturerbar` / `Afventer godkendelse` / `Godkendt`.                                                                                                   |
| `billing_reason`             | `enum?`    | `bug_fix` / `new_development` / `change_request` / `support` / `technical_debt` / `goodwill` / `unclear`, eller null.                                                                         |
| `billing_note`               | `string?`  | Fri notetekst til fakturering, ≤ 2000 chars.                                                                                                                                                  |
| `estimate_hours`             | `number?`  | 0..10000, op til 2 decimaler.                                                                                                                                                                 |
| `actual_hours`               | `number?`  | 0..10000, op til 2 decimaler.                                                                                                                                                                 |
| `reporter_name`              | `string?`  | Navn på ekstern henvender uden NodeOS-profil. PII — kun interne brugere.                                                                                                                      |
| `reporter_email`             | `string?`  | E-mail på henvender. Skjules for kundebrugere som ikke selv er reporter.                                                                                                                      |
| `reporter_organization`      | `string?`  | Firma/afdeling for ekstern henvender. Kun interne brugere.                                                                                                                                    |
| `reporter_channel`           | `enum?`    | `portal` / `email` / `phone` / `meeting` / `api` / `other`.                                                                                                                                   |
| `completion`                 | `object`   | Fuld Completion Checklist på `GET /tasks/{id}`; kompakt `{quality_score, color}` på `GET /tasks?include=completion`. Se §15.                                                                  |
| `start_date`                 | `date`     | Planned start date (`YYYY-MM-DD`). Nullable.                                                                                                                                                  |
| `deadline`                   | `date`     | Due date (`YYYY-MM-DD`). Must be ≥ `start_date` if both are set.                                                                                                                              |
| `tags`                       | `string[]` | Up to 20 labels, each ≤ 50 characters. Returned as `[]` when empty.                                                                                                                           |
| `requested_by`               | `uuid`     | Profile id of the person who reported the task. Nullable.                                                                                                                                     |
| `parent_item_id`             | `uuid`     | Parent initiative id when the task is a subtask. Max one level.                                                                                                                               |
| `created_via_intake_form_id` | `uuid`     | Intake-formular sagen kom fra, hvis relevant. Nullable.                                                                                                                                       |
| `created_at`                 | `datetime` | ISO 8601 creation timestamp.                                                                                                                                                                  |
| `updated_at`                 | `datetime` | ISO 8601 last-modified timestamp.                                                                                                                                                             |
| `created_via_api_key_id`     | `uuid`     | Set when the task was created through the public API. Nullable.                                                                                                                               |

`POST` and `PATCH` accept `start_date`, `deadline`, `tags`, `visibility`
(`customer` | `internal`), samt de fem målgruppe-felter ovenfor
(`customer_summary`, `customer_solution`, `customer_value`, `release_note`,
`technical_notes`, `customer_reply`). Alle er valgfri og additive — eksisterende
klienter kan fortsat sende kun `description`. English status / priority / type
aliases (see §3) are still accepted on writes.

**Målgruppe-separation (v1.9):** `description` er det oprindelige fritekstfelt
og forbliver uændret for bagudkompatibilitet. De nye felter tillader adskilt
kommunikation til forskellige målgrupper — KIRO/interne agenter bør skrive
teknisk analyse i `technical_notes`, mens `customer_summary` /
`customer_solution` / `customer_value` holdes i kundens sprog uden kode,
filnavne eller SQL. `release_note` bruges direkte på Leverancer-siden når
opgaven markeres som `Released`; ellers falder siden tilbage til
`customer_summary` og til sidst `description`.

**`visibility` defaults to `customer`** så opgaver oprettet via API'et er
synlige for kundens customer-admin / manager / contributor i portalen. Sæt
til `internal` hvis opgaven kun skal være synlig for det interne team.

**PATCH-skrivbare økonomi- og planlægningsfelter (v1.10.1).** Ud over
målgruppe-felterne accepterer `PATCH /tasks/{id}` nu:

| Felt                     | Type & validering                                                                                                    |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------- |
| `payment_responsibility` | enum: `customer`, `owner`, `shared`, `not_decided`, `not_billable`. Ikke nullable — brug `not_decided` for at rydde. |
| `billable_status`        | enum: `Ikke vurderet`, `Fakturerbar`, `Ikke fakturerbar`, `Afventer godkendelse`, `Godkendt`. Ikke nullable.         |
| `estimate_hours`         | number 0..10000 (max 2 decimaler) eller `null`.                                                                      |
| `actual_hours`           | number 0..10000 (max 2 decimaler) eller `null`.                                                                      |
| `billing_reason`         | string ≤ 2000 chars eller `null`.                                                                                    |
| `billing_note`           | string ≤ 2000 chars eller `null`.                                                                                    |
| `roadmap_bucket`         | string ≤ 200 chars eller `null`.                                                                                     |
| `assigned_to`            | UUID på et aktivt medlem af organisationen, eller `null`. Ikke-medlemmer returnerer `validation_error`.              |

Økonomifelter accepteres af API'et uanset om kunden har aktiveret dem i sine
intake-indstillinger — Completion Checklist læser aktive felter fra
`intake_settings` og medregner kun de aktive i `quality_score`, men skrivning
er tilladt så AI-agenter altid kan udfylde dem inden en org konfigureres.
Kræver `tasks.update` scope.

**`customer_reply` semantik.** Fri tekst op til 10.000 tegn, gemmes som plain
text (ingen markdown-render i kundeportalen). Feltet dokumenterer det svar
der er _foreslået eller sendt_ til henvenderen — det beviser ikke at svaret
er leveret. Forskellen på `customer_reply` og en kommentar med
`visibility: "customer"`: kommentaren er et vedvarende indlæg i sagens
tidslinje, mens `customer_reply` er ét muterbart felt-snapshot der bruges af
Completion Checklist og som draft-buffer for AI-agenter. `customer_reply`
returneres kun til interne viewere / interne API-nøgler.

### Comments

- `GET /api/public/v1/tasks/{id}/comments?limit=&offset=`
- `POST /api/public/v1/tasks/{id}/comments`

---

## 5. Errors & status codes

| HTTP | `error.code`                                       | Meaning                                     |
| ---- | -------------------------------------------------- | ------------------------------------------- |
| 400  | `validation_error`                                 | Body or parameter validation failed         |
| 400  | `field_not_allowed`                                | PATCH body contains a non-whitelisted field |
| 400  | `invalid_json`                                     | Body is not valid JSON                      |
| 401  | `missing_api_key`                                  | No `Authorization: Bearer` header           |
| 401  | `invalid_api_key`                                  | Key hash not found                          |
| 401  | `key_revoked`                                      | Key has been revoked                        |
| 401  | `key_expired`                                      | Key past `expires_at`                       |
| 403  | `scope_forbidden`                                  | Key lacks the required scope                |
| 403  | `area_forbidden`                                   | Resource belongs to an area outside the key |
| 404  | `not_found`                                        | Resource missing or invisible to this key   |
| 413  | `payload_too_large`                                | Body exceeds size cap                       |
| 415  | `unsupported_media_type`                           | `Content-Type` is not `application/json`    |
| 429  | `rate_limited`                                     | (Reserved) too many requests                |
| 500  | `internal_error`                                   | Server-side failure; incident logged        |
| 500  | `query_failed` / `insert_failed` / `update_failed` | DB operation failed                         |

The full enum is also published in the `ErrorCode` schema in `openapi.yaml`.

---

## 6. Pagination

List endpoints (`/tasks`, `/tasks/{id}/comments`) accept:

- `limit` — 1..200, default 100
- `offset` — ≥ 0, default 0

Responses include `meta.pagination`:

```json
{
  "success": true,
  "data": [ ... ],
  "meta": {
    "pagination": { "limit": 100, "offset": 0, "total": 273, "next_offset": 100 }
  }
}
```

`next_offset` is `null` when there are no more rows.

---

## 7. Rate limits

Rate limiting is not enforced at the time of writing. When introduced, the
response will include:

- `X-RateLimit-Limit` — requests allowed per window
- `X-RateLimit-Remaining` — requests left in the current window
- `X-RateLimit-Reset` — Unix epoch seconds when the window resets
- HTTP `429` with `error.code = "rate_limited"` when exceeded

Be conservative: target ≤ 5 requests/second per key.

---

## 8. Versioning & changelog

The URL carries the major version (`/v1`). Breaking changes ship under a new
prefix (`/v2`); additive changes (new fields, new endpoints) remain on `/v1`.

Every response includes `X-API-Version` (the URL major) and `X-NodeOS-Version`
(the backend build, also surfaced in `/api/public/health`).

| Date       | Version | Change                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| ---------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-08-09 | 1.24.0  | **Completion-semantik: applicability (Phase 3.4B).** Completion Checklist skelner nu mellem *manglende* og *ikke relevant*. Et krav, der er irrelevant for den type arbejde sagen repræsenterer, udgår af både tæller og nævner i `quality_score` og returneres i det nye `not_applicable[]` med `applicable: false` og `not_applicable_reason`. Nye tællefelter: `applicable_total`, `applicable_completed`, `not_applicable_total`. Billability afgøres af de eksisterende felter — `payment_responsibility = not_billable` eller `billable_status = Ikke fakturerbar` gør `estimate_hours` og `actual_hours` N/A — mens `not_decided` / `Ikke vurderet` fortsat tæller som en manglende vurdering. Kundevendt tekst, release note, teknisk analyse og evidens bliver aldrig N/A. Ændringen er additiv: eksisterende felter er uændrede. Samtidig er scope-tabellen i §2 rettet til den kanoniske liste (inkl. `evidence.*`, `docs.read`, `docs.sync`, `attachments.*`, `integrations.manage`) med eksplicit markering af de scopes, `admin.full` aldrig giver. |
| 2026-08-08 | 1.23.0  | **Development Memory Retrieval V1 (Phase 3.4).** Nyt endpoint `GET /api/public/v1/memory/search` (scope `memory.read`) med deterministisk PostgreSQL full-text search på tværs af sager, beslutninger, kommentarer, leveringsbeviser, arbejdssessioner og — med `docs.read` — dokumentation. Bevidst **ingen** embeddings, vektordatabase eller LLM-resuméer: resultatsættet skal kunne reproduceres og sikkerhedsgennemgås. Alle kildetabeller har genererede `tsvector`-kolonner med GIN-indeks, og `items` har adskilte kundevendte og interne vektorer. Søgefunktionen er `SECURITY INVOKER`, så RLS er autoritativ for bruger-tokens; for service-role-kald anvendes organisationsfilteret **før** ranking og **før** `ts_headline`, så et snippet aldrig kan genereres fra en række, kalderen ikke må læse. Synlighed udledes af nøglens aktørklasse — aldrig af en parameter — og `memory.read` giver aldrig adgang til dokumentation uden `docs.read`. `memory.read` er et eksplicit scope og gives ikke af `admin.full`. Samme retrieval-lag eksponeres som MCP-værktøjet `search_development_memory` og som den interne portalside **Udviklingshukommelse**. Se [NODEOS_DEVELOPMENT_MEMORY.md](./NODEOS_DEVELOPMENT_MEMORY.md). |
| 2026-08-08 | 1.22.0  | **Idempotens på `POST /tasks`.** Endpointet accepterer nu `Idempotency-Key` (≤ 200 tegn). Nøglen reserveres før indsættelsen og er bundet til payloadens SHA-256-fingeraftryk (uden headere og nøgle-id), så en retry aldrig opretter dubletter: samme nøgle + samme payload replayer det oprindelige `201` med `Idempotent-Replay: true`, samme nøgle + anden payload giver `409 idempotency_key_reuse`, og en retry mens første kald stadig kører giver `409 idempotency_in_progress`. Fejlede indsættelser frigiver reservationen, så nøglen kan bruges igen. |
| 2026-08-04 | 1.20.1  | **Final Integrity Closure (Phase 2.2.1).** Delivery Evidence bærer nu en holdbar, ikke-hemmelig skaberattribution (`created_via_api_key_prefix`, `created_via_api_key_name`, `created_by_agent_kind`, `created_by_agent_label`), som fryses ved oprettelse og overlever fysisk sletning af API-nøglen; `created_via_api_key_id` må derfor nulles referentielt, men kun når nøglen ikke længere findes — ethvert andet forsøg afvises fortsat som immutabilitetsbrud. En kompromitteret nøgle kan dermed både tilbagekaldes og fjernes uden at revisionssporet går tabt. Attributionsfelterne er interne og nulles i kundevendte projektioner. `item_release_snapshots` er nu append-only i databasen: direkte `DELETE` afvises for alle roller, kun referentiel cascade kan fjerne en snapshot. Legacy `task_links` sender deprecation-headere på **alle** metoder (`GET`, `POST`, `DELETE`) og på både succes- og fejlsvar. Ingen brydende ændringer. |
| 2026-08-04 | 1.20.0  | **Delivery Consolidation & Surface (Phase 2.2, Contract v1.0).** Nyt aggregeret endpoint `GET /tasks/{id}/delivery` (scope `evidence.read`) der i ét kald returnerer opgave, arbejde, evidens, integritet, releases, release snapshots og en samlet tidslinje. API-nøgler har nu en **aktørklasse** (`internal`, `service`, `customer`, `integration`), der afgør synlighed uafhængigt af scopes; filtreringen sker **før** alt afledes, så kundevendte aktører ikke kan udlede skjult intern evidens fra tal, gaps, kæder eller tidslinje. `wall_clock_span` findes nu som server-beregnet union på tværs af sessioner på samme sag. Release Snapshots (`item_release_snapshots`) fryser integritet, completion, evidens-id'er og alle kontraktversioner ved overgang til `Released`. Integritetsalgoritmen har fået et `integrity_algorithm_fingerprint`, som eksponeres i `/health` og i hvert snapshot. Legacy `task_links` er **deprecated** (sunset 2026-12-31), spejles automatisk til Delivery Evidence og svarer med `Deprecation`/`Sunset`/`Link`/`Warning`-headere. Se §20. |
| 2026-08-04 | 1.19.0  | **Delivery Integrity & Delivery Confidence (Phase 2.1, Contract v1.0).** Nyt endpoint `GET /tasks/{id}/delivery-integrity` (scope `evidence.read`) der on-the-fly beregner beviskraft pr. evidens (`LOW`–`CRITICAL`), evidensgraf (`produced`, `verifies`, `supersedes`, `approves`, `released_in`, `supports`, `documents`), supersede-kæder, Delivery Confidence Score 0–100 og et samlet `integrity_result` (`INSUFFICIENT`, `WEAK`, `MODERATE`, `STRONG`) plus konkrete `gaps`. Dækning vægter tungere end mængde: ti commits scorer lavere end commit + test + deployment. `rejected` og `superseded` evidens tæller aldrig. Completion Checklist får de anbefalede regler `delivery_integrity_sufficient`, `delivery_verification_evidence` og `delivery_integrity_at_release`. Intet gemmes, og ingen statusovergang blokeres. Se [DELIVERY_INTEGRITY.md](./DELIVERY_INTEGRITY.md). |
| 2026-08-04 | 1.18.0  | **Delivery Evidence Engine (Phase 2, Contract v1.0).** Nye endpoints: `GET`/`POST /evidence`, `GET`/`PATCH /evidence/{id}`, `POST /evidence/{id}/verify`, `POST /evidence/{id}/reject`, `POST /evidence/{id}/supersede`. Nye scopes `evidence.read`, `evidence.write` og `evidence.verify` — sidstnævnte gives **aldrig** implicit, heller ikke af `admin.full`, fordi verifikation er en menneskelig handling. Evidens er append-only: fakta er immutable, kun `visibility` kan ændres (`400 immutable_field`), intet slettes, og erstatning sker via supersede. Selvverifikation afvises (`403 self_verification_forbidden`). Metadata valideres for prompts, credentials og persondata. Completion Checklist får `delivery_evidence_present` (fra Klar til test) og `delivery_evidence_verified` (ved Released) som anbefalede regler. Se [DELIVERY_EVIDENCE.md](./DELIVERY_EVIDENCE.md). |

| 2026-08-04 | 1.17.0  | **Work Sessions Phase 1.1 — hardening.** Idempotensnøgler er nu bundet til payloaden: genbrug med afvigende indhold giver `409 idempotency_key_reuse`, og en nøgle i brug giver `409 idempotency_in_progress` (nøglen reserveres før oprettelse, så to samtidige kald aldrig begge opretter). `/health` returnerer `api_version` fra samme kilde som `openapi.info.version` plus `work_sessions_contract_version`. Timeout-lukning af inaktive sessioner er planlagt hvert 15. minut (`end_reason: "timeout"`). OpenAPI dokumenterer nu `created_at`, `updated_at` og `event_count`. Advarsel tilføjet mod at summere `wall_clock_span` på tværs af sessioner. |
| 2026-08-04 | 1.16.0  | **Work Sessions Phase 1 (Contract v1.0).** Nye endpoints: `POST`/`GET /work-sessions`, `GET /work-sessions/{id}` (`?include=events`), `POST /work-sessions/{id}/lifecycle`, `POST`/`GET /work-sessions/{id}/events`. Nye scopes `work.read` (i `read_only`) og `work.write` (i `read_write`). Serveren ejer tiden: server-tildelt `seq`, append-only events, idempotency på start og events, tre adskilte tidstal (`wall_clock_span`, `session_time_sum`, `calendar_elapsed`) — uret kører kun i status `active`. `outcome` er påkrævet ved `complete`. Sessionsdata er interne. `actual_hours` er uændret manuelt. Se §19. |
| 2026-07-25 | 1.14.0  | **KIRO agent-ergonomi.** (1) `GET /tasks` understøtter `search_field` (`title`\|`description`\|`tags`\|`all`) og `exact=true` — præcis duplikat-detektion i stedet for brede OR-hits. (2) `POST /tasks` accepterer nu `parent_item_id`, så en subtask kan oprettes i ét kald. (3) Nyt `POST /tasks/bulk` — op til 50 opgaver og 100 relationer per request med `ref`/`parent_ref`-binding. (4) `GET /tasks/{id}/relations?direction=outgoing\|incoming\|all` gør det muligt at se "hvem er min parent?" fra en subtask. (5) Agent-kit-clienten: `bulk-create`, `--parent`, `--area <navn>`, `--search-field`, `--exact`, `--direction` samt rene exit codes (0 ved succes). Se §18. |

| 2026-07-13 | 1.13.0  | **KIRO Runde B: batch + comment-flow + areas write.** (1) Nyt `POST /tasks/batch` — best-effort batch PATCH med max 50 operationer per request. Returnerer altid 200 med `results[]` (per-op `status`, `task`, evt. `error`); inspicér `meta.batch.{total, succeeded, failed}`. Understøtter `include: ["completion"]` (batch- eller per-op) så scoren returneres uden ekstra fetch. Kræver `tasks.update`. Se §17. (2) `POST /tasks/{id}/comments` accepterer nu `is_customer_reply: true` (kræver `visibility: "customer"`), som spejler kommentarens body ind i `items.customer_reply` samme request. Response udvides med `synced_to_customer_reply`. (3) `POST /projects`, `PATCH /projects/{id}` og `DELETE /projects/{id}` (soft-delete via `is_active=false`) er nu tilgængelige — kræver den nye scope `projects.manage`. Area-restricted API-nøgler kan ikke oprette nye areas.                                                                                                       |
| 2026-07-13 | 1.12.0  | **KIRO Runde A: audit-parity for list-endpointet.** (1) `GET /tasks` returnerer nu det fulde task-shape — økonomifelter (`payment_responsibility`, `billable_status`, `billing_reason`, `billing_note`, `estimate_hours`, `actual_hours`, `customer_impact`) og reporter-felter (`reporter_name`, `reporter_email`, `reporter_organization`, `reporter_channel`, `requested_by`). Dashboards/audits kan bygges fra én liste-request i stedet for N+1 detail-fetches. (2) `GET /tasks?include=completion` tilføjer et kompakt `{quality_score, color}` objekt per task (kræver `limit ≤ 100`). (3) Historisk backfill kørt: sager uden `start_date` men med `entered_at` for "Under udvikling" i status-historikken har fået første udviklingsstart som `start_date` (Europe/Copenhagen). (4) `billing_reason` valideres nu mod DB-enum (`bug_fix`, `new_development`, `change_request`, `support`, `technical_debt`, `goodwill`, `unclear`) — invalide værdier returnerer 400 i stedet for 500. |
| 2026-07-13 | 1.11.0  | **Automatiske metadataforbedringer.** (1) NodeOS sætter automatisk `start_date` til dagens dato (Europe/Copenhagen) første gang en sag rammer status `Under udvikling` — også ved oprettelse direkte i den status. Manuelt satte datoer overskrives aldrig. (2) `roadmap_bucket` er nu skrivbart via `POST /tasks` og `PATCH /tasks/{id}` med kanonisk enum-validering (`Nu                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | Næste | Senere | Parkeret`; `null`rydder). (3) Ny reporter-model:`reporter_name`, `reporter_email`, `reporter_organization`, `reporter_channel` (`portal | email | phone | meeting | api | other`) på tasks. `requested_by`beholdes som profil-UUID. Ved POST forsøger NodeOS entydig e-mail→profil match inden for organisationen; ved PATCH kun hvis`requested_by`er NULL og klienten sender kun`reporter_email`. Reporterfelter er PII og maskeres server-side for kundebrugere. Ændringer logges som én masket hændelse — den fulde e-mail lækker ikke i aktivitetslog eller webhook-payloads. Se §16. |
| 2026-07-13 | 1.10.1  | `PATCH /tasks/{id}` udvidet: økonomifelter (`payment_responsibility`, `billable_status`, `estimate_hours`, `actual_hours`, `billing_reason`, `billing_note`) og planlægningsfelter (`roadmap_bucket`, `assigned_to`) er nu skrivbare. `estimate_hours` tælles som recommended i Completion Checklist fra status `Planlagt`. `/api/public/health` returnerer nu `api_version` (denne changelog). Se §5 og §15.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| 2026-07-13 | 1.10.0  | Completion Checklist / Sagskvalitet: nyt endpoint `GET /tasks/{id}/completion` + inline `completion` på `GET /tasks/{id}`. Nyt task-felt `customer_reply` (kun internt). Additivt — eksisterende felter uændret. Se §15.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| 2026-07-13 | 1.9.0   | Audience-specific task fields: `customer_summary`, `customer_solution`, `customer_value`, `release_note`, `technical_notes`. Additive — `description` uændret. `technical_notes` skjules i kundevendte visninger. Leverancer-siden bruger `release_note` med fallback til `customer_summary`/`description`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| 2026-06-28 | 1.7.0   | Webhooks: `webhook_endpoints`, HMAC-SHA256 signed deliveries (`task.created`, `task.updated`, `task.status_changed`, `comment.created`), retry with exponential backoff, auto-disable after 5 consecutive failures, `webhooks.read` / `webhooks.manage` scopes. See §9.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| 2026-06-28 | 1.6.0   | Added `GET /tasks?search=` (alias `q=`) for server-side title/description/tag search, and `GET /api/public/v1/docs` returning this file as markdown (or JSON with `Accept: application/json`) for AI agents without JS.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| 2026-06-19 | 1.5.0   | Added task metadata: `start_date`, `deadline`, `tags`, `requested_by`, `parent_item_id`, `created_at`/`updated_at` surfaced in list and detail views.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| 2026-06-19 | 1.4.0   | Added `GET /me`, pagination meta on lists, version headers, `/docs` UI.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |

---

## 9. Webhooks

Subscribe to events instead of polling. NodeOS POSTs HMAC-signed JSON to
your URL whenever a matching event occurs.

### Endpoints

| Method | Path                        | Scope             |
| ------ | --------------------------- | ----------------- |
| GET    | `/webhooks`                 | `webhooks.read`   |
| POST   | `/webhooks`                 | `webhooks.manage` |
| GET    | `/webhooks/{id}`            | `webhooks.read`   |
| PATCH  | `/webhooks/{id}`            | `webhooks.manage` |
| DELETE | `/webhooks/{id}`            | `webhooks.manage` |
| GET    | `/webhooks/{id}/deliveries` | `webhooks.read`   |
| POST   | `/webhooks/{id}/test`       | `webhooks.manage` |

### Creating an endpoint

```bash
curl -X POST -H "Authorization: Bearer nw_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/nodeos",
    "events": ["task.created", "task.status_changed", "comment.created"],
    "description": "KIRO integration",
    "secret": "auto"
  }' \
  https://nodeos.dk/api/public/v1/webhooks
```

`secret: "auto"` generates a 64-char hex secret. You can also supply your
own (≥16 chars). **The secret is returned ONLY in the creation response.**
Subsequent `GET` requests expose `secret_last4` instead.

### Events

| Event                 | Triggered by                                                       |
| --------------------- | ------------------------------------------------------------------ |
| `task.created`        | INSERT on `items` (via API or portal)                              |
| `task.updated`        | UPDATE on any whitelisted field (title, description, …)            |
| `task.status_changed` | UPDATE that changes `status` (fires in addition to `task.updated`) |
| `comment.created`     | INSERT on `item_comments` (non-deleted)                            |
| `webhook.test`        | Manual call to `POST /webhooks/{id}/test`                          |

Events are deduplicated within a 1-second rolling window per
`(task_id, event)` pair, so back-to-back identical updates collapse into one
delivery.

### Payload

```json
{
  "event": "task.status_changed",
  "timestamp": "2026-06-28T10:30:00.000Z",
  "webhook_id": "uuid",
  "delivery_id": "uuid",
  "data": {
    "task": { "id": "uuid", "title": "...", "status": "Under udvikling", "...": "..." },
    "changes": { "status": { "from": "Ny", "to": "Under udvikling" } },
    "triggered_by": { "type": "api_key", "key_id": "uuid" }
  }
}
```

`triggered_by.type` is `api_key`, `user`, or `system`. Test deliveries also
carry `"test": true` at the top level.

If the envelope exceeds 64 KB, `data.task.description` (and, if still too
large, `data.comment.body`) is truncated to 8 KB and the envelope gains
`"truncated": true`. The event is never dropped.

### Headers

| Header                 | Example                                    |
| ---------------------- | ------------------------------------------ |
| `X-NodeOS-Signature`   | `sha256=<hex(hmac_sha256(secret, …))>`     |
| `X-NodeOS-Event`       | `task.status_changed`                      |
| `X-NodeOS-Delivery`    | `<uuid>` (also serves as idempotency key)  |
| `X-NodeOS-Timestamp`   | `<ISO 8601>`                               |
| `X-NodeOS-Retry-Count` | `0` on first attempt, increments per retry |
| `User-Agent`           | `NodeOS-Webhooks/1.0`                      |

### Verifying the signature

Compute `HMAC-SHA256(secret, "${timestamp}.${rawBody}")` and compare in
constant time with the hex part of `X-NodeOS-Signature`. Reject deliveries
where `timestamp` differs from `now()` by more than ±5 minutes to defeat
replay.

```javascript
import crypto from "node:crypto";

function verify(req, secret) {
  const sig = (req.headers["x-nodeos-signature"] ?? "").replace(/^sha256=/, "");
  const ts = req.headers["x-nodeos-timestamp"];
  if (!sig || !ts) return false;
  if (Math.abs(Date.now() / 1000 - new Date(ts).getTime() / 1000) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${ts}.${req.rawBody}`).digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(sig, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

```python
import hmac, hashlib, time
from datetime import datetime, timezone

def verify(headers, raw_body, secret):
    sig = headers.get("x-nodeos-signature", "").removeprefix("sha256=")
    ts  = headers.get("x-nodeos-timestamp", "")
    if not sig or not ts: return False
    if abs(time.time() - datetime.fromisoformat(ts.replace("Z","+00:00")).timestamp()) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{ts}.{raw_body}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)
```

### Retries & auto-disable

Failed deliveries (network error, timeout, non-2xx response) are retried at
1m, 5m, 30m, 2h, 12h after each failure (5 attempts total). After **5
consecutive failures across all deliveries**, the endpoint is automatically
set to `is_active = false`. Re-enable with `PATCH /webhooks/{id}` —
`consecutive_failures` resets when you set `is_active: true`.

The delivery timeout is **10 seconds**. Responses must be 2xx to count as
successful.

### Inspecting deliveries

```bash
curl -H "Authorization: Bearer nw_live_..." \
  https://nodeos.dk/api/public/v1/webhooks/<id>/deliveries
```

Returns the last attempts with `response_status`, `duration_ms`, `success`,
`attempt_count`, and `next_retry_at`. Deliveries older than 30 days are
purged automatically.

### Test delivery

`POST /webhooks/{id}/test` synchronously sends a `webhook.test` envelope to
your URL and returns the resulting `response_status` and `duration_ms`. Use
it to validate the receiver's signature check during integration.

---

## 10. Task links (v1.8.0, **deprecated i v1.20.0**)

> **Deprecated — brug Delivery Evidence i stedet.** Sunset **2026-12-31**.
> Endpointet virker uændret indtil da, men alle svar bærer
> `Deprecation: true`, `Sunset`, `Link: </api/public/v1/evidence>;
> rel="successor-version"` og `Warning: 299`.
> Nye integrationer skal bruge `POST /evidence` og `GET /evidence?item_id=…`.

Attach commits, pull requests, or URLs to a task.

- `GET /tasks/{id}/links` — list. Scope `tasks.read`.
- `POST /tasks/{id}/links` — body `{ type: "commit"|"pr"|"url", url, title? }`. Scope `tasks.update`.
- `DELETE /tasks/{id}/links/{link_id}` — remove. Scope `tasks.update`.

`url` must be http/https, ≤2000 chars. `title` optional, ≤200 chars. Commit-type links without a title display the short SHA extracted from the URL.

### 10.1 Automatisk spejling til Delivery Evidence

Hvert nyt link spejles (best effort, idempotent) til `delivery_evidence`:

| `task_links.type` | `evidence_type`   |
| ----------------- | ----------------- |
| `commit`          | `commit`          |
| `pr`              | `pull_request`    |
| `url`             | `external_link`   |

Den spejlede række oprettes altid med `status: created`,
`visibility: internal` og `verified: false`. Et legacy-link kan derfor aldrig
producere verificeret evidens — verifikation er fortsat en eksplicit
menneskelig handling via `POST /evidence/{id}/verify`.

At slette et link sletter **ikke** den spejlede evidens: evidens er
append-only. Brug `POST /evidence/{id}/supersede` for at erstatte den.

De 177 historiske links fra før v1.20.0 er backfillet med samme regler.


## 11. Status history (v1.8.0)

`GET /tasks/{id}/status-history` — rows sorted ascending by `entered_at`, each `{ id, item_id, status, entered_at, exited_at, changed_by, changed_via_api_key_id, duration_seconds }`. Populated automatically by triggers. Compute lead time from `created_at → first "Released"`; cycle time from `first "Under udvikling" → first "Released"`.

## 12. Task relations (v1.8.0)

Bidirectional links between two tasks in the same organization.

- `GET /tasks/{id}/relations` — list with `target: {id, title, status}`. Scope `tasks.read`.
- `POST /tasks/{id}/relations` — body `{ target_task_id, relation_type }`. Scope `tasks.update`. `409 conflict` on duplicate.
- `DELETE /tasks/{id}/relations/{relation_id}` — removes both directions. Scope `tasks.update`.

`relation_type`: `relates_to`, `blocks`, `is_blocked_by`, `duplicates`. Mirror rows are created/deleted automatically.

## 13. Tags catalog (v1.8.0)

`GET /tags` → `[{ tag, count }]` sorted by count desc for the key's org (and area allow-list). Scope `tasks.read`.

## 14. Advanced task filters (v1.8.0)

`GET /tasks` now accepts, in addition to `search`/`status`/`area_id`:

- `type`, `priority` — Danish enum or English aliases (see §3).
- `tags` — comma-separated; item must contain ALL listed tags.
- `created_after`, `created_before`, `updated_after` — ISO 8601 timestamps.

All filters combine with `AND`.

---

## 15. Completion Checklist / Sagskvalitet (v1.10.0)

**Filosofi.** NodeOS beregner en dynamisk Completion Checklist for hver sag ud
fra sagstype, status og eksisterende feltværdier. Intet gemmes — hele
resultatet beregnes on-the-fly ud fra en deklarativ regelmotor og
organisationens `intake_settings` (som allerede styrer hvilke økonomifelter
der er aktiveret pr. kunde — der er ingen særskilt "economics_enabled"-toggle).

**Ikke-blokerende.** Checklisten forhindrer aldrig, at en sag markeres
`Released`. Den viser blot, hvad der stadig kan udfyldes for at have en
komplet sag.

**Required vs. recommended.** Hovedscoren beregnes KUN på `required`-regler.
`recommended`-regler vises separat som "berigelse" og trækker aldrig scoren
ned. Har en sag ingen relevante required-regler i sin nuværende tilstand
returneres `quality_score = 100`.

**Applicability (v1.24.0).** En regel kan være *irrelevant* for den type
arbejde, sagen repræsenterer. Sådan en regel er hverken opfyldt eller
manglende: den udgår af både tæller og nævner og returneres i `not_applicable[]`
med `applicable: false` og en `not_applicable_reason`.

Kanonisk kilde til billability — der indføres intet nyt system, de eksisterende
felter er autoritative:

| Felt | Værdi | Virkning |
| --- | --- | --- |
| `payment_responsibility` | `not_billable` | Kommercielle krav bliver N/A; `billable_status` regnes som besvaret |
| `billable_status` | `Ikke fakturerbar` | Kommercielle krav bliver N/A |
| `payment_responsibility` | `not_decided` | **Ikke** N/A — en manglende vurdering trækker fortsat scoren ned |
| `billable_status` | `Ikke vurderet` | **Ikke** N/A — samme begrundelse |

N/A-gaten rammer `estimate_hours`, `actual_hours` og (når kun
`billable_status` er sat) `payment_responsibility`. Ikke-kommercielle krav —
kundevendt tekst, release note, teknisk analyse, evidens — bliver aldrig N/A.
Scoren måler dermed **completeness af leveringsrecorden i forhold til de krav,
der faktisk gælder**, ikke "hvor mange felter er udfyldt".

**Farvekoder.** `green` ≥ 100 %, `yellow` 70–99 %, `red` < 70 %.

**Endpoints**

- `GET /api/public/v1/tasks/{id}` — inkluderer altid feltet `completion` (fuld shape).
- `GET /api/public/v1/tasks/{id}/completion` — dedikeret endpoint (samme
  shape). Scope: `tasks.read`.
- `GET /api/public/v1/tasks?include=completion` — v1.12.0. Attacher et
  kompakt `{quality_score, color}` objekt per task i list-responsen. Kræver
  `limit ≤ 100`. Bruges til dashboards/audits uden N+1 requests. `missing[]`
  / `satisfied[]` er ikke med — hent detail-endpointet for det.

**Response shape**

```json
{
  "success": true,
  "data": {
    "quality_score": 86,
    "color": "yellow",
    "required_completed": 6,
    "required_total": 7,
    "recommended_completed": 3,
    "recommended_total": 5,
    "applicable_total": 12,
    "applicable_completed": 9,
    "not_applicable_total": 2,
    "missing": [
      {
        "id": "payment_responsibility",
        "field": "payment_responsibility",
        "label": "Betales af",
        "category": "economics",
        "severity": "required",
        "satisfied": false,
        "applicable": true,
        "reason": "Required når økonomi er aktiveret og sagen er under udvikling eller senere (undtagen Beslutning)."
      }
    ],
    "satisfied": [
      {
        "id": "release_note",
        "field": "release_note",
        "label": "Release note",
        "category": "customer",
        "severity": "required",
        "satisfied": true,
        "applicable": true,
        "reason": "…"
      }
    ],
    "not_applicable": [
      {
        "id": "actual_hours",
        "field": "actual_hours",
        "label": "Faktisk tid",
        "category": "economics",
        "severity": "required",
        "satisfied": false,
        "applicable": false,
        "not_applicable_reason": "Sagen er markeret som ikke fakturerbar — kommercielle felter er ikke relevante.",
        "reason": "…"
      }
    ]
  }
}
```

`missing[]`, `satisfied[]` og `not_applicable[]` er disjunkte, og
`all[]` = summen af de tre. Feltet er additivt: eksisterende felter er uændret,
og en klient, der ignorerer `not_applicable[]`, virker som før.


**Regelmatrix (uddrag)**

| Kategori      | Regel                                       | Sagstype                | Status                                  | Severity                                   |
| ------------- | ------------------------------------------- | ----------------------- | --------------------------------------- | ------------------------------------------ |
| basic         | `title`                                     | Alle                    | Alle                                    | required                                   |
| customer      | `customer_summary`                          | Support, Fejl, Feature  | Fra `Godkendt`                          | required                                   |
| customer      | `customer_solution`                         | Support, Fejl, Feature  | Fra `Klar til test`                     | required                                   |
| customer      | `customer_value`                            | Support, Fejl, Feature  | `Released`                              | required                                   |
| customer      | `release_note`                              | Support, Fejl, Feature  | `Released`                              | required                                   |
| communication | `customer_reply`                            | Support, Fejl           | Fra `Klar til test` + ekstern henvender | recommended                                |
| development   | `technical_notes`                           | Fejl                    | Fra `Under udvikling`                   | required                                   |
| development   | `technical_notes`                           | Feature                 | Fra `Under udvikling`                   | recommended                                |
| economics     | `payment_responsibility`                    | Alle (undt. Beslutning) | Fra `Under udvikling` (hvis aktiv)      | required                                   |
| economics     | `actual_hours`                              | Alle (undt. Beslutning) | `Released` (undt. Ikke fakturerbar)     | required                                   |
| economics     | `billable_status`                           | Alle (undt. Beslutning) | `Released`                              | required                                   |
| economics     | `billing_reason`                            | Alle                    | `Released` + fakturerbart               | required (opfyldes også af `billing_note`) |
| planning      | `assigned_to`, `roadmap_bucket`, `deadline` | Kontekstuelt            | —                                       | recommended                                |

`Parkeret` / `Afvist` udløser kun basis-krav (`title` + `area` hvis kunden
bruger områder).

**Feltbehandling**

- Tomme strenge og whitespace tælles som ikke udfyldt.
- `0` er en gyldig værdi for `estimate_hours` og `actual_hours`.
- Sentinelværdier `payment_responsibility = not_decided` / `Ikke vurderet` og
  `billable_status = Ikke vurderet` tælles som ikke udfyldt.
- `customer_summary`-kravet opfyldes også af legacy-feltet `customer_impact`.

**Maskering pr. viewer**

Interne (og API-nøgler) ser hele checklisten. Kundevendte visninger får
maskeret alle interne kategorier — økonomi, teknisk analyse, tildeling og
`customer_reply` vises aldrig for en customer-viewer, og `quality_score`
genberegnes over det filtrerede regelsæt så scoren ikke afslører interne
mangler.

**AI-integrationsvejledning (KIRO)**

```
GET /api/public/v1/tasks/{id}/completion
→ læs missing[] og filtrér på severity=required
→ foreslå brugeren at udfylde et par felter ad gangen
→ skriv aldrig customer_reply eller økonomifelter uden brugerens instruktion
→ blokér aldrig sagens afslutning
```

Eksempel-dialog:

> Jeg kan se, at sagen er Released. Der mangler stadig: **Betales af**,
> **Estimerede timer**, **Kundesvar**. Vil du udfylde dem?

## 16. Automatiske metadataforbedringer (v1.11.0)

### 16.1 Automatisk `start_date`

NodeOS udfylder `start_date` automatisk første gang en sag rammer status
`Under udvikling` (både ved `UPDATE` og direkte `INSERT` med den status).

- Datoen sættes til dagens dato i tidszonen `Europe/Copenhagen`
  (platformens nuværende standard).
- **Manuelt satte datoer overskrives aldrig.** Reglen sætter kun feltet, hvis
  `start_date IS NULL`.
- Tilbageflytning og efterfølgende genstart ændrer ikke datoen.
- Reglen gælder for portal-UI, public API (POST + PATCH + `/status`) og
  interne server-funktioner.

KIRO og andre integrationsklienter skal **ikke** selv gætte `start_date`.

### 16.2 `roadmap_bucket` via API

`roadmap_bucket` accepteres nu på `POST /tasks` og `PATCH /tasks/{id}` med
kanonisk enum:

- Gyldige værdier: `Nu`, `Næste`, `Senere`, `Parkeret`.
- `null` rydder feltet. Tom streng afvises med `400 validation_error`.
- Ingen skjult synkronisering mellem `roadmap_bucket = 'Parkeret'` og
  `status = 'Parkeret'`.

Ændringer registreres i aktivitetsloggen som `roadmap_bucket_changed` og
udløser et `task.updated` webhook.

### 16.3 Reporter / henvender

`items` har fire nye felter til at repræsentere en henvender uden NodeOS-profil:

| Felt                    | Type         | Bemærkning                |
| ----------------------- | ------------ | ------------------------- |
| `reporter_name`         | string ≤ 200 | PII                       |
| `reporter_email`        | email ≤ 320  | PII, valideres serverside |
| `reporter_organization` | string ≤ 200 | PII                       |
| `reporter_channel`      | enum `portal | email                     | phone | meeting | api | other` | Ikke PII |

`requested_by` beholdes uændret som profil-UUID. Ved POST/PATCH forsøger
NodeOS automatisk profilmatch på e-mail:

- **POST:** hvis `reporter_email` er sat og `requested_by` ikke er sendt.
- **PATCH:** kun hvis den eksisterende `requested_by IS NULL`, og klienten
  udelukkende sender `reporter_email` (ikke også `requested_by`).
- Match sker inden for organisationen med case-insensitiv sammenligning og
  kun ved præcis ét matchende aktivt medlem.
- En eksisterende `requested_by` ændres **aldrig** automatisk.

Ryddes felter med `null`; tom streng afvises med `400 validation_error`.
Cross-org `requested_by` afvises. Ingen implicit profil-oprettelse.

**Privacy / maskering (server-side):**

| Viewer                             | Reporter-felter                          | `requested_by`         |
| ---------------------------------- | ---------------------------------------- | ---------------------- |
| Intern bruger / API-nøgle          | Alle 4 felter                            | UUID                   |
| Kundebruger (som ER reporter)      | `reporter_name` synligt; øvrige maskeret | UUID                   |
| Kundebruger (som ikke er reporter) | Alle 4 maskeret                          | UUID (UI viser navnet) |

Maskeringen sker i `stripInternalItemFields()` før JSON-serialisering — ikke
kun i React.

**Aktivitetslog og webhooks:** Enhver ændring i `requested_by`,
`reporter_name`, `reporter_email`, `reporter_organization` eller
`reporter_channel` logges som **én** samlet, masket hændelse
(`reporter_updated`) med værdien `(reporteroplysninger opdateret)`.
`emit_item_webhook` udsender kun de ændrede feltnavne — aldrig værdier —
under `changes.reporter.fields`.

### 16.4 Completion Checklist — "Indmeldt af"

Ny regel `reporter_identity`, severity `recommended`, gælder for `Support` og
`Fejl`. Opfyldt hvis mindst én af:

- `requested_by` er sat, eller
- `reporter_name` er sat, eller
- `reporter_email` er sat, eller
- sagen er en ren systemgenereret API-oprettelse
  (`reporter_channel = 'api'` + `created_via_api_key_id` + ingen reporter-navn/e-mail).

`reporter_channel` alene tæller **ikke**.

### 16.5 Historisk backfill af `start_date`

Backfill leveres som to separate scripts (ikke som del af migrationen):

- `scripts/backfill-start-dates-preview.sql` — read-only, rapporterer antal
  kandidater, tidligste/seneste dato og et udsnit på 20 sager.
- `scripts/backfill-start-dates-apply.sql` — idempotent
  `UPDATE ... WHERE start_date IS NULL`, `RAISE NOTICE` med opdaterede rækker.

Scripts køres manuelt af drift efter forudgående preview.

---

## 17. Batch, customer-reply & areas write (v1.13.0)

### 17.1 `POST /tasks/batch` — best-effort batch PATCH

Anvend samme feltsæt som `PATCH /tasks/{id}` på op til 50 opgaver i én
request. Best-effort: hver operation valideres og udføres uafhængigt, så
delvist held er forventet. Endpointet svarer altid **200** — inspicér
`results[].status` og `meta.batch`.

**Kræver:** `tasks.update`

**Body:**

```json
{
  "include": ["completion"],
  "operations": [
    {
      "id": "11111111-1111-1111-1111-111111111111",
      "patch": { "status": "Under udvikling", "priority": "P2" }
    },
    {
      "id": "22222222-2222-2222-2222-222222222222",
      "patch": { "tags": ["kiro", "sprint-42"] },
      "include": ["completion"]
    }
  ]
}
```

- `operations[].id` — UUID på task.
- `operations[].patch` — samme accepterede felter som enkelt-PATCH.
- `include: ["completion"]` — enten batch-niveau eller per-op; når sat
  returneres `{quality_score, color}` sammen med `task` (sparer et
  ekstra kald efter opdatering).

**Response (200):**

```json
{
  "success": true,
  "data": {
    "results": [
      { "id": "11111111-…", "status": 200, "task": { … } },
      { "id": "22222222-…", "status": 400,
        "error": { "code": "validation_error", "message": "…" } }
    ]
  },
  "meta": { "batch": { "total": 2, "succeeded": 1, "failed": 1 } }
}
```

Hver operation logges som en `task.updated`-audit-linje (med
`metadata.batch = true`), plus én samlet `tasks.batch_update`-linje.

### 17.2 `POST /tasks/{id}/comments` — `is_customer_reply`

Nyt boolean-felt på kommentar-POST. Når `true`:

- **Kræver** `visibility: "customer"` (eller default). Kombineret med
  `"internal"` returneres 400 `validation_error`.
- Kommentaren gemmes som sædvanlig **plus** `items.customer_reply`
  opdateres med samme body i samme request.
- Response udvides med `synced_to_customer_reply: true` (eller `false`
  hvis mirror-writen fejlede — kommentaren er stadig gemt).

Ændringer i `customer_reply` fanges af eksisterende `log_item_changes` og
`emit_item_webhook`, så aktivitetslog og webhooks er upåvirket.

**Eksempel:**

```bash
curl -X POST -H "Authorization: Bearer nw_live_…" \
     -H "Content-Type: application/json" \
     -d '{"body":"Vi har rullet fixet ud — sagen er lukket.",
          "is_customer_reply":true}' \
     https://nodeos.dk/api/public/v1/tasks/{id}/comments
```

### 17.3 Areas write — `POST/PATCH/DELETE /projects`

Nyt scope: **`projects.manage`**. Ligger _ikke_ i `read_write`-bundlen —
tilføj den eksplicit på nøglen, eller brug en `full_access`-nøgle.

- `POST /projects` — opretter et nyt area. Body: `{ name, description? }`.
  Area-restricted nøgler (nøgler med `area_ids.length > 0`) får 403
  `area_forbidden` — de kunne oprette et area de bagefter ikke måtte se.
- `PATCH /projects/{id}` — opdaterer `name`, `description`, `is_active`.
  Respekterer `area_ids`.
- `DELETE /projects/{id}` — **soft-delete** (sætter `is_active=false`).
  Hard delete afvises fordi `items.area_id` refererer.

**Bemærk:** `POST /tasks` returnerer `area_id` som en del af sit svar, så
efter oprettelse af et projekt kan du oprette den første sag i det og
verificere linket i samme response.

## 18. Agent-ergonomi (v1.14.0)

### 18.1 Præcis søgning

```bash
curl -H "$AUTH" "$BASE/api/public/v1/tasks?search=signing&search_field=title"
curl -H "$AUTH" "$BASE/api/public/v1/tasks?search=Signing%20key&search_field=title&exact=true"
```

`search_field` defaulter til `all` (titel + beskrivelse + tag), som er den
hidtidige adfærd — det er derfor et "bredt" resultat er forventet, når
`search_field` ikke sendes. Søgningen er ren `ILIKE`/tag-containment; der er
ingen trigram- eller full-text-indeks der udvider matchet.

Svaret ekkoer den anvendte søgning, så en agent kan verificere at parametrene
faktisk kom frem:

```json
"meta": { "search": { "term": "signing", "field": "title", "exact": false } }
```

 `exact=true` kræver at hele feltværdien matcher
(case-insensitivt) — brug den til duplikat-tjek før oprettelse.

### 18.2 `parent_item_id` ved oprettelse

`POST /tasks` accepterer nu `parent_item_id`. Parent skal ligge i samme
organisation (og for area-begrænsede nøgler i et tilladt område) og må ikke
være opgaven selv. Tidligere krævede en subtask to kald.

### 18.3 `POST /tasks/bulk`

Best-effort bulk-create: max **50** opgaver og **100** relationer per request.
Hver opgave valideres gennem samme regelsæt som `POST /tasks`.

```json
{
  "tasks": [
    { "ref": "epic", "title": "Fase 0 — Signing", "type": "feature" },
    { "ref": "sub1", "title": "Generér signing key", "parent_ref": "epic" }
  ],
  "relations": [
    { "source_ref": "epic", "target_ref": "sub1", "relation_type": "relates_to" }
  ]
}
```

`ref` er kun et lokalt navn i payloaden. `parent_ref` skal pege på en opgave
**tidligere** i arrayet og sættes som rigtig `parent_item_id`. Relationer kan
også bruge literale `source_task_id` / `target_task_id`.

Svaret er altid `200`:

```json
{
  "success": true,
  "data": { "results": [ { "ref": "epic", "status": 201, "task": { } } ], "relations": [] },
  "meta": { "batch": { "total": 2, "created": 2, "failed": 0, "relations_total": 1 } }
}
```

Kræver scope `tasks.create`.

### 18.4 Bidirektionelle relationer

```bash
curl -H "$AUTH" "$BASE/api/public/v1/tasks/<id>/relations?direction=all"
```

`direction` er `outgoing` (default, uændret adfærd), `incoming` (opgaven er
target) eller `all`. Hver række indeholder et `direction`-felt, så retningen i
grafen stadig er entydig.

## 19. Work Sessions & Delivery Evidence (v1.18.0)

> **Delivery Evidence (Phase 2) er LIVE fra v1.18.0.** Endpoints, scopes,
> statusmaskine og regler for verifikation er dokumenteret i
> [docs/DELIVERY_EVIDENCE.md](./DELIVERY_EVIDENCE.md).

> **Status: LIVE (Phase 1).** Work Sessions Contract Version **1.0**
> (`NodeOS-Work-Sessions-Contract`). Alle responses indeholder
> `meta.contract_version`.

Den normative begrebs-, tids- og targetmodel ligger i
[docs/WORK_SESSIONS.md](./WORK_SESSIONS.md). Operationel agentkontrakt:
[docs/agent-kit/AGENT_CONTRACT.md](./agent-kit/AGENT_CONTRACT.md).

**Outcome over Activity.** Sessioner er evidens for levering — ikke et mål i
sig selv. Flere events gør ikke en leverance bedre.

### 19.1 Scopes

| Scope        | Allows                                                   |
| ------------ | -------------------------------------------------------- |
| `work.read`  | `GET /work-sessions`, `GET /work-sessions/{id}`, events   |
| `work.write` | `POST /work-sessions`, `/lifecycle`, `/events`            |

`work.read` indgår i `read_only`, `work.write` i `read_write`. Eksisterende
nøgler beholder deres scopes — udsted en ny nøgle eller tilføj scopes for at
bruge endpointet.

### 19.2 Endpoints

| Metode | Path                              | Scope        | Formål                    |
| ------ | --------------------------------- | ------------ | ------------------------- |
| POST   | `/work-sessions`                  | `work.write` | Start session             |
| GET    | `/work-sessions`                  | `work.read`  | Liste (filtre nedenfor)   |
| GET    | `/work-sessions/{id}`             | `work.read`  | Detaljer (+ `?include=events`) |
| POST   | `/work-sessions/{id}/lifecycle`   | `work.write` | Statusovergang            |
| POST   | `/work-sessions/{id}/events`      | `work.write` | Tilføj work event         |
| GET    | `/work-sessions/{id}/events`      | `work.read`  | Eventlog (`after_seq`)    |

**Start**

```bash
curl -X POST https://nodeos.dk/api/public/v1/work-sessions \
  -H "Authorization: Bearer $NODEOS_API_KEY" -H 'content-type: application/json' \
  -d '{"item_id":"<uuid>","purpose":"Rette fejl i fakturaeksport",
       "actor_type":"ai_agent","external_session_id":"kiro-2026-08-04-01"}'
```

`purpose` er påkrævet og skal beskrive det tilsigtede **udfald**.
`actor_type`: `human` | `ai_agent` (default) | `system` | `hybrid`.
Agentidentitet (`agent_kind`, `agent_model`, `agent_runtime`, `agent_version`)
arves fra API-nøglen og kan overskrives i bodyen.

**Lifecycle**

```bash
curl -X POST .../work-sessions/<id>/lifecycle \
  -H "Authorization: Bearer $NODEOS_API_KEY" -H 'content-type: application/json' \
  -d '{"action":"complete","outcome":"delivered","summary":"Eksport virker igen"}'
```

`action`: `pause` | `resume` | `wait` | `complete` | `fail` | `cancel`.
`wait` tager `wait_for`: `human` (default) | `system` | `external`.
Ved `complete` er `outcome` **påkrævet** (`delivered`, `partially_delivered`,
`blocked`, `no_change`, `failed`, `cancelled`). Ugyldige overgange giver
`409 invalid_transition`. Terminale sessioner kan ikke genåbnes.

**Events**

```bash
curl -X POST .../work-sessions/<id>/events \
  -H "Authorization: Bearer $NODEOS_API_KEY" -H 'content-type: application/json' \
  -d '{"event_type":"milestone","message":"Rodårsag fundet","idempotency_key":"m1"}'
```

Tilladte `event_type` her: `waiting_ended`, `milestone`, `note`, `analysis`,
`implementation`, `review`, `test_run`, `build`, `deployment`,
`evidence_recorded`, `status_changed`, `error`, `correction`.
Lifecycle-events (`session_started`, `session_paused`, `session_resumed`,
`session_completed`) skrives kun af `/lifecycle` og afvises her med 400.

### 19.3 Tid — tre tal, aldrig ét

Hver session returnerer et `time`-objekt (`basis: "server_clock"`):

| Felt                       | Betydning                                        |
| -------------------------- | ------------------------------------------------ |
| `wall_clock_span_seconds`  | Union af aktive intervaller (overlap tælles én gang) |
| `session_time_sum_seconds` | Sum af aktive intervaller (overlap tælles flere gange) |
| `calendar_elapsed_seconds` | Første start → sidste slut                        |
| `has_open_interval`        | Om uret kører lige nu                            |

Uret kører **kun** i status `active`. `paused` og alle `waiting_*` stopper det.
Timeversioner i timer (`*_hours`) er afrundede bekvemmelighedsværdier.
Ingen af disse tal må præsenteres som "menneskelig ækvivalent tid".

### 19.4 Garantier

- **Serveren ejer tiden.** `client_reported_at` gemmes, men er rent diagnostisk.
- **Server-tildelt `seq`.** Rækkefølge fastlægges af serveren, ikke klienttid.
- **Append-only.** Events kan ikke rettes eller slettes; korrektioner er nye
  events af typen `correction`.
- **Idempotency (v1.17.0).** `Idempotency-Key`-header (eller
  `external_session_id`) på start; `idempotency_key` på events. Nøglen er
  bundet til payloadens indhold:
  - Samme nøgle + samme payload → det oprindelige svar, header
    `Idempotent-Replay: true`.
  - Samme nøgle + **anden** payload → `409 idempotency_key_reuse`. Der oprettes
    ikke en ny session/event, og den gamle returneres ikke stille.
  - Samme nøgle mens den første forespørgsel stadig kører →
    `409 idempotency_in_progress`. Prøv igen om et øjeblik.

  Fingeraftrykket er en SHA-256 af den kanoniske (nøgle-sorterede) semantiske
  payload. For start: `item_id`, `purpose`, `actor_type`, `parent_session_id`,
  `external_session_id`, `metadata`. For events: `event_type`, `message`,
  `metadata`. Headere, `Authorization` og `correlation_id` indgår ikke — de kan
  variere frit mellem retries.
- **Ingen idempotency på `/lifecycle`.** Overgange er beskyttet af
  tilstandsmaskinen: en gentaget `complete` giver `409 invalid_transition`.
  Brug ikke `Idempotency-Key` her; den ignoreres.
- **Internt som standard.** Sessions- og eventdata er interne. Kundebrugere og
  kundevendte visninger ser dem ikke.
- **Timeout-sikkerhedsnet.** Et planlagt job kører hvert 15. minut og lukker
  sessioner uden aktivitet i 4 timer som `failed` med `outcome: "blocked"` og
  `end_reason: "timeout"`. Det åbne interval lukkes ved `last_activity_at`, så
  ventetiden efter sidste aktivitet ikke tælles som aktiv tid.

### 19.5 Filtre på `GET /work-sessions`

`item_id`, `status`, `correlation_id`, `open=true`, `limit` (≤200), `offset`.

> **Advarsel: læg ikke `wall_clock_span` sammen på tværs af sessioner.**
> Tiden er beregnet **pr. session**. To sessioner, der kørte samtidig i 4
> sekunder, returnerer 4 s hver — summen 8 s beskriver ikke 8 sekunders
> arbejde. Fra v1.20.0 findes den korrekte union på tværs af alle sessioner på
> en sag i `work` i `GET /tasks/{id}/delivery` (§20). Brug den i stedet for at
> summere selv. Se
> [WORK_SESSIONS.md §4](./WORK_SESSIONS.md#4-parallelitetsregler).


### 19.6 `actual_hours`

**Current state:** `actual_hours` er fortsat et manuelt felt på sagen med
uændret validering, rettigheder og completion-checklist-adfærd. Sessioner
opdaterer **ikke** `actual_hours` automatisk i Phase 1.

**Target semantics:** `actual_hours` forstås som *godkendt fakturerbar mængde i
timer* — et kommercielt godkendt timegrundlag, ikke en måling af AI-tid,
menneskelig tid, systemtid, ventetid eller hypotetisk menneskelig ækvivalent.
Se [WORK_SESSIONS.md §2.9](./WORK_SESSIONS.md#29-godkendt-fakturerbar-mængde-i-timer-actual_hours).


---

## 20. Delivery Consolidation & Surface (v1.20.0)

> **Status: LIVE (Phase 2.2).** Konsolideringsfasen samler arbejde, evidens,
> integritet og releases til ét læsedomæne med én synlighedsregel.
> Normative referencer: [DELIVERY_EVIDENCE.md](./DELIVERY_EVIDENCE.md) og
> [DELIVERY_INTEGRITY.md](./DELIVERY_INTEGRITY.md).

### 20.1 Aktørklassifikation

Scopes og synlighed er **to adskilte beslutninger**. Et scope siger hvad en
aktør må *gøre*. Aktørklassen på nøglen siger hvad den må *se*.

| `actor_class` | Typisk bruger              | Ser evidens med visibility     |
| ------------- | -------------------------- | ------------------------------ |
| `internal`    | Medarbejder-nøgle          | `internal`, `customer`, `public` |
| `service`     | Intern automatisering, CI  | `internal`, `customer`, `public` |
| `customer`    | Kundens egen nøgle         | `customer`, `public`             |
| `integration` | Tredjeparts-integration    | `customer`, `public`             |

Porten anvendes på evidensmængden **før** noget afledes. Tal, `gaps`,
supersede-kæder, evidensgraf og tidslinje beregnes udelukkende på den synlige
delmængde, så en kundevendt aktør ikke kan udlede, at der findes skjult intern
evidens. Opslag på evidens, aktøren ikke må se, giver `404 not_found` — aldrig
`403`, som ville bekræfte eksistensen.

Kundevendte projektioner nulstiller desuden `metadata`, `verified_by`,
`rejected_by`, `rejection_reason` samt oprettende bruger-/nøgle-id'er.

### 20.2 `GET /tasks/{id}/delivery`

Scope `evidence.read`. Read-only. Ét kald erstatter fem.

Query: `timeline=false` udelader tidslinjen.

```json
{
  "success": true,
  "data": {
    "task": { "id": "…", "title": "…", "status": "Released", "…": "…" },
    "delivered": true,
    "work": {
      "wall_clock_span_seconds": 5400,
      "wall_clock_span_hours": 1.5,
      "session_time_sum_seconds": 7200,
      "calendar_elapsed_seconds": 86400,
      "has_open_interval": false,
      "sessions_total": 3,
      "sessions_active": 0,
      "sessions_completed": 3,
      "actor_types": ["ai_agent", "human"],
      "sessions": [{ "id": "…", "purpose": "implementation", "…": "…" }]
    },
    "evidence": { "total": 4, "verified": 2, "items": [] },
    "integrity": { "integrity_result": "STRONG", "confidence_score": 88, "gaps": [] },
    "releases": [],
    "release_snapshots": [],
    "timeline": []
  },
  "meta": {
    "read_model_version": "1.0",
    "integrity_contract_version": "1.0",
    "integrity_algorithm_fingerprint": "…",
    "audience": "internal"
  }
}
```

`work.wall_clock_span_*` er den **server-beregnede union** på tværs af alle
sessioner på sagen: overlappende arbejde tælles én gang. Genberegn den ikke ved
at summere per-session-værdier (§19.5).

For en kundevendt aktør indeholder `work` kun `calendar_elapsed_hours`,
`sessions_total` og `sessions_completed` — ikke hvem eller hvad der arbejdede,
og ikke agentens egen fritekst. `integrity` reduceres til resultat og score
uden interne gaps, og `releases` filtreres til `visibility: customer`.

### 20.3 Tidslinje

Tidslinjen normaliserer fem kilder til én kronologisk strøm: statusskift,
work sessions, work events, evidenshændelser (oprettet, verificeret, afvist,
superseded) og releases. Hver post har `at`, `kind`, `title` og en
audience-markering; kundevendte aktører får kun de poster, der er
kundeegnede. Rækkefølgen er serverens — klienter skal ikke re-sortere.

### 20.4 Release Snapshots

Integritet beregnes on-the-fly og er derfor altid aktuel og aldrig historisk:
tilføjes eller superseders evidens, er gårsdagens billede væk. Når en sag går
til `Released`, fryses derfor et **immutabelt** snapshot i
`item_release_snapshots`:

- `integrity_result`, `confidence_score`, `integrity_gaps`
- `completion_score`, beståede og manglende regel-id'er
- aktive og verificerede evidens-id'er plus antal
- work-totaler og aktørtyper på leveringstidspunktet
- alle kontraktversioner: `api_version`,
  `work_sessions_contract_version`, `delivery_evidence_contract_version`,
  `delivery_integrity_contract_version` og
  `integrity_algorithm_fingerprint`

Snapshots kan ikke redigeres eller slettes. De besvarer ét spørgsmål, som
ingen live-forespørgsel nogensinde kan besvare igen: *hvad troede vi, da vi
leverede?* Capture er best effort — et fejlet snapshot blokerer eller ruller
aldrig statusskiftet tilbage.

### 20.5 Integritetsversionering

Scoringen kan udvikle sig. For at gamle tal forbliver forståelige eksponerer
`/api/public/health` og hvert snapshot både
`delivery_integrity_contract_version` og et
`integrity_algorithm_fingerprint`. To scores må kun sammenlignes direkte, hvis
fingeraftrykket er identisk.

### 20.6 Invarianter

- Integritet og completion er **rådgivende**. Ingen statusovergang blokeres.
- `actual_hours` ændres aldrig automatisk — heller ikke af sessioner,
  evidens eller snapshots.
- Evidens er append-only. Sletning findes ikke; erstatning sker via supersede.
- Verifikation kræver `evidence.verify`, som `admin.full` **ikke** giver, og
  selvverifikation afvises altid (`403 self_verification_forbidden`).
