Skip to content

Contract

Ingest contract — session_ship@1

How a recorded session gets into Machinaut. This is the receiver side of the Lodestar OSS shipper's wire format: your environment produces it, Machinaut ingests it. Machinaut never runs the writer.

POST /v1/events is a receiver for the OSS shipper's output — NDJSON, one bounded POST per session, idempotent to retry.

The Lodestar shipper (lodestar ship) serializes a session's event log and POSTs it here. Machinaut validates its structure and tamper-evidence, then stores it tenant-scoped. It does not run the log writer, re-derive the chain, or mutate what you sent — see the trust boundary.

The endpoint

  • POST /v1/events with Content-Type: application/x-ndjson.
  • Authenticated with a machine token — Authorization: Bearer mk_… (or x-api-key). A project-scoped key may only ingest into its own project; the receiver checks every envelope against the manifest, so one check bounds the whole batch.
  • Batch, not a live tail. There is no --follow; freshness is a cheap re-ship (dedupe makes it free — see below).

The wire format

NDJSON: line 1 is the manifest, lines 2…N are wrapper records in seq order. One line per event.

POST /v1/events — requesthttp
POST /v1/events
Authorization: Bearer mk_live_…
Content-Type: application/x-ndjson

{"kind":"lodestar.session_ship","version":1,"project_id":"my-agent","session_id":"s-2026-07-06-01","event_count":3,"ceiling":"internal","redacted_count":1}
{"v":1,"redacted":false,"envelope":{"id":"e0","seq":0,"type":"observation.recorded","schema_version":"1","project_id":"my-agent","session_id":"s-2026-07-06-01","actor_id":"agent:claude-code","timestamp":"2026-07-06T09:00:00Z","logical_clock":0,"causal_parent_ids":[],"payload_hash":"…","payload":{ /* … */ },"versions":{ /* … */ }}}
{"v":1,"redacted":false,"envelope":{"id":"e1","seq":1,"type":"claim.made","schema_version":"1", /* … */ "payload_hash":"…","payload":{ /* … */ }}}
{"v":1,"redacted":true,"payload_sensitivity":"secret","envelope":{"id":"e2","seq":2,"type":"observation.recorded","schema_version":"1", /* … */ "payload_hash":"…UNCHANGED…","payload":{"lodestar.redacted":true}}}

The manifest (line 1)

What this batch is and how it was gated.

FieldMeaning
kindLiteral "lodestar.session_ship".
versionLiteral 1 — the @1 in session_ship@1.
project_idThe project this session belongs to. Every record must match it.
session_idThe shipped session. Every record must match it.
event_countNumber of record lines that follow. Must equal the actual count.
ceilingThe sensitivity ceiling this batch was gated at — public · internal · confidential · secret.
redacted_countHow many records were redacted at the source before shipping.

The records (lines 2…N)

One wrapper per event, discriminated on redacted:

  • { v: 1, redacted: false, envelope } — the envelope ships verbatim.
  • { v: 1, redacted: true, payload_sensitivity, envelope } — the payload was above the ceiling, so it's replaced with { "lodestar.redacted": true } at the source. The payload_hash is kept unchanged, so a later clearance can verify the original against it. payload_sensitivity is the class that triggered redaction, and it lives on the wrapper, never on the envelope.

The envelope

The atomic unit Lodestar writes and Machinaut stores — the OSS EventEnvelope. Its fields: id, seq, type, schema_version, project_id, session_id, actor_id, timestamp, logical_clock, causal_parent_ids, payload_hash (sha-256 hex), payload, versions, and an optional signature. Machinaut consumes this type verbatim — it does not define its own.

The receiver contract

Machinaut enforces exactly these rules; any violation fails the whole POST with no partial writes:

  • Tamper-evidence. For every redacted:false record, payload_hash must equal canonicalHash(payload) — else 422 hash_mismatch. On redacted:true the check is skipped and the hash is retained as a commitment.
  • Order. seq is strictly increasing within the POST.
  • Scope. Every record's project_id / session_id matches the manifest.
  • Count. The record count equals event_count.
  • Dedupe. The dedupe key is (project_id, session_id, seq). Re-POSTing a shipped session is an idempotent retry-all: already-seen records come back as duplicates, never re-inserted.

Retrying the whole batch is always safe

A non-2xx means the entire POST failed — nothing was written — so the correct recovery is to re-send the whole thing. And because dedupe is on (project_id, session_id, seq), re-shipping a session that grew only inserts the new tail. There is no partial-write state to reconcile.

The response

A 200 confirms what landed. inserted + duplicates account for every record; alerts_dispatched counts channels that accepted a forwarded sentinel alert this POST (best-effort — it never affects the ingest outcome).

200 — response bodyjson
200 OK
{
  "accepted": true,
  "project_id": "my-agent",
  "session_id": "s-2026-07-06-01",
  "event_count": 3,
  "redacted_count": 1,
  "inserted": 3,
  "duplicates": 0,
  "alerts_dispatched": 0
}

Errors

StatusCodeWhen
400empty_bodyNo body, or not application/x-ndjson.
400bad_manifestLine 1 is not valid JSON, or fails manifest validation.
400bad_recordA record line is not valid JSON, or fails record validation.
400scope_mismatchA record's project_id / session_id doesn't match the manifest.
400seq_orderseq is not strictly increasing within the POST.
400count_mismatchThe manifest's event_count doesn't match the number of records.
401missing_credentials · invalid_credentialsNo token supplied, or the token is unknown.
402quota_exceededA genuinely new project/session would exceed a configured free-tier cap.
403project_scope_conflictA project-scoped key tried to ingest into a different project.
413too_many_eventsMore records than the per-POST event cap.
413payload_too_largeBody exceeds the ingest byte ceiling (returns limit_bytes).
422hash_mismatchA redacted:false record's payload_hash ≠ canonicalHash(payload).

Limits

  • Body size. The ingest ceiling is 25 MiB per POST (checked at the edge on Content-Length, then authoritatively on a bounded read). A self-hosted Ingress admits it with proxy-body-size: 26m.
  • Event count. A per-POST record cap, enforced on the actual record-line count before the parse loop runs — so a manifest that under-declares can't smuggle past it.
  • Quota. In a metered posture, only a genuinely new project or session past a configured cap is refused (402). A re-ship, or a new session in an existing project, is never blocked — and the approval loop is never gated.

Related

The hold half of the loop is the approval relay contract. Every route, grouped and status-labelled, is in the API reference. The terms here — envelope, ceiling, ship, payload hash — are defined in the glossary.