Shared SDK Semantics
Status: Authoritative for v0. The 3 v0 SDKs (Python, TypeScript, Go) MUST implement the surfaces in this doc with semantic parity. Last reviewed: 2026-05-03 (Phase 13.0).
Overview
Beava's JSON wire format is the canonical contract.
Per-language SDKs are thin compilers from idiomatic syntax to the wire — they
own the developer-experience translation, but they do not own semantics. Every
behavior visible to a user (cold-start returns {}, schema mismatch raises,
batch atomicity, window grammar) is observable directly through curl against
the wire spec, and every SDK MUST match.
This document is normative for cross-language behavior. Per-language idioms (naming style, sync vs async, error patterns, decorator vs builder syntax) are documented in the per-language docs:
- Python SDK — canonical authoring UX (
@bv.event,bv.col,bv.count(...)). - TypeScript SDK — npm package
@beava/sdk; camelCase + builder pattern. - Go SDK — module
github.com/beava-io/beava-go; context-aware methods + functional options.
When Python prose and the cross-language semantics in this doc disagree, this doc wins — Python is the canonical implementation but this is the canonical contract.
Wire transports
Three transports map to one URL scheme each. URL-scheme dispatch is part of every SDK's contract — the user passes the URL, and the SDK selects the transport.
| Scheme | Transport | Use case | Spec |
|---|---|---|---|
http://host:port / https://host:port |
HTTP/1.1 + JSON | curl reach, observability, LB / WAF integration | docs/http-api.md |
tcp://host:port |
Custom-framed TCP, [u32 length][u16 op][u8 ct][payload] |
Low-latency fast-path, fraud / ad-tech serving | docs/wire-spec.md |
| (no URL) | Embed mode — spawn local beava binary on ephemeral ports |
Tests, local dev, in-process default | docs/concepts/embed-mode.md (forward-ref Plan 13.0-13) |
Embed mode is the default when the user constructs an App with no URL:
- Python:
bv.App()with no argument. - TypeScript:
new BeavaApp()with no argument. - Go:
beava.NewApp(ctx, "")with an empty URL string.
In embed mode the SDK locates the beava binary (via $BEAVA_BINARY,
$PATH, or a workspace target/debug/beava walk), spawns it on ephemeral
ports, and reads the bound addresses from the binary's stdout JSON log lines
(server.http_bound and server.tcp_bound). The transport then connects to
those addresses just like any other URL-mode call. Lifecycle is owned by the
App instance; closing the App terminates the embedded subprocess.
Window grammar
Windows for streaming-aggregation operators use a single grammar across all languages:
window := digit+ unit | "forever"
unit := "ms" | "s" | "m" | "h" | "d"
Examples (parse-equivalent across all 3 SDKs): 100ms, 30s, 5m, 1h,
24h, 7d, forever.
Validation rules:
- Leading digit MUST be
1-9—"0ms"and"0s"are rejected. - Sub-second resolutions are supported only via
ms(e.g.100ms);0.5sis invalid. - The literal
"forever"is the lifetime mode sentinel — equivalent to omittingwindow=entirely.foreveris REJECTED for decay operators (ewma,ewvar,decayed_sum, etc.) since exponential decay over an unbounded window is mathematically undefined.
All 3 SDKs MUST reject malformed windows at decorator / builder time (client-side, before the wire call). Server-side validation re-checks for defense-in-depth.
Key shape
Entity keys (the key field on OP_GET / OP_BATCH_GET requests) come in
two shapes:
- Single key: a
string. - Composite key: an array of
[string | number | boolean]items, in the same order as the table's declaredkeyfield.
Cross-language hash equality is achieved via stringification at the server
boundary (FxHash on the server-side EntityKey struct). Composite-key arrays
must use homogeneous element types per position — e.g., ["alice", 42, true]
is fine, but the SDK must serialise integers as JSON numbers (not as JSON
strings) to preserve the type discriminator.
The SDK is responsible for translating idiomatic per-language types
(Python int, JS number, Go int64) into the appropriate JSON form on
the wire. v0 specifically:
- Python:
[str | int | float | bool]items map to JSON[string | number | boolean]. - TypeScript:
Array<string | number | boolean>maps directly. JSbigintis REJECTED in v0 (clients should pre-convert tonumberif they fit). - Go:
[]anycontainingstring/int64/float64/boolitems. Other types in the slice raise an error before the wire call.
Global-table sentinel (key = "") — per ADR-003
Per ADR-003, a register payload with key: [] (empty array) declares a global table — single output dict, no per-entity dimension. The wire-level GET sentinel is key: "" (empty string).
| Language | Per-entity GET | Global GET |
|---|---|---|
| Python | app.get("Table", "alice") |
app.get("Table") (1-arg overload) |
| TypeScript | await app.get("Table", "alice") |
await app.get("Table") (overloaded signature) |
| Go | app.Get(ctx, "Table", "alice") |
app.GetGlobal(ctx, "Table") (separate method) |
All three SDKs produce the same wire request when querying a global table ({"table": "...", "key": ""}). Per-language ergonomics differ to match each ecosystem's conventions:
- Python uses arity overloading — natural in dynamic typing.
- TypeScript uses overloaded signatures with type-level enforcement (compile-time arity check).
- Go uses a separate method — Go's static typing makes arity overloading awkward; explicit method names are idiomatic.
The wire shape is identical — only the per-language client surface differs. Global-table state is bounded by the per-table state size (single slot), independent of entity count. See docs/concepts/global-aggregation.md for the full conceptual treatment.
Public expression literal (bv.lit) — per ADR-003
Per ADR-003, all three SDKs expose a public literal factory:
| Language | Signature |
|---|---|
| Python | `bv.lit(value: int |
| TypeScript | `bv.lit(value: number |
| Go | beava.Lit(value any) Expr |
The literal AST node already exists internally in all three SDKs (used by the implicit operator-overloading coercion path); ADR-003 promotes it to the public namespace. Wire-level: literals are serialized via the existing expression-string path; no wire change.
Implementation deferred: Phase 13.5 (Python ~5 LOC) + Phase 13.6 (TS + Go ~30 LOC each).
Field types
The 6-element field-type vocabulary is shared across all languages. Wire representation (the string used in the registered schema) is the wire name column; per-language SDKs use idiomatic types and translate at the boundary.
| Wire name | Python | TypeScript | Go | Notes |
|---|---|---|---|---|
str |
str |
string |
string |
UTF-8 strings; max length per server config. |
f64 |
float |
number |
float64 |
IEEE 754 double precision. |
i64 |
int |
number |
int64 |
TypeScript has no native 64-bit integer; values up to Number.MAX_SAFE_INTEGER (2^53 - 1) are safe. Larger integers MUST be sent as strings (subject to TS-13.6 design — see typescript.md). |
bool |
bool |
boolean |
bool |
|
bytes |
bytes |
Uint8Array |
[]byte |
Base64-encoded on the JSON wire; SDKs decode/encode transparently. |
datetime |
datetime.datetime |
Date |
time.Time |
ISO 8601 (RFC 3339) on the JSON wire; SDKs parse / serialise. |
Optional / nullable fields
Optional[T] semantics (across all 3 SDKs): the field MAY be absent from a
push payload, and the registered schema records it as nullable. The wire
form is the same Python bv.Optional[T] / TypeScript T | null / Go
*T — the SDK marks the field as optional in the
schema.optional_fields list inside the register descriptor.
A required field that is missing from a push payload returns
missing_field per docs/error-codes.md (forward-ref
Plan 13.0-12).
FeatureResult shape
The result of app.get(...) is the row-shape — a flat dict / object /
map of feature name → value, exactly as defined in the wire spec for
OP_GET responses. No wrapper object, no envelope.
Cross-language surface:
- Python:
dict[str, Any]. - TypeScript:
Record<string, any>(or generic<T>with a typed result — see typescript.md). - Go:
map[string]any(or strongly-typed result via codegen — v0.1+).
Cold-start semantics: if no events have ever been pushed for the
queried (table, key) pair, all 3 SDKs return an empty dict / object /
map ({}). This is not an error and not the same as the table
being absent — unknown_table is a separate error code per
docs/wire-spec.md OP_GET errors.
The motivation is the Redis-shaped contract: cold keys are just keys with
no data, not a 404-class condition. SDK ports MUST surface cold-start as
{} and not raise.
ValidationError envelope
The cross-language schema for the validation-error envelope matches
python/beava/_errors.py::ValidationError:
{
"kind": "<one-of-9>",
"path": "<DAG/JSON path>",
"message": "<human-readable, forward-looking framing per Phase 12.7 D-02>"
}
The 9 kind values are frozen for v0; new kinds require an ADR:
| Kind | When |
|---|---|
cycle |
Descriptor list forms a cycle through upstreams. |
missing_upstream |
A derivation references an upstream not declared in this batch and not previously registered. |
schema_mismatch |
A push field has the wrong type and cannot be coerced; or bv.sum field arg is not a string per Q1 Path B. |
bad_return_type |
A function-form @bv.event returns the wrong descriptor shape. |
unknown_field_type |
Field type annotation is not in the supported vocabulary (str / f64 / i64 / bool / bytes / datetime). |
table_key_invalid |
Composite-key shape is malformed at register time. |
registration_conflict |
Destructive change (field type change, field removal) without force=true. |
duplicate_name |
Two descriptors in the same register call have the same name. |
unsupported_node_kind |
Body has kind="upsert"/"delete"/"retract" etc. — pre-12.7 surface that is permanently killed per project_v0_events_only_scope. |
message text follows the forward-looking framing locked in Phase 12.7
D-02: messages say "X is not supported in v0", not "X has been removed"
or "X was deprecated". This avoids implying a previous-version reference
for users who never saw older revisions.
Schema evolution
The force and dry_run register-time flags are cross-language register
knobs (per docs/wire-spec.md OP_REGISTER):
| Flag | Type | Default | Behavior |
|---|---|---|---|
force |
bool | false |
Permits destructive register (e.g., field type change, field removal). The server accepts the change and zeroes affected aggregations. Without force, destructive changes return 409 Conflict with registration_conflict. |
dry_run |
bool | false |
Returns the diff without applying. Response body: {added, removed, changed, diff}. registry_version is NOT bumped. |
Per-language idiom for these flags:
- Python: keyword-only —
app.register(*descs, force=False, dry_run=False). - TypeScript: camelCase options object —
app.register(descs, { force: false, dryRun: false }). - Go: functional options —
app.Register(ctx, descs, beava.WithForce(), beava.WithDryRun()).
The flags compose: force=true + dry_run=true returns the diff for the
destructive change without applying it, useful for migration tooling.
Cross-language naming convention
Spec-level normative rules:
| Layer | Convention | Example |
|---|---|---|
| Wire JSON keys | snake_case |
event_name, registry_version, cold_after_ms |
| Python public API | snake_case (PEP 8) |
app.batch_get(...), bv.n_unique(...) |
| TypeScript public API | camelCase |
app.batchGet(...), bv.nUnique(...) |
| Go public API | PascalCase |
App.BatchGet(...), beava.NUnique(...) |
All 3 languages serialize / deserialize automatically at the transport
layer; users write idiomatic per-language code, and the SDK translates
field names to wire snake_case on the way out and back to the
language's idiomatic shape on the way in.
The wire snake_case discipline is frozen — adding new wire fields
requires they use snake_case to preserve cross-language uniformity.
Error semantics
Cross-language behavior for each error class:
| Class | Cold-start | Behavior |
|---|---|---|
Cold-start ({} for unknown key) |
NOT an error — returns empty row/object/map | All 3 SDKs return empty dict/object/map. |
| Schema mismatch on push | ERROR | Python raises RegistrationError (push variant: ValidationError). TS throws RegistrationError. Go returns error. |
| Unknown event/table on push or get | ERROR with unknown_event / unknown_table |
Same as schema mismatch — language-idiomatic surfacing. |
| Validation error on register | ERROR — RegistrationError carrying ALL errors in .errors (Python: errors: list[ValidationError]; TS: errors: ValidationError[]; Go: Errors []ValidationError). |
Batch atomicity (OP_BATCH_GET): v0 has no partial success. If
any single per-entry request fails (e.g., one bad table), the entire
frame returns OP_ERROR_RESPONSE with the offending entry indexed in
the path field (e.g., requests[2].table). All 3 SDKs surface this
as a single language-idiomatic error — they do NOT return partial
results plus per-entry exceptions. Clients re-issue with the bad
request removed. Partial success is reserved for v0.1+ per
docs/error-codes.md batch_too_large.
Lifetime aggregation rules (cross-language register-time validation)
Per V0-MEM-GOV-02 (Phase 12.8):
Lifetime aggregations (windowless mode —
window=omitted or set to"forever") MUST declare a finite per-entity memory ceiling at register-time.
Server-side enforcement: the JSON-prelude shim returns
code: "unbounded_op_in_lifetime_mode" if a register payload places an
unbounded operator in lifetime mode. The shim is default-on per Phase
12.8 Plan 06; the env-var BEAVA_MEMORY_GOV_ENFORCE=0 disables it
(operators MUST NOT disable in production).
All 3 SDKs SHOULD validate this client-side (not strictly required, but recommended) so the user gets fast feedback. The catalogue of bounded vs unbounded ops in lifetime mode lives at docs/architecture/memory-budget.md (forward-ref Plan 13.0-13). v0 enforces this on the server regardless of SDK behavior.
Cross-language API surface map
For quick reference, here is the canonical surface that every SDK MUST implement:
| Wire opcode | Python | TypeScript | Go |
|---|---|---|---|
OP_REGISTER |
app.register(*descriptors, force=False, dry_run=False) |
app.register(descriptors, { force, dryRun }) |
app.Register(ctx, descriptors, beava.WithForce(), beava.WithDryRun()) |
OP_PUSH |
app.push(event_name, fields) |
app.push(eventName, fields) |
app.Push(ctx, eventName, fields) |
OP_GET |
app.get(table, key) |
app.get(table, key) |
app.Get(ctx, table, key) |
OP_BATCH_GET |
app.batch_get(requests) |
app.batchGet(requests) |
app.BatchGet(ctx, requests) |
OP_RESET |
app.reset() |
app.reset() |
app.Reset(ctx) |
OP_PING |
app.ping() |
app.ping() |
app.Ping(ctx) |
| (close lifecycle) | app.close() / context manager |
app.close() / Symbol.asyncDispose |
app.Close(ctx) / defer |
Each language doc fills in the per-language signature details with full type annotations, error semantics, and lifecycle expectations.
Plan-level traceability
This document is authored by Plan 13.0-04 (Wave 1). Downstream consumers:
- Python SDK, TypeScript SDK, Go SDK — per-language docs in the same plan import the cross-language rules above.
- Phase 13.5 — Python SDK rewrite implements the canonical surface.
- Phase 13.6 — TypeScript + Go SDK ports implement the per-language docs.
- Phase 13.4 — engine validates the wire contract that all 3 SDKs target.
For the full Phase 13.0 plan tree, see
.planning/phases/13.0-design-contract-spec-docs/13.0-PLAN.md.