/* * context.cpp — Antheos Protocol v1.0 Level 1 Context * * Stateful codec: owns identity, SidPool, Parser, and 32 session slots. * Inbound dispatch via internal parser callbacks. * Outbound via bus/service/session verb builders. * * Copyright (c) 2025-2026 Are Bjørby * SPDX-License-Identifier: MIT */ #include "antheos.hpp" #include #include #include #include namespace antheos { /* ANTHEOS-BLOB-RADIX-COVERAGE-GAP — decode a BLOB size word, or say it could * not be decoded. * * This returned a plain size_t and answered 0 for anything it did not handle. * Base32 is one of those: §6.3 lists five radices, `allows_radix(Blob)` is true * and `is_radix_flag` accepts Base32, so this library's own encoder emits BLOB * size words this function could not read. A 0 became set_tail_length(0), so * the tail was never consumed and the payload bytes were fed to the state * machine as protocol — the frame completed, the callback fired with no tail, * and the parser resynchronised on whatever byte in the payload happened to * look like SOM. A peer using a radix this library calls legal desynchronised a * receiver with no diagnostic anywhere. * * `Base32` is the enum's name for what §6.3 calls DUOTRIGESIMAL — base 32 * NUMERIC, the same kind of thing as the other four, not RFC 4648 byte * encoding. The name invites the wrong reading; strtoull's base argument is * the right one, and it already spans 2..36. * * nullopt now means "could not decode", which is what a size of 0 could not * say: a body that really says zero is a legitimate empty tail, and the two had * exactly the same effect. */ static std::optional decode_blob_size(wire::Radix radix, const uint8_t* body, size_t len) { if (len == 0) return std::nullopt; /* an empty body declares nothing */ std::string s(reinterpret_cast(body), len); int base = 0; switch (radix) { case wire::Radix::Binary: base = 2; break; case wire::Radix::Octal: base = 8; break; case wire::Radix::Decimal: base = 10; break; case wire::Radix::Hex: base = 16; break; case wire::Radix::Base32: base = 32; break; /* §6.3 duotrigesimal */ default: return std::nullopt; } try { size_t consumed = 0; unsigned long long v = std::stoull(s, &consumed, base); /* Trailing junk is not a size. stoull stops at the first character the * base does not accept and reports success on the prefix, so without * this "12x" would declare 12 and the rest of the stream would shift. */ if (consumed != s.size()) return std::nullopt; return static_cast(v); } catch (...) { return std::nullopt; } } struct Context::Impl { /* ANTHEOS-BLOB-RADIX-COVERAGE-GAP — BLOB size words this Context could not * decode. Separate from the parser's own parse_errors(): the bytes were * well-formed protocol, it is the DECLARED SIZE that could not be read. */ size_t blob_size_errors_ = 0; /* ANTHEOS-SCRATCH-IDS-SILENT-DROP — IDs a message carried past * MAX_SCRATCH_IDS, so a truncated message is distinguishable * from one that fit. */ size_t scratch_ids_dropped_ = 0; std::string oid, did, iid, bid_str; SidPool sid_pool; Parser parser; struct SessionSlot { std::string sid; uint32_t mid = 0; SessionState state = SessionState::Idle; }; SessionSlot sessions[MAX_SESSIONS]; Context::MessageCb msg_cb; Context::OfferCb offer_cb; Context::EventCb event_cb; Context::RelayCb relay_cb; /* Parser scratch — accumulated during word callbacks, dispatched on EOM */ static constexpr int MAX_SCRATCH_IDS = 4; char scratch_verb = 0; std::string scratch_ids[MAX_SCRATCH_IDS]; wire::Radix scratch_id_radix[MAX_SCRATCH_IDS]{}; int scratch_id_count = 0; uint32_t scratch_mid = 0; bool scratch_has_mid = false; std::string scratch_body; bool scratch_has_body = false; char scratch_msg_ref = 0; bool scratch_has_msg_ref = false; uint32_t scratch_integer = 0; bool scratch_has_integer = false; std::string scratch_path; bool scratch_has_path = false; size_t scratch_blob_total = 0; /* BLOB tail captured by on_tail, read via last_tail() */ std::vector last_tail_; Impl(std::string_view o, std::string_view d, std::string_view i, std::string_view b) : oid(o), did(d), iid(i), bid_str(b), sid_pool(o, d, i) { parser.on_word([this](wire::WordType type, wire::Radix radix, wire::Unit unit, const uint8_t* body, size_t len) { on_parser_word(type, radix, unit, body, len); }); parser.on_tail([this](const uint8_t* data, size_t len) { last_tail_.assign(data, data + len); }); parser.on_message([this](size_t word_count) { on_parser_message(word_count); }); } void scratch_reset() { scratch_verb = 0; scratch_id_count = 0; scratch_has_mid = false; scratch_mid = 0; scratch_has_body = false; scratch_body.clear(); scratch_msg_ref = 0; scratch_has_msg_ref = false; scratch_has_integer = false; scratch_integer = 0; scratch_has_path = false; scratch_path.clear(); scratch_blob_total = 0; } void on_parser_word(wire::WordType type, wire::Radix radix, wire::Unit /*unit*/, const uint8_t* body, size_t len) { switch (type) { case wire::WordType::Symbol: /* First word of a new message — clear stale tail from prior message */ if (scratch_verb == 0) last_tail_.clear(); if (len >= 1) scratch_verb = static_cast(body[0]); break; case wire::WordType::Blob: { /* ANTHEOS-BLOB-RADIX-COVERAGE-GAP — an undecodable size is counted * and the tail length is LEFT ALONE. Setting it to zero is what * desynchronised the stream; leaving it means the payload is not * mistaken for protocol, and blob_size_errors_ makes the frame * distinguishable from one that really declared an empty tail. */ auto sz = decode_blob_size(radix, body, len); if (!sz) { blob_size_errors_++; break; } scratch_blob_total += *sz; parser.set_tail_length(scratch_blob_total); break; } case wire::WordType::Id: if (radix == wire::Radix::Decimal) { std::string tmp(reinterpret_cast(body), len); scratch_mid = static_cast( std::strtoul(tmp.c_str(), nullptr, 10)); scratch_has_mid = true; } else if (scratch_id_count >= MAX_SCRATCH_IDS) { /* ANTHEOS-SCRATCH-IDS-SILENT-DROP — the surplus is COUNTED. * This branch did nothing at all: no error, no counter, no * state change, so a message carrying five or more IDs was * dispatched from the first four as though that were all of * them — a truncated message that read as a complete one. * Every verb this library BUILDS stays within four * (verify_response is the widest at three), so nothing in-tree * trips it; a peer is not bound by what this library builds. */ scratch_ids_dropped_++; } else { scratch_ids[scratch_id_count].assign( reinterpret_cast(body), len); scratch_id_radix[scratch_id_count] = radix; scratch_id_count++; } break; case wire::WordType::Text: scratch_body.assign( reinterpret_cast(body), len); scratch_has_body = true; break; case wire::WordType::Message: if (len >= 1) { scratch_msg_ref = static_cast(body[0]); scratch_has_msg_ref = true; } break; case wire::WordType::Integer: { std::string tmp(reinterpret_cast(body), len); scratch_integer = static_cast( std::strtoul(tmp.c_str(), nullptr, 10)); scratch_has_integer = true; break; } case wire::WordType::Path: scratch_path.assign( reinterpret_cast(body), len); scratch_has_path = true; break; default: break; } } void on_parser_message(size_t /*word_count*/) { std::string_view id0 = scratch_id_count > 0 ? std::string_view(scratch_ids[0]) : std::string_view{}; std::string_view id1 = scratch_id_count > 1 ? std::string_view(scratch_ids[1]) : std::string_view{}; std::string_view body = scratch_has_body ? std::string_view(scratch_body) : std::string_view{}; char verb_buf[2] = { scratch_verb, '\0' }; std::string_view verb(verb_buf, 1); switch (scratch_verb) { /* Bus events: ID-carrying verbs */ case 'E': case 'C': case 'P': case 'D': case 'W': if (event_cb) event_cb(verb, id0, id1, {}); break; /* Verify: responses carry 3 IDs (OID, DID, IID), requests carry 1 */ case 'V': { if (event_cb) { std::string_view id2 = scratch_id_count > 2 ? std::string_view(scratch_ids[2]) : std::string_view{}; event_cb(verb, id0, id1, id2); } break; } /* Relay: dispatch via relay_cb if MESSAGE word present, else event_cb */ case 'R': if (scratch_has_msg_ref && relay_cb) { std::string_view path_sv = scratch_has_path ? std::string_view(scratch_path) : std::string_view{}; uint32_t idx = scratch_has_integer ? scratch_integer : 0; relay_cb(scratch_msg_ref, id0, id1, body, idx, path_sv); } else if (event_cb) { event_cb(verb, id0, {}, body); } break; /* Bus events: text-carrying verbs */ case 'B': case 'X': if (event_cb) event_cb(verb, id0, {}, body); break; /* Auth: Z-verb (Level 2) — challenge or response */ case 'Z': if (event_cb) event_cb(verb, id0, id1, body); break; /* Service: query */ case 'Q': if (event_cb) event_cb(verb, {}, {}, body); break; /* Service: offer */ case 'O': if (offer_cb) offer_cb(id0, body); break; /* Service: accept */ case 'A': if (event_cb) event_cb(verb, id0, id1, {}); break; /* Session messages: call, notify */ case 'K': case 'N': if (msg_cb) { uint32_t mid = scratch_has_mid ? scratch_mid : 0; msg_cb(verb, id0, id1, mid, body); } break; /* Session events: status, locate, resume, finish */ case 'T': case 'L': case 'U': case 'F': if (event_cb) { std::string_view detail = scratch_has_body ? body : id1; event_cb(verb, id0, {}, detail); } break; default: break; } scratch_reset(); } /* MID advance with wrap detection */ struct MidResult { uint32_t mid; bool wrap; }; MidResult advance_mid(SessionSlot& s) { if (s.mid == 0) { s.mid = 1; return {0, true}; } uint32_t mid = s.mid; if (s.mid == UINT32_MAX) s.mid = 0; else s.mid++; return {mid, false}; } }; /* ── Lifecycle ── */ Context::Context(std::string_view oid, std::string_view did, std::string_view iid, std::string_view bid) { if (oid.empty() || did.empty() || iid.empty() || bid.empty()) throw std::invalid_argument( "Context: OID, DID, IID, BID must be non-empty"); impl_ = std::make_unique(oid, did, iid, bid); } Context::~Context() = default; std::string_view Context::bid() const { return impl_->bid_str; } void Context::on_message(MessageCb cb) { impl_->msg_cb = std::move(cb); } void Context::on_offer(OfferCb cb) { impl_->offer_cb = std::move(cb); } void Context::on_event(EventCb cb) { impl_->event_cb = std::move(cb); } void Context::on_relay(RelayCb cb) { impl_->relay_cb = std::move(cb); } /* ── Inbound ── */ /* ANTHEOS-CONTEXT-FEED-SWALLOWS-PARSE-ERRORS — feed still returns the bytes it * accepted, and the outcome is now reachable. * * It discarded the ParseState the parser returned and answered `len` * unconditionally, and Context exposed none of the parser's error surface while * owning the parser privately. A caller feeding a corrupt stream saw success * forever with no accessor anywhere on the type that would say otherwise — the * one discarded result in the library. * * The return value is deliberately unchanged: it means "bytes taken", which is * what a caller needs to advance its buffer, and a caller that never asks about * errors behaves exactly as before. Whether the bytes PARSED is a separate * question, so it gets separate accessors rather than an overloaded return. */ size_t Context::feed(const uint8_t* data, size_t len) { if (!data || len == 0) return 0; impl_->parser.feed(data, len); return len; } ParseState Context::parse_state() const { return impl_->parser.state(); } size_t Context::parse_errors() const { return impl_->parser.parse_errors(); } size_t Context::blob_size_errors() const { return impl_->blob_size_errors_; } size_t Context::dropped_ids() const { return impl_->scratch_ids_dropped_; } size_t Context::total_messages() const { return impl_->parser.total_messages(); } std::pair Context::last_tail() const { if (impl_->last_tail_.empty()) return {nullptr, 0}; return {impl_->last_tail_.data(), impl_->last_tail_.size()}; } /* ── Outbound — Bus Scope ── */ std::optional Context::establish() { return bus::establish(impl_->bid_str); } std::optional Context::broadcast(std::string_view text) { return bus::broadcast(text); } std::optional Context::ping(std::string_view bid) { if (bid.empty()) return bus::ping_all(); return bus::ping(bid); } std::optional Context::exception(std::string_view reason) { return bus::exception(reason); } std::optional Context::verify(std::string_view bid) { return bus::verify(bid); } std::optional Context::verify_response() { return bus::verify_response(impl_->oid, impl_->did, impl_->iid); } std::optional Context::discover(std::string_view bid) { return bus::discover(bid); } std::optional Context::acknowledge(std::string_view bid) { return bus::acknowledge(bid); } /* ── Outbound — Service Scope ── */ std::optional Context::query(std::string_view capability) { return service::query(capability); } std::optional Context::offer(std::string_view description) { return service::offer(impl_->bid_str, description); } std::optional Context::accept(std::string_view bid, std::string_view sender_bid) { return service::accept(bid, sender_bid); } /* ── Outbound — Auth Scope (Level 2) ── */ std::optional Context::auth_challenge(std::string_view target_bid, std::string_view nonce_hex) { return bus::auth_challenge(target_bid, nonce_hex); } std::optional Context::auth_response(std::string_view target_bid, std::string_view key_id, std::string_view sig_hex) { return bus::auth_response(target_bid, key_id, sig_hex); } /* ── Outbound — Relay + Auth (Level 2: multi-hop Z-verb) ── */ std::optional Context::relay_auth_challenge(std::string_view target_bid, std::string_view nonce_hex, uint32_t index, std::string_view path) { return bus::relay_auth_challenge(target_bid, nonce_hex, index, path); } std::optional Context::relay_auth_response(std::string_view target_bid, std::string_view key_id, std::string_view sig_hex, uint32_t index, std::string_view path) { return bus::relay_auth_response(target_bid, key_id, sig_hex, index, path); } /* ── Session helpers ── */ static bool slot_valid(int slot) { return slot >= 0 && slot < MAX_SESSIONS; } /* ── Outbound — Session Scope ── */ int Context::session_open() { auto& p = *impl_; int slot = -1; for (int i = 0; i < MAX_SESSIONS; i++) { if (p.sessions[i].state == SessionState::Idle) { slot = i; break; } } if (slot < 0) return -1; auto sid = p.sid_pool.acquire_unique( [&p](std::string_view candidate) { for (int i = 0; i < MAX_SESSIONS; i++) { if (p.sessions[i].state != SessionState::Idle && p.sessions[i].sid == candidate) return true; } return false; }); if (!sid) return -1; p.sessions[slot].sid = std::move(*sid); p.sessions[slot].state = SessionState::Active; p.sessions[slot].mid = 1; return slot; } int Context::session_accept(std::string_view sid, uint32_t inbound_mid) { if (sid.empty()) return -1; auto& p = *impl_; int slot = -1; for (int i = 0; i < MAX_SESSIONS; i++) { if (p.sessions[i].state == SessionState::Idle) { slot = i; break; } } if (slot < 0) return -1; p.sessions[slot].sid = std::string(sid); p.sessions[slot].state = SessionState::Active; p.sessions[slot].mid = inbound_mid + 1; return slot; } std::string_view Context::session_sid(int slot) const { if (!slot_valid(slot)) return {}; auto& s = impl_->sessions[slot]; if (s.state == SessionState::Idle) return {}; return s.sid; } uint32_t Context::session_mid(int slot) const { if (!slot_valid(slot)) return 0; auto& s = impl_->sessions[slot]; if (s.state == SessionState::Idle) return 0; return s.mid; } SessionState Context::session_state(int slot) const { if (!slot_valid(slot)) return SessionState::Idle; return impl_->sessions[slot].state; } std::optional Context::session_call(int slot, std::string_view target_bid, std::string_view payload) { if (!slot_valid(slot)) return std::nullopt; auto& s = impl_->sessions[slot]; if (s.state != SessionState::Active) return std::nullopt; auto [mid, wrap] = impl_->advance_mid(s); if (wrap) return session::call_wrap(s.sid, target_bid); return session::call(s.sid, target_bid, mid, payload); } std::optional Context::session_call_blob(int slot, std::string_view target_bid, std::string_view payload, const uint8_t* blob, size_t blob_len) { if (!slot_valid(slot)) return std::nullopt; auto& s = impl_->sessions[slot]; if (s.state != SessionState::Active) return std::nullopt; auto [mid, wrap] = impl_->advance_mid(s); if (wrap) return session::call_wrap(s.sid, target_bid); return session::call_blob(s.sid, target_bid, mid, payload, blob, blob_len); } std::optional Context::session_notify(int slot, std::string_view target_bid, std::string_view event) { if (!slot_valid(slot)) return std::nullopt; auto& s = impl_->sessions[slot]; if (s.state != SessionState::Active) return std::nullopt; auto [mid, wrap] = impl_->advance_mid(s); if (wrap) return session::call_wrap(s.sid, target_bid); return session::notify(s.sid, target_bid, mid, event); } std::optional Context::session_status(int slot, std::string_view target_bid) { if (!slot_valid(slot)) return std::nullopt; auto& s = impl_->sessions[slot]; if (s.state != SessionState::Active) return std::nullopt; auto [mid, wrap] = impl_->advance_mid(s); if (wrap) return session::call_wrap(s.sid, target_bid); return session::status(s.sid, target_bid, mid); } std::optional Context::session_close(int slot, std::string_view target_bid) { if (!slot_valid(slot)) return std::nullopt; auto& s = impl_->sessions[slot]; if (s.state == SessionState::Idle) return std::nullopt; auto frame = session::finish(s.sid, target_bid); s.state = SessionState::Idle; s.mid = 0; s.sid.clear(); return frame; } } // namespace antheos