the http transport already exposes /mcp, /rpc, /healthz, /health, and /capabilities (src/vouch/http_server.py), but every write path still requires a caller that can speak the mcp or jsonl envelope. a lot of upstream producers — ci jobs, cron scripts, form backends, log shippers — can only do one thing well: POST a chunk of text at a url. today they have no way to hand that content to a kb without embedding a full client.
add an authenticated POST /ingest that accepts a small json payload and files it as a proposal into the pending queue. it is a thin front door over the same proposals.propose_* path the other surfaces use — it never writes an approved artifact, it just enqueues review work that a human later walks with vouch review / kb.approve.
proposed surface
new route on the existing starlette app in make_app():
POST /ingest
authorization: bearer <token>
content-type: application/json
{
"kind": "claim", // claim | page | entity | relation
"text": "…", // claim text / page body per kind
"evidence": ["source-id-or-url"],
"claim_type": "observation",
"confidence": 0.7,
"tags": ["ci", "nightly"],
"rationale": "posted by nightly-drift job",
"slug_hint": "…" // optional
}
behaviour:
- dispatches by
kind to proposals.propose_claim / propose_page / propose_entity / propose_relation — the exact functions _h_propose_* in src/vouch/jsonl_server.py already call. no new business logic; /ingest is a payload adapter, not a second write path.
- responds
202 with {"ok": true, "proposal_id": "…", "status": "pending", "kind": "…"} on success; 400 on a bad/unknown-kind payload and 413 on an oversize body (reuse MAX_BODY_BYTES and the _error_payload shape already in the file); 401 when the bearer gate rejects.
dry_run: true is honoured and returns the proposal shape without enqueuing, mirroring propose_claim(dry_run=...).
- attribution: read
X-Vouch-Agent for proposed_by exactly as _rpc does, so ingested proposals are traceable to their producer in the audit log.
config stays in the existing serve: block — no new keys. an operator who wants /ingest reachable off-box uses the same bearer_tokens accept-list and --allow-public already used for /rpc and /mcp.
review gate & scope
/ingest can only propose, never approve. it calls propose_* and stops; there is no code path from this endpoint to proposals.approve(). approval remains a separate, human-driven step through kb.approve (mcp/jsonl/cli). that keeps the load-bearing invariant intact: every write still goes through the review gate, and this background-driven front door never auto-writes or auto-approves.
the endpoint runs behind the same bearer middleware as /rpc and /mcp, and it sets the trust context to a remote authenticated subject before dispatching — trust_mod.with_auth_subject(trust_mod.JSONL_HTTP, bearer) inside trust_mod.trust_context(...), the same wiring _rpc uses (http_server.py around lines 203-216). so the remote=True boundary from #227 applies: an ingested payload is stamped as a remote, authenticated caller and is subject to every confinement a remote caller gets. combined with the endpoint physically lacking an approve path, this closes the door #168 warned about (a remote transport reaching an approval) rather than reopening it.
local-first is unchanged: default bind stays 127.0.0.1, make_server's bind policy still refuses a non-loopback host without both --allow-public and a token, and nothing here calls out to a network service.
since /ingest is not a new kb.* method — it is a transport-level convenience over existing propose_* methods — it does not add a kb.* verb and so does not touch the four registration sites (server.py, jsonl_server.py HANDLERS, capabilities.METHODS, cli.py). if review prefers to expose it as a first-class method instead, that becomes a kb.* addition and must register in all four plus a test; the transport-only framing here is deliberately narrower.
this is distinct from the adjacent issues: #176 shipped the http transport and its /mcp + /rpc + auth scaffolding but no bulk-ingest front door; #227 defines the remote trust boundary this endpoint consumes rather than defines; #168 is the cross-agent-approval hole this endpoint must stay clear of, not touch.
acceptance criteria
the http transport already exposes
/mcp,/rpc,/healthz,/health, and/capabilities(src/vouch/http_server.py), but every write path still requires a caller that can speak the mcp or jsonl envelope. a lot of upstream producers — ci jobs, cron scripts, form backends, log shippers — can only do one thing well:POSTa chunk of text at a url. today they have no way to hand that content to a kb without embedding a full client.add an authenticated
POST /ingestthat accepts a small json payload and files it as a proposal into the pending queue. it is a thin front door over the sameproposals.propose_*path the other surfaces use — it never writes an approved artifact, it just enqueues review work that a human later walks withvouch review/kb.approve.proposed surface
new route on the existing starlette app in
make_app():behaviour:
kindtoproposals.propose_claim/propose_page/propose_entity/propose_relation— the exact functions_h_propose_*insrc/vouch/jsonl_server.pyalready call. no new business logic;/ingestis a payload adapter, not a second write path.202with{"ok": true, "proposal_id": "…", "status": "pending", "kind": "…"}on success;400on a bad/unknown-kind payload and413on an oversize body (reuseMAX_BODY_BYTESand the_error_payloadshape already in the file);401when the bearer gate rejects.dry_run: trueis honoured and returns the proposal shape without enqueuing, mirroringpropose_claim(dry_run=...).X-Vouch-Agentforproposed_byexactly as_rpcdoes, so ingested proposals are traceable to their producer in the audit log.config stays in the existing
serve:block — no new keys. an operator who wants/ingestreachable off-box uses the samebearer_tokensaccept-list and--allow-publicalready used for/rpcand/mcp.review gate & scope
/ingestcan only propose, never approve. it callspropose_*and stops; there is no code path from this endpoint toproposals.approve(). approval remains a separate, human-driven step throughkb.approve(mcp/jsonl/cli). that keeps the load-bearing invariant intact: every write still goes through the review gate, and this background-driven front door never auto-writes or auto-approves.the endpoint runs behind the same bearer middleware as
/rpcand/mcp, and it sets the trust context to a remote authenticated subject before dispatching —trust_mod.with_auth_subject(trust_mod.JSONL_HTTP, bearer)insidetrust_mod.trust_context(...), the same wiring_rpcuses (http_server.pyaround lines 203-216). so theremote=Trueboundary from #227 applies: an ingested payload is stamped as a remote, authenticated caller and is subject to every confinement a remote caller gets. combined with the endpoint physically lacking an approve path, this closes the door #168 warned about (a remote transport reaching an approval) rather than reopening it.local-first is unchanged: default bind stays
127.0.0.1,make_server's bind policy still refuses a non-loopback host without both--allow-publicand a token, and nothing here calls out to a network service.since
/ingestis not a newkb.*method — it is a transport-level convenience over existingpropose_*methods — it does not add akb.*verb and so does not touch the four registration sites (server.py,jsonl_server.pyHANDLERS,capabilities.METHODS,cli.py). if review prefers to expose it as a first-class method instead, that becomes akb.*addition and must register in all four plus a test; the transport-only framing here is deliberately narrower.this is distinct from the adjacent issues: #176 shipped the http transport and its
/mcp+/rpc+ auth scaffolding but no bulk-ingest front door; #227 defines the remote trust boundary this endpoint consumes rather than defines; #168 is the cross-agent-approval hole this endpoint must stay clear of, not touch.acceptance criteria
POST /ingestroute added inmake_app()(src/vouch/http_server.py), methods["POST"].kindto the matchingproposals.propose_*function; unknownkind→400.202withproposal_id+status: pending; the proposal lands in the pending queue (kb.list_pending/vouch reviewshows it)./ingestreachesproposals.approve(); approval still requires a separate humankb.approve.BearerMiddleware; unauthenticated request →401;/ingestis not added to_PUBLIC_PATHS.JSONL_HTTP+with_auth_subject) for the dispatch, soremote=Trueconfinement applies.413and the existing_error_payloadshape (reuseMAX_BODY_BYTES); malformed json →400.dry_run: truereturns the proposal shape without enqueuing.X-Vouch-Agentflows intoproposed_byand appears in the audit log for the enqueued proposal.--allow-public+ a bearer token.tests/test_http_server.py(or a focusedtests/test_ingest.py): happy-path enqueue,401without token,400on bad kind, and an assertion that no approved artifact is written.