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/eventswithContent-Type: application/x-ndjson.- Authenticated with a machine token —
Authorization: Bearer mk_…(orx-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
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.
| Field | Meaning |
|---|---|
| kind | Literal "lodestar.session_ship". |
| version | Literal 1 — the @1 in session_ship@1. |
| project_id | The project this session belongs to. Every record must match it. |
| session_id | The shipped session. Every record must match it. |
| event_count | Number of record lines that follow. Must equal the actual count. |
| ceiling | The sensitivity ceiling this batch was gated at — public · internal · confidential · secret. |
| redacted_count | How 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. Thepayload_hashis kept unchanged, so a later clearance can verify the original against it.payload_sensitivityis 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:falserecord,payload_hashmust equalcanonicalHash(payload)— else422 hash_mismatch. Onredacted:truethe check is skipped and the hash is retained as a commitment. - Order.
seqis strictly increasing within the POST. - Scope. Every record's
project_id/session_idmatches 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 asduplicates, never re-inserted.
Retrying the whole batch is always safe
(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 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
| Status | Code | When |
|---|---|---|
| 400 | empty_body | No body, or not application/x-ndjson. |
| 400 | bad_manifest | Line 1 is not valid JSON, or fails manifest validation. |
| 400 | bad_record | A record line is not valid JSON, or fails record validation. |
| 400 | scope_mismatch | A record's project_id / session_id doesn't match the manifest. |
| 400 | seq_order | seq is not strictly increasing within the POST. |
| 400 | count_mismatch | The manifest's event_count doesn't match the number of records. |
| 401 | missing_credentials · invalid_credentials | No token supplied, or the token is unknown. |
| 402 | quota_exceeded | A genuinely new project/session would exceed a configured free-tier cap. |
| 403 | project_scope_conflict | A project-scoped key tried to ingest into a different project. |
| 413 | too_many_events | More records than the per-POST event cap. |
| 413 | payload_too_large | Body exceeds the ingest byte ceiling (returns limit_bytes). |
| 422 | hash_mismatch | A 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 withproxy-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