# Project Events Write API — Spec v1

**Author:** Atlas
**Reviewer:** Riv (sign off before building poller against this)
**Date:** 2026-05-18
**Status:** Draft — awaiting Riv ack

Used by: Riv's mem.ai poller, Larry's session-summary writes, file-drop hooks, and any specialist's `handoff` posts. One endpoint, one shape.

---

## Endpoint

`POST /api/project-events`

**Base URL (local):** `http://localhost:3000/api/project-events`
(Atlas Next.js runs on port 3000 in production via PM2 `next start` — the `:80` in `package.json` is dev-mode-only and not what's currently live.)

**Auth:** Bearer token via `Authorization: Bearer $ATLAS_INGEST_TOKEN`. Token lives in `app/.env.local`. Riv stores it in Zapier/Make credential store, never inline.

**Content-Type:** `application/json`

---

## Request body

```jsonc
{
  "source":      "mem.ai",            // required — enum, see below
  "external_id": "mem_abc123",        // required when source="mem.ai" or "file-drop"; optional otherwise
  "project_id":  null,                // null → unclassified bucket; integer → direct link
  "event_type":  "recording",         // required — enum, see below
  "payload":     { ... },             // required, jsonb — schema by event_type below
  "actor":       "jimmie",            // optional — defaults to source
  "created_at":  "2026-05-18T14:22:00Z" // optional — server uses now() if omitted
}
```

### Enums

| Field | Allowed values |
|---|---|
| `source` | `mem.ai`, `larry-session`, `specialist`, `file-drop`, `manual` |
| `event_type` | `decision`, `next_action`, `recording`, `chat_summary`, `handoff`, `file_added`, `status_change`, `note` |

### Payload shape by event_type

Loose JSON — all keys optional except where noted. Stored verbatim in `payload` jsonb. The classifier reads these to assign `project_id` when null.

| event_type | Expected keys |
|---|---|
| `recording` | `text` (transcript), `audio_url`, `mem_url`, `duration_sec` |
| `chat_summary` | `text`, `session_id`, `decisions[]`, `next_actions[]` |
| `decision` | `text` (the decision), `rationale` |
| `next_action` | `text`, `assignee`, `due` |
| `handoff` | `from_actor`, `to_actor`, `text`, `artifact_path` |
| `file_added` | `document_id`, `path`, `mime`, `auto_tags[]` |
| `status_change` | `from`, `to`, `reason` |
| `note` | `text` |

Riv: for `recording` from Plaud-via-mem.ai, populate `text`, `audio_url` (if Plaud), `mem_url`, `duration_sec`. `external_id` = the mem.ai entry ID.

---

## Idempotency contract

**Critical.** Riv's poller must rely on this — Atlas guarantees it.

- The unique constraint `project_events_source_external_uniq` enforces `(source, external_id)` uniqueness when `external_id IS NOT NULL`.
- A retried POST with the same `(source, external_id)` returns **`200 OK`** with the existing event record, not a duplicate insert and not an error.
- A POST with the same `(source, external_id)` but a *changed* `payload` performs an UPDATE on the existing row (last-write-wins on payload). Use this for "mem.ai entry edited" scenarios.
- A POST without `external_id` always inserts a new row. Use sparingly — `manual` and `larry-session` may go this route.

**Riv's takeaway:** Send `(source: "mem.ai", external_id: <mem entry id>)` on every poll. Repeated polls = safe no-ops.

---

## Responses

### 201 Created — new event inserted
```jsonc
{
  "ok": true,
  "event": { "id": 42, "project_id": null, ... },
  "classified": false  // true if classifier assigned project_id
}
```

### 200 OK — idempotent re-post (existing event)
```jsonc
{
  "ok": true,
  "event": { ... },
  "deduped": true
}
```

### 400 Bad Request — validation failure
```jsonc
{ "ok": false, "error": "source must be one of ...", "field": "source" }
```

### 401 Unauthorized — missing or bad token
### 500 Internal — server error (retryable per Riv's exception path)

---

## Classifier behavior

When `project_id` is null on insert:

1. Atlas runs the classifier inline (LLM call, ~1-2 sec).
2. If classifier confidence ≥ threshold (start: 0.75), event's `project_id` is set.
3. Otherwise stays null → lands in unclassified bucket UI.
4. Either way, response is returned promptly. No long polls.

Classifier latency budget: < 3 sec total round-trip. If LLM is slow, fall back to async — event stored unclassified, classified later by a background job.

Riv: don't change polling behavior based on classifier outcome. Atlas owns that.

---

## Trigger side-effects

- `projects.last_touched_at` is auto-updated to `created_at` whenever an event with a non-null `project_id` lands (via the `trg_bump_project_last_touched` trigger).
- No other side-effects yet. Future: webhook fan-out to subscribers (TBD).

---

## Open questions for Riv

1. **Rate limits.** Expected polling rate from mem.ai is ~1 poll/15min. Within a poll, batch size? Suggest the poller POST events one at a time vs. a batch endpoint. (Atlas leans single-event; simpler idempotency. Open to batch if Riv needs it.)
2. **Token rotation cadence.** Atlas will start with a static token in `.env.local`. Riv — do you want OAuth-style refresh, or is static + manual rotation fine for a local-first system?
3. **Backfill marker.** For the 90-day mem.ai backfill, do we want a header like `X-Backfill: true` to suppress classifier notifications, or just let it flow?

Sign off this doc (or red-line it) and we both build against it.

---

**File location:** `C:\PKA\Team Inbox\Atlas\continuity-system\specs\project-events-api.md`
**Next:** Atlas implements the endpoint + classifier; Riv builds the poller. Sync target: 2026-05-19.
