Skip to content

tacplus Generated API

This document defines the public API surfaces tacplus generates and the behavioral guarantees those surfaces provide. USER.md explains how to declare these surfaces in proto and tacplus.yaml; DEVELOPMENT.md explains how the compiler preserves them.

The rule of thumb is simple: hand-written service code implements business logic at the generated handler seam. Everything else that is common across services or languages is generated and owned by tacplus.

Generated Surface

For a proto package that declares services, state, stores, or client dependencies, tacplus emits the full service surface in the full-service targets (go, rust, java):

Surface Contract
ServiceManager Owns startup, identity, config, state-store connection, TLS, generated clients, server wrappers, health, tracing, and shutdown.
Handler The only business-logic seam. Implementers provide handler methods; generated wrappers call them after auth and validation.
Service wrappers Enforce authentication, authorization, inbound validation, namespace policy, tracing/accounting, handler dispatch, outbound validation, and stable status-code mapping. Auth precedes validation — see the pipeline below.
Client managers Expose typed C2S/W2S/S2S/C2D clients selected by proto annotations and tacplus.yaml transport settings, subject to the target language support matrix.
State clients Expose typed read/write/watch access to proto-declared state roots, with imports restricted to generated read-only accessors.
Typed disk persistence Exposes typed snapshot and redo-log persistence for generated store/state types when enabled by the generated service/store config.
registry.json Exposes the normalized semantic IR: types, services, auth metadata, RPC verb/pattern metadata, state roots, projections, annotations, and generated surface metadata.

No full-service language is the reference implementation. The contract is the annotated proto model plus the generated API behavior described here. Go, Java and Rust are the three server backends and are at parity on the record plane — the (spec.store_namespace) / (spec.store_principal) / (spec.record).private egress row filters (see Egress row filters below) are generated by all three. Python, TypeScript, Swift and C# remain client-only and generate no service wrapper, so a record read surface cannot be hosted in any of them.

Client-only targets do not implement the service runtime contract, and split into two tiers. swift and csharp are "lite" client targets: they emit C2D, C2S, and W2S client surfaces. Swift's W2S covers unary (HTTP/JSON) and server-streaming (SSE) methods; C#'s W2S is unary-only. A method neither tier can carry — client-streaming and bidi, which the gateway serves over WebSocket, plus every streaming method for C# — is emitted as a compile-time-unavailable stub rather than omitted, so a mixed service still compiles and no declared method silently disappears. Unlike the full-client tier, both currently emit no client-side request validation and no default request deadline. python and typescript are full-client targets: they emit every client surface (C2D, C2S, W2S, and REST) with client-side validation. Both tiers reject S2S clients, server wrappers, ServiceManager runtime, EventBus runtime, health runtime, SMB runtime, service state, and service stores loudly, instead of silently producing partial service code.

ServiceManager

The ServiceManager (sm) is the top-level reachable surface for generated runtime infrastructure. Generated helpers and wrappers may exist internally, but application code should reach generated capabilities through sm.

The ServiceManager is responsible for:

  • loading generated config and runtime environment;
  • creating and refreshing identity tokens;
  • obtaining and renewing TLS material;
  • connecting to the state store and declared service dependencies;
  • exposing typed state, store, and service clients;
  • installing generated server wrappers around the handler;
  • publishing health/readiness and trace/accounting events;
  • shutting down owned tasks, streams, and clients.

If a service implementation manually wires raw gRPC servers, auth interceptors, state-watch loops, bearer headers, or generated transport clients that should be available through sm, that is either a caller bug or a missing generated surface.

Certificate Delivery Streams

Generated ServiceManagers own long-lived certificate delivery streams in full-service targets (go, rust, java). The contract applies to both auto-TLS (X509Mint/ManageCert) and SMB member certificate (SmbCertService/ManageSmbCert) streams.

Certificate streams are startup-critical until the first usable credential is installed. After that first credential, they are renewal-critical: failures must be retried without leaving the service permanently stale or pairing an old certificate with a new private key. Handlers only install delivered credentials through the generated handler seam.

Startup follows this state machine:

  1. Resolve the authority endpoint for the current attempt.
  2. Generate a pending private key and CSR.
  3. Open the certificate stream with a cancellable attempt context.
  4. Wait for the first delivery.
  5. Promote the pending key only after the matching certificate delivery is accepted by the generated handler seam.
  6. Report startup success only after the first credential has been installed.

Startup attempts use bounded exponential backoff with a shutdown escape. If the attempt budget is exhausted before first delivery, startup fails loudly. Shutdown or cancellation exits without reporting a false startup success.

Readiness and reconnects (Go, Java, Rust). The gRPC health endpoint that Kubernetes readiness probes read starts NOT_SERVING and flips to SERVING only when bootstrap has completed, the last effective health status is not UNHEALTHY, and every required s2s channel is up: each event-bus subscription the service declares, and each s2s target listed explicitly through grpc_s2s_client (targets discovered through s2s_client_all do not gate). It flips back to NOT_SERVING while any of them is down. Only readiness is gated; the status published to ServiceHealthState is not, since peers resolve each other through it. Every s2s reconnect loop (service resolution, subscriptions, publishers) backs off from 500ms doubling to a 30s cap, from one shared chassis constant per language.

Required failure behavior:

Case Generated behavior
Authority unreachable before first delivery Retry with bounded backoff; exhaust budget as startup error.
Stream open error before first delivery Retry with bounded backoff; exhaust budget as startup error.
Clean stream end before first delivery Startup error for that attempt; retry until the startup budget is exhausted.
Server error before first delivery Startup error for that attempt; retry until the startup budget is exhausted.
First-delivery handler failure Fatal startup error; do not retry because local installation failed.
Shutdown before first delivery Exit promptly and do not mark startup successful.
Clean stream end after first delivery Reconnect while running, with backoff and endpoint re-resolution inside the loop.
Server error after first delivery Reconnect while running, with backoff and endpoint re-resolution inside the loop.
Renewal handler failure Log and retry; keep the last active credential.
Renewal CSR/key generation failure Log and retry; keep the last active credential.

Every renewal attempt uses a pending key distinct from the active key. The active key must not be replaced at CSR generation time. Promotion is atomic: generate pending key, send the CSR built from it, receive the matching certificate chain, invoke the generated handler seam with the pending key and delivered certificate, and promote pending to active only if the handler returns success. On stream error, clean stream end, handler failure, cancellation, or shutdown, the pending key is discarded and the service keeps using the last active credential until a future renewal succeeds.

Every stream attempt must have a cancellable context or equivalent cancellation handle. On every exit path, including clean stream end, server error, startup failure, handler failure, timeout, and shutdown, generated code cancels or half-closes the stream or call before retrying or returning. Reconnect loops re-resolve the authority endpoint on every attempt; a certificate stream must not pin a dead channel, stub, observer, or connection forever after the authority endpoint changes.

SMB member certificate delivery follows the same state machine and staging rules. The delivered per-SMB CA certificate is the trust anchor for the black-box member mesh. The true root CA and any CA private key must not enter generated black-box runtime configuration. The generated SMB handler seam receives the delivered member certificate chain, the pending member private key, and the delivered per-SMB CA certificate; the pending member key is promoted only after that handler seam succeeds.

The behavioral fixture for this contract drives both auto-TLS and SMB loops through first delivery success, renewal success, clean end before and after first delivery, server error before and after first delivery, authority unreachable, first-delivery handler failure, renewal handler failure, and shutdown during startup and renewal attempts. Template-level regression tests pin the state-machine markers where a full runtime fixture is not practical.

The fixture does NOT currently exercise endpoint re-resolution after reconnect or CSR/key generation failure injection. Reconnect behavior is tested (the loop does reconnect after post-delivery errors), but re-resolution to a changed endpoint is not pinned. The contract requires it (see the required failure table above), and template code implements it, but no behavioral test verifies it.

Handler And Wrappers

Handlers are the only generated seam intended for handwritten business logic. The generated wrapper owns the request pipeline:

  1. Decode the proto message.
  2. Authenticate and authorize when auth is enabled. This precedes validation so an unauthenticated caller cannot drive the validation path or distinguish outcomes by the validation errors it returns.
  3. Validate inbound request constraints before handler dispatch.
  4. Enforce subject/object namespace rules when the proto declares namespace fields: a subject may act only at or below its namespace level.
  5. Call the handler.
  6. Validate each outbound response or stream item before sending it.
  7. Map failures to stable generated error/status codes.
  8. Emit trace/accounting/health hooks where the generated service surface requires them.

Outbound validation applies to user-defined response messages on every generated full-service send path, including gRPC unary/streaming and W2S REST/SSE translation. google.protobuf.* well-known responses such as Empty carry no tacplus assert_* constraints, so generated wrappers pass them through without a validation call.

W2S REST/SSE egress validation scope: the generated W2S server dispatch (Rust rest_dispatch.rs.tmpl, Go rest_dispatch.go.tmpl, Java RestDispatch.java.tmpl) validates both unary responses and SSE stream items before sending. Python and TypeScript are client-only targets and do not host a W2S server.

W2S REST/SSE ingress validation scope: the generated W2S server dispatch (Rust rest_dispatch.rs.tmpl, Go rest_dispatch.go.tmpl, Java RestDispatch.java.tmpl) validates the decoded request on both the unary and the SSE leg before forwarding it to the backend gRPC service, rejecting a failure as 400 with request validation failed. As with egress, requests whose input type is google.protobuf.* (including Empty) carry no tacplus assert_* constraints and are forwarded without a validation call. Python and TypeScript are client-only targets and do not host a W2S server.

All three languages plan the same route table and dispatch arms from one shared planner (pkg/internal/common/restdispatch): which routes exist, what each dials, whom it entitles callers against, and which methods can be dispatched at all are decided once, and each language renders only its own client, JSON and validator names. A (spec.rest_browser) redirect_field is carried in every language's route table as its proto3 JSON name (redirectUrl); because the Go and Java JSON helpers emit the proto field name (redirect_url), their route entries also carry that spelling and the redirect lookup accepts either. A redirect URL that is not a valid HTTP header value (control or non-ASCII bytes) is answered 502 in all three languages, never written into Location.

The planner refuses, at generation time, a dispatchable route whose request or response is a google.protobuf.* well-known type other than Empty, and a server-streaming route whose response is Empty: the dispatchers encode and decode with the generated per-message helpers, which do not exist for the protobuf runtime's types, and there is nothing to stream for Empty. Wrap such a payload in a message of your own.

Auth-disabled methods skip token lookup and authorization but still validate messages. Auth-enabled methods must not reach the handler without a valid token and a satisfied authorization decision.

Transport Surfaces

Service shape is declared in proto. tacplus.yaml only chooses which transport surfaces to emit for the current package and language.

Surface Meaning Transport
C2S client to service native gRPC
W2S web to service gRPC-over-HTTP/REST surface suitable for browser callers
S2S service to service native gRPC with service identity tokens
C2D client to daemon local IPC gRPC, such as Unix domain sockets or named pipes

C2S and W2S are not mutually exclusive for full-service targets. A native/server package may enable either or both. Browser web apps should enable W2S and should not enable C2S.

C2D is a local daemon IPC surface; it does not introduce SMN/SMB mesh work. The lite client targets (swift, csharp) emit C2D, C2S, and W2S clients (Swift's W2S is unary + server-streaming over SSE, C#'s is unary-only; the streaming methods neither can carry are emitted as unavailable stubs); the full-client targets (python, typescript) additionally emit REST clients.

C2D server auth-skip scope: Rust, Go and Java host a generated C2D server for the package's own service_c2d services, mounted by the generated ServiceManager on the declared socket_path (Unix domain socket; required, no default): C2dServer in service_manager.rs.tmpl, grpcserver.C2DServer (newC2DServer in the Go ServiceManager) and grpcserver.C2DServer (ServiceManager.newC2DServer in Java). The services are hosted on that socket only, never on the TLS gRPC listener; a service with no other gRPC surface starts no TLS server at all. Every hosted service_c2d service must declare the same socket_path, since one ServiceManager runs one c2d server. The server removes a stale socket file, binds, and sets the socket file to owner-only (0600); if it cannot, it refuses to serve. The wrapper skips token authentication by design: the auth boundary is the socket permission (filesystem ACL), not a bearer token. It still validates request/response constraints and runs the full handler pipeline; only the token-lookup and authorization steps are bypassed, via the IsC2D flag on the service descriptor, and the ServiceManager wires no authorizer into a c2d wrapper. A Go or Java c2d server that fails to start ends Run/run with the error; the Rust ServiceManager logs it. Python and TypeScript are client-only targets.

C2D standalone host: a local daemon has no cluster, so it cannot call New/create/new() (config load, identity manager, state store) or Run. For a build whose every hosted service is service_c2d and that declares nothing cluster-bound (no state, S2S client, subscription, record reader, disk store or requires_device gate), the generated ServiceManager package also emits a standalone host entry: Go sm.ServeC2DStandalone(ctx, socketPath, h), Java ServiceManager.serveC2DStandalone(socketPath, handler, stop), Rust ServiceManager::serve_c2d_standalone(socket_path, handler, shutdown). It builds the generated wrappers from the generated Handler and serves them through the same c2d mount Run uses, so the validation, the auth skip and the 0600 fail-closed socket are identical; it loads no config, mints no identity, opens no state store and starts no TLS server. It serves until the context is cancelled (Go), the stop future completes (Java) or the shutdown future resolves (Rust), drains in-flight RPCs, removes the socket and then calls the handler's shutdown (bounded at 30 s in Go and Rust); a socket that cannot be bound or locked down is returned (thrown in Java) without calling it. A daemon passes the generated constant for the declared path (Go sm.C2DSocketPath, Java ServiceManager.C2D_SOCKET_PATH, Rust s2sm::C2D_SOCKET_PATH, which Run also serves on and clients dial); a test passes its own path so it never takes over the socket of a daemon running on the same host. The decision is one shared rule (service.HostsC2DStandalone); every other shape keeps New + Run only, including a c2d-only daemon that also reads state or calls a peer, because that daemon needs the cluster Run brings up.

Client Send Contract

Generated clients validate before sending. Auth scope depends on the call shape:

  • Unary / server-streaming: a fresh token is obtained per CALL; if the identity manager cannot provide one, the call fails before leaving the process.
  • Client-streaming / bidi: auth is STREAM-OPEN scoped — exactly one token is attached to the stream headers when the stream opens (gRPC metadata cannot change mid-stream). There is deliberately no per-send token preflight: it would mint a token the server never sees while still being able to fail a legitimate send. A server that rejects an expired stream credential terminates the stream; the generated reconnect paths (e.g. EventBus subscribers) obtain a fresh token per reconnect attempt.

Auth-disabled calls bypass token lookup.

Generated mutation helpers do not retry by default unless the generated API explicitly marks the operation as safe. Read-like helpers may use generated retry/backoff policy.

State And Store Access

A message annotated as service state becomes a typed state root. The generated surface includes:

  • a state service with typed Get/Set/Watch/Delete-style RPCs for the declared boundaries;
  • typed state clients surfaced from the ServiceManager;
  • generated import accessors for read-only cross-service state imports;
  • server-side enforcement that imports cannot write through raw RPCs;
  • view services for declared read-only unions across compatible roots;
  • topic, queue, command, persistence, and eviction behavior declared by proto annotations.

Services own their state. Other services access exported state through declared imports and generated read-only accessors, not by manually dialing backing state services.

No state store implements (spec.persistence) today. Kernel does not: kernel is in-memory coordination plus its own identity snapshot, and holds no durable user data. Setting the annotation on a state root whose (spec.service_state).label includes kernel is a generation error — durable data belongs in the record store records. On any other state root the annotation is accepted and carried descriptively into registry.json (BoundaryInfo.persistence) for a future store that implements it; it produces no durability behavior on its own. Generated state clients carry no local durability either — no state client in any language writes a boundary to local disk.

Typed Disk Persistence

This section is about (spec.service_store) typed disk persistence and grpcconfig.disk_persistence; it is unrelated to (spec.persistence), which is a descriptive request for state-boundary durability that no store implements today.

Typed disk persistence is the generated disk analogue of typed HTTP clients: code reads and writes proto types with generated serialization and validation, not raw JSON or ad hoc file I/O.

The generated contract is:

  • JSON plus newline framing only; no alternate on-disk format;
  • write paths use generated ToJSON helpers;
  • read paths use generated FromJSON helpers and then Validate;
  • invalid data on read fails closed;
  • single-writer, single-process, local-filesystem only;
  • same-directory atomic rename and file/parent-directory fsync for replacement writes;
  • archive rotation for replaced or compacted files;
  • redo logs append newline-delimited records and compact by snapshotting the latest keyed state;
  • restore handles active, temporary, and in-progress promoted files without silently dropping a committed baseline.

The ServiceManager owns scheduled redo-log lifecycle when the init-time grpcconfig.Config.disk_persistence setting is present. Generated ServiceManagers open one redo-log handle per hosted keyed service_store, expose those handles to service code, and run deterministic per-store roll schedulers. This setting is not hot-reloaded; service code should append through the ServiceManager-owned handles and should not run its own backup loop for generated stores.

Archive to an object store (Java)

Set grpcconfig.disk_persistence.archive to ship each writer's redo log to an object store and restore from it after losing the local volume. Java only; Go, Rust, Python and TypeScript ignore the field (see roadmap/LANGUAGE_PARITY.md).

  • Segments. Each roll queues the rolled redo log as a segment. The local file is removed only after the archive holds it, so a failing upload never loses a segment. Uploads run FIFO on a daemon thread, retry with backoff (at most five minutes), and never block an append.
  • Checkpoints. Every archive.checkpoint_interval_seconds (default 86400) the full store state is shipped as a checkpoint, recorded under the redo position it covers: the newest segment rolled at that moment. The position and the snapshot are read under the same lock as a roll, so no roll can split them.
  • Layout. Objects live under <prefix>/<member>/<store>/segments/<seq> and <prefix>/<member>/<store>/checkpoints/<seq>, with seq zero-padded to twenty digits so names sort in roll order. member is the writer's instance id, so several writers ship independently.
  • Restore. ArchiveRestore.restore(sink, prefix, member, store, inserter) loads the newest checkpoint, then replays every segment shipped after its position, in order, into an empty store. Records are deduplicated across the chain, last write wins, so a delete after the checkpoint arrives as a tombstone for the consumer's Inserter to honour. A missing segment is an error, never a partial restore. Restore must name the writer's original member.
  • Recovery point. A write is in the archive only once its roll has shipped. Writes made since the last roll are lost with the volume, so the worst case is one roll_interval_seconds (default 86400). Shorten the roll interval to narrow it.
  • Credentials. The archive sink is Google Cloud Storage, authenticated as the workload's ambient identity (Application Default Credentials). No key is read from config. The sink is compiled in only when the project pins com.google.cloud:google-cloud-storage in dependencies.yaml; a service that sets archive in a build without that pin fails at startup.
  • Observability. A failed upload logs a WARNING with the object name and retry delay. RedoLog.shipPending() (objects not yet shipped, the lag) and RedoLog.shipFailures() (failed attempts) report the same numbers to code.

Append-only history and plain file reads (Java)

A redo log is keyed and deduplicated on read, so it is the wrong primitive for a full history such as a daily tombstone file. Java also exposes the primitives for files that are neither snapshots nor redo logs. Go, Rust, Python and TypeScript have neither (see roadmap/LANGUAGE_PARITY.md).

  • AppendLog.append(base, relPath, line, mode) writes line verbatim followed by one \n, creating relPath's parent directories and the file itself. It fsyncs before returning. relPath is the caller's path: there is no store namespace segment and no forced .log suffix. mode is the Unix permission applied on create (0 leaves the default). A line containing \n is rejected as INVALID_ARGUMENT.
  • AppendLog.readLines(base, relPath) returns every complete line in order, each without its terminator, with no dedup. An absent file reads as no lines. A trailing fragment with no terminator (a crash mid-append) is not a line and is dropped; the next append removes it before writing.
  • PlainFile.readAll, PlainFile.exists, PlainFile.list read a whole file, test for its presence, and list a directory's entries, sorted. A missing file in readAll is NOT_FOUND; a missing directory in list is empty.
  • All paths are relative to base. An absolute path, or one that resolves outside base, is rejected as INVALID_ARGUMENT.
  • Errors are DiskStoreException: io for I/O failures.

Collection RPCs

Collection-returning RPCs are explicit. A multi-result read declares a method_collection mode and bound; tacplus derives transport tier, flow control, and public handler/client shapes from that declaration.

A unary response whose only meaningful field is one repeated result field is the Tier-1 collection convention and must be annotated. Tacplus rejects that shape when method_collection is absent because there is no safe way to infer bounds, transport tier, or public handler/client shape from proto alone. Composite responses that happen to contain repeated nested fields are not treated as collection reads unless annotated.

Modes

Mode Contract
SNAPSHOT Finite point-in-time result. Requires a positive max_items; terminates.
WATCH Open-ended change stream. Requires an explicit from_revision; does not self-terminate.
PAGE Unary, client-driven window over an ordered set. Requires order_by and a positive default_page_size; the client resumes with an opaque page_token.

An unbounded non-async read is invalid. If a read is unbounded, it is a WATCH.

Snapshot Tiers

Tacplus computes max_items * item_bytes and selects a delivery tier:

Tier Wire shape Handler surface Client surface
Tier 1 unary response with exactly one repeated field materialized collection materialized collection
Tier 2 server-streaming items materialized collection materialized collection
Tier 3 server-streaming items lazy producer/iterator lazy iterator/stream

Tier-2 and Tier-3 proto methods are ordinary gRPC server-streaming methods. The streamed message is the item. Tier-1 proto methods are ordinary unary methods whose response contains exactly one repeated field.

WATCH Contract

A WATCH proto method is an ordinary server-streaming RPC whose response is an author-declared envelope:

  • snapshot_item entries establish the initial state;
  • snapshot_done carries the revision at which that snapshot is complete;
  • delta entries carry later changes;
  • stale from_revision returns OUT_OF_RANGE, prompting the client to relist.

The handler returns a language-appropriate WatchSource shape containing scope, snapshot producer, revision, and change producer. The generated wrapper owns envelope framing, replay-ring lookup, relist behavior, teardown, and flow control. The replay ring lives in a shared per-language collection runtime lib, keyed by scope and bounded by replay_depth.

The ring is ordered by one int64 field on the delta, named by (spec.method_collection).revision_field and defaulting to revision. The default suits a purpose-built change envelope (<T>WatchChange { item; revision }); a delta that IS the row — an append-only record has no update or delete frame to distinguish — names the sequence the row already carries instead, rather than growing a second ordering field that must agree with the first forever. The named field must exist and be a singular int64: generation fails otherwise, including for the default, rather than emitting a wrapper that only fails later in cargo/javac/go build.

The wrapper also owns the egress row filters on this seam, exactly as on the unary and PAGE seams (Go, Rust and Java): every snapshot_item and every delta is checked before it is sent — a row whose (spec.store_principal) field is not the caller's is skipped, and a row whose (spec.store_namespace) field is not the caller's tenant trips the abort backstop. snapshot_done carries no row and is always sent. The delta may be the row itself or an envelope with one level of singular message fields around it; a pinned row inside a repeated, map or oneof field of the delta fails generation, as does a pinned row on any non-WATCH streaming shape (Tier-2/Tier-3 SNAPSHOT, plain server streams), which have no per-item hook.

Go, Java and Rust are at parity on the record plane. All three render the egress filter above on every record-bearing seam — the unary response, the PAGE window, WATCH's per-item hook, and the query predicate — so any of the three may generate a service wrapper for a store_namespace / store_principal / (spec.record).private-annotated response, and all three spell the same abort, the same linked-claim widening and the same operator-facing diagnostics. The three generators are held to one behaviour by pkg/internal/common/record_egress_parity_test.go, which fails the build if one of them drops a rule the other two keep, and the filters are generated and compiled side by side in all three by the proto-recordegress fixture leg of cross-test/current-generation.

Two refusals remain, and both are deliberate rather than gaps:

  • A pinned row on a streaming shape with no per-item hook — a Tier-2/Tier-3 SNAPSHOT or a plain server stream, and a WATCH delta carrying pinned rows in a repeated, map or oneof field. There is nowhere to put the check, so generation refuses instead of emitting a wrapper that under-enforces. Go and Rust refuse exactly this set; it is not a language difference.
  • A private-record read with no caller principal to pin against — a (spec.record).private read on a surface no user token reaches, or over a private row whose owner no annotation names. The owner pin has nothing to compare rows against, so the read cannot be hosted in Go, Java or Rust. This one is language-independent by construction, not a property of any generator.

PAGE Contract

A PAGE proto method is an ordinary unary RPC. The request carries the filter fields plus int32 page_size and string page_token; the response carries exactly one repeated items field, string next_page_token, and an optional int64 total_count. Generation rejects a request that declares page_token / page_size (or a response with next_page_token) but lacks COLLECTION_MODE_PAGE — otherwise it would silently degrade to an unbounded unary with no page ceiling.

The generated wrapper owns the page mechanics; the handler is a keyset producer:

  • page_token decodes to a keyset cursor (the order_by key of the last row). A malformed token is INVALID_ARGUMENT — never a silent reset to the first page (which would skip rows and read as success).
  • The effective page size is page_size when positive, else default_page_size, clamped to the trust tier's MaxPageSize (Ceilings.MaxItems: 100 anon / 10k C2S-auth / 1M S2S). This is the per-call row ceiling that SNAPSHOT's max_items provides statically and that streaming reads lack.
  • The handler receives PageParams{limit, after} plus the request and returns a Page{items, nextAfter, total} (total = -1 means "unknown", not "empty"). The wrapper encodes nextAfter into next_page_token (empty on the last page) and sets total_count only when the handler computed it.

The page_token is opaque, versioned, and byte-identical across all full-service backends — base64url(no pad) of {"v":1,"k":[...]} over the order_by key — so a token minted against one backend decodes on any other. Pages are best-effort keyset over order_by (stable when the sort key is monotonic/immutable); they are not a point-in-time snapshot unless the handler bakes a revision into the key.

Each language exposes two client seams: an explicit single-page call ({Method}Page → items + next_page_token + total) and an auto-pager (the natural method name) that follows next_page_token until exhausted — iter.Seq2 (Go), an async generator (TypeScript), an async generator (Python), a futures::Stream (Rust), and a lazy Iterator (Java). The auto-pager advances the token on a request clone, so the caller's request is not mutated.

Flow Control And Teardown

Generated stream wrappers must respect transport backpressure and deterministic cleanup. Java uses ServerCallStreamObserver readiness hooks. TypeScript uses write() backpressure and drain. Go, Rust, and Python rely on their native stream/cancellation semantics.

Idle-timeout backstop — scope. The generated idle-timeout backstop applies to long-lived WATCH streams: the replay Ring waits for the next delta and, if none arrives within the caller's trust-tier STREAM_IDLE_TIMEOUT, ends the stream with DEADLINE_EXCEEDED — a living query must not pin a connection forever while idle. It does not apply to finite Tier-3 SNAPSHOT cursors: a cursor is a bounded pull over a materialized result set, so its teardown is driven by exhaustion, client cancellation, or the request context deadline, not a server-side idle timer. (The cursor producer is a synchronous pull — an iter.Seq2 / equivalent — that cannot be preempted mid-Next without leaking the producer goroutine, so a per-item idle timer is deliberately not generated; a handler whose producer can wedge must honor the context itself.)

EventBus Topics

EventBus participation in generated service runtimes is declared, never inferred. A topic is a message carrying (spec.service_event) (with label naming the broker service) plus a field carrying (spec.topic) = true. Producers and consumers declare (spec.event_publish) / (spec.event_watch) with a topic_ref in the canonical format, parsed from the right so dotted packages work:

<proto-package>.<topic-message>.<topic-field>
e.g. telemetry.AgenticCodeEventBus.event_stream

Full-service generation validates every topic_ref before emitting code: it must match the grammar, resolve to exactly one package/message/field, point at a (spec.service_event) message and a (spec.topic) field, and preserve the field's protobuf type as the event type. Ambiguity or absence fails generation.

Typed EventBus Subscribe responses use a metadata-bearing envelope, not a bare event payload. Field 1 is the record metadata and field 2 is the typed event payload. The metadata wire fields are stable across languages: key = 1, topic = 2, partition = 3, offset = 4, and timestamp = 5.

EventBus publish/watch RPCs are derived RPCs, and auth uses the standard method auth model, not an EventBus-specific vocabulary: the event_publish/event_watch annotations carry the normal method_s2s / method_c2s shapes, and generation copies that policy onto the derived RPC's metadata exactly as if hand-authored. Topic declarations are collected from all loaded descriptors, but publish/watch bindings attach only to the current actor's own protos — importing a proto never makes a service (or the broker itself) a producer or subscriber.

Consumer-scoped routing envelope. s2s_subscribe carries, alongside auth_services, an additive routing_only_services list. It is declared by the topic owner on its own (spec.record)/(spec.event) — never by the subscriber, which cannot be trusted to choose its own blast radius — and names the subset of the already-authorized auth_services whose generated subscriber Handler is typed for a synthesized <T>RoutingEnvelope ({delivery_id, recipient, namespace}) instead of the full <T>. The three slots are projected from the source type's own annotations — (spec.record).key, (spec.store_principal), (spec.store_namespace) — and a flagged type that cannot fill all three, or whose slots are not singular strings, fails generation rather than emitting a half-empty envelope. Listing a service grants it nothing; it must still appear in auth_services. Omitting the field changes nothing: every subscriber not named keeps the full type, unchanged.

This is a type boundary, not a memory one. The full message still arrives on the wire and is still decoded in the subscriber's process; the generated dispatch projects the three fields out of it and drops the decoded message. Narrowing the wire itself is a broker-side change and is not what this annotation does.

The registry carries the same boundary by annotation rather than by field spelling: FieldInfo.store_principal, FieldInfo.store_namespace and FieldInfo.record_key mark which field plays which role, so a registry consumer never has to match on a field being literally named recipient or namespace.

RPC Patterns And Verbs

Every RPC resolves to exactly one pattern from a closed set, with one static access kind: GET (READ) · LIST (READ; SNAPSHOT or PAGE) · WATCH (READ) · CREATE/UPDATE/DELETE (WRITE) · ACTION · CLIENT_STREAM · BIDI (declared access, default WRITE). One method = one verb = one access kind; branching a method's meaning on input is an architectural-norm violation (see DEVELOPMENT.md).

RPC names are Verb<Noun> (PascalCase, verb first) against a closed verb registry. A verb exists only if the operation cannot be expressed as Get/List/Watch/Create/Update/Delete without losing meaning (the fold test); synonyms fold to canonicals and are rejected:

  • Core (6): Get, List (replaces Search/Query/Find), Watch, Create, Update, Delete.
  • Action (16): Mint, Revoke, Authorize, Send, Publish, Subscribe, Broker, Execute, Cancel, Acquire, Release, Retry, Reset, Ping, Proxy, Merge. Subscribe and Broker are canonical action verbs (not folded to Watch); the messaging verbs are Send/Publish/Subscribe/Broker. Stateful vs stateless rule: Create/Delete for stored resources; Mint/Revoke for self-contained credentials (a JWT is MintToken, never CreateSession). Protocol-mandated URL paths (OAuth/OIDC) live on rest_path; the name conforms to the registry.
  • AI/Agent (7): Generate, Embed, Classify, Rank, Assist, Guard, Evaluate — all under the AGENT entitlement scope. Fold rules: Summarize/Translate/ Rewrite/Redact→Generate; Moderate/Detect→Classify; Rerank→Rank; Plan/Critique→Assist; semantic Search→List.

The registry is governed: core/action verbs admit rarely under strict review; agent verbs have a lighter bar but the registry stays closed. The authored compiler dictionary is pkg/internal/common/verbs/registry.json; generated service semantic output lands in the build artifact registry.json RpcInfo (see Tracing And Registry). EnforceVerbs is part of descriptor parsing for generated-auth surfaces: non-canonical verbs, folded synonyms, noun-first names, and verb/pattern/cardinality mismatches fail generation before registry, entitlement, or REST metadata can be emitted. Full design genealogy: the archived RPC_PATTERNS_AND_VERBS_PLAN (not in this repository).

Auth And Namespace Policy

Auth policy is declared in proto service and method annotations. Generated code performs token verification, service/user authorization, entitlement checks, and namespace enforcement before handler dispatch.

Subject namespace and object namespace are distinct. When both are declared, the subject namespace must be at or above the object namespace. Generated code must deny namespace-mismatched calls before the handler. Channels with auth disabled do not perform token lookup but still validate message constraints.

SPIFFE Identity

All SPIFFE identity construction, parsing, and comparison goes through the generated sys/spiffe — no generated code or downstream service formats or splits spiffe://... strings by hand.

Trust domain. The URI authority: a single globally unique instance name containing no / (e.g. example.com). Configured per deployment (spiffe_trust_domain); a missing or malformed value fails loudly — there is no default.

URI layout (typed, self-describing — the kind is recoverable from fixed tags, no side channel needed):

spiffe://<trust_domain>/
  system/<location...>/services/<service>[/workloads/<child>...]
  tenants/<tenant>/users/<user>
  tenants/<tenant>/workspaces/<workspace>...
  tenants/<tenant>/...[/clients/<client>]
  tenants/<tenant>/...[/credentials/<kind>/<id>]
  tenants/<tenant>/...[/data/<kind>/<id>]

/system holds infrastructure/service identities; /tenants holds tenant-scoped principals and resources. The <location...> tiers between /system/ and services are opaque and variable-arity (the parser keys off the services tag). Reserved tags (system, tenants, services, workloads, users, workspaces, clients, credentials, data) are rejected as namespace segments at every depth.

Segment grammar: [A-Za-z0-9][A-Za-z0-9._-]*. Empty segments, separators, traversal, query/fragment, control characters, and encoded separators are rejected. Reject by default; never sanitize-and-accept. Richer external identifiers go through opaque_segment(kind, value) (SHA-256 over kind‖0x1F‖NFC(value), truncated to 160 bits, lowercase base32 no-pad) — byte-identical across supported target surfaces.

Service identity and matching. The canonical service SPIFFE ID — spiffe://<td>/system[/<loc>...]/services/<svc> — is what the cert minter emits as the X509 URI SAN and what the generated go/rust/java peer verifiers require (parsed via sys/spiffe; SubjectKind == Service, trust domain equal, service label equal). S2S auth_services checks match on the location-agnostic service_path() (the bare service label, location tiers stripped), so the same service matches in any cloud/account/az/cluster. A "*" pattern admits service subjects only; a workload subject matches only a pattern that names /workloads/ (see PIGEON_SERVICE_PATH in docs/USER.md).

Token claims. The SPIFFE ID is derived from claims, never carried as a claim: namespace is the authorization-tree path; sub is the stable principal id (not a URI); sub_type ∈ {service, workload, user, client, external}; azp is the attestation context (kernel/oauth/cloud-auth) — attestation is never encoded in the subject path, so the same human in the same tenant role has the same subject regardless of login method. /tenants/<t>/users/<u> is a tenant-scoped role, not the global human; the person is correlated only via the stable IDP sub. External tokens are rejected by generated internal auth.

Full design rationale and decision record: the archived SPIFFE_IDENTITY_NORMALIZATION_PLAN (not in this repository).

Validation And Errors

Every generated language enforces the same field constraints, default collection bounds, oneof scalar string/bytes bounds, maximum message nesting depth, enum vocabulary, and message validation semantics. Generated validators reject nested message graphs deeper than 24 levels and reject enum numeric values not declared in the proto. A declared zero/sentinel enum value is allowed unless assert_enum_defined is present. Generated code should fail closed for invalid proto shapes, invalid runtime config, invalid tokens, unsafe transport setup, and failed validation.

Generated types and wrappers should expose tacplus structured errors/status codes rather than language-specific exception behavior at public seams.

Tracing And Registry

Generated service wrappers emit typed trace/accounting data where the surface requires it. Request handlers are responsible for propagating non-empty trace_id fields into downstream requests they create.

Durable accounting (an accountingrecord.AccountingRecord event-bus topic) never holds an RPC in Go, Java, Rust, Python or TypeScript. The sink hands each record to a bounded in-process spool of 4096 records and returns; the publisher replays the spool, in order, as the event bus drains it. A record is dropped only when the spool is full or (Go, Java, Rust) the service is shutting down, and each drop is logged at ERROR with a running total (issue #34).

registry.json is the machine-readable semantic IR. Service-local generators must read registry.json, not parse proto files directly, when they need tacplus semantics such as projections, store metadata, auth metadata, or generated surface information.

For generated-auth RPCs, RpcInfo includes both transport/auth metadata and governed classification metadata. A C2S generated-auth method that cannot classify fails generation (the external surface is strict). An S2S or C2D generated-auth method that cannot classify does NOT fail generation: its RpcInfo is emitted with verb_status = "unclassified" (or "exempt" when the method carries no auth model at all), the folded verb recorded in folded_to_verb, and the governed classification fields (rpc_verb, rpc_noun, method_pattern, access_kind, …) left empty. Internal surfaces are looser because store/infra RPCs legitimately fall outside the C2S verb dictionary; the recorded verb_status is the signal, not a hard failure. When classification succeeds:

  • rpc_verb and rpc_noun from the canonical Verb<Noun> split.
  • method_pattern: GET, LIST, WATCH, CREATE, UPDATE, DELETE, ACTION, CLIENT_STREAM, or BIDI.
  • access_kind: READ or WRITE.
  • verb_class: core, action, or agent.
  • entitlement_scopes: grant scopes that admit the method, ordered broad to narrow.
  • retry_policy: READ_ONLY, WATCH_RECONNECT, IDEMPOTENCY_REQUIRED, or NO_GENERATED_AUTO_RETRY.
  • capability_scope: primary package capability bucket for capability-any expansion (READ, WRITE, WATCH, PAGE, or AGENT).
  • verb_definition, verb_boundary, and audit_meaning: governed dictionary metadata for documentation, review, and operator/security surfaces.
  • rejected_verbs: known non-canonical verbs that fold to rpc_verb and are rejected by generation.
  • allowed_patterns: method patterns the canonical verb may resolve to.
  • handler_shape: UNARY, SERVER_STREAM, CLIENT_STREAM, or BIDI.

The root entitlement_grants array is the generated grant catalog for C2S authorization tooling. It includes entitlement-checked C2S methods, not auth_disabled or auth_any_user methods. Exact grants identify one package/service/RPC token. Wildcard grants such as PACKAGE_ANY, PACKAGE_READ_ANY, PACKAGE_WATCH_ANY, and PACKAGE_PAGE_ANY are convenience values whose expands_to list contains the exact grants they imply. Runtime authorizers should authorize against exact grants after expansion; wildcard values are not runtime magic.

Empty classification fields are invalid for C2S generated-auth surfaces (they hard-fail generation). On S2S/C2D surfaces empty fields are permitted and accompanied by a non-classified verb_status, as described above. Consumers should read the generated fields (and verb_status) rather than inventing a second classifier.

Record Dispatch To Timeseries Projections

A subscription to a source that has (spec.timeseries_type) projections (a (spec.transformation) of a record, or a message naming it in timeseries_type.source) runs a generated record dispatch on every delivered event, after the subscription's own handler: each projection the event yields is extracted and handed, typed, to that projection's insert callback, which the service author registers on the ServiceManager before run. A source with the injected trace-round noop set writes no row; a projection whose callback is unset is skipped; a callback's failure fails that delivery. One callback per projection, shared by every subscription to its source.

Language Registration Callback Dispatch
Go sm.Set<P>InsertFunc(fn) func(ctx context.Context, row *<P>) error, synchronous Handle<Source>(ctx, event, insert<P>...)
Java sm.set<P>Insert(fn) <Source>Projections.<P>Insert: void insert(<P> row) throws Exception, synchronous <Source>Projections.handle<Source>(event, insert<P>...)
Rust sm.set_<p>_insert(\|row\| async move { .. }) on the owned manager <P>Insert: Fn(<P>Row) -> Pin<Box<dyn Future<Output = Result<(), Box<dyn Error + Send + Sync>>> + Send>>, asynchronous handle_<source>(&event, insert_<p>...).await

<P> is the projection message. Go and Java pass the extracted projection message itself; their column list and ordered row stay available as <P>TimeseriesColumns / <P>ToTimeseriesRow (Go) and Timeseries<P>.columns / toRow (Java). Rust passes the generated flattened <P>Row, the struct its timeseries helpers already define for batch insertion.

The shapes differ on purpose; parity is in capability, not in shape. Go and Java run the callback synchronously on the subscription's delivery goroutine or thread: the next event waits for it, with nothing else to schedule. The Rust service runs on tokio, where a blocking callback would stall the executor, so its callback returns a future that the subscription awaits; delivery order is the same as in Go and Java. No language is made to adopt another's shape.

The dispatch is generated beside the projections and called through the source's own package, so a subscribed source with a projection declared in another package fails generation instead of being silently left out of the dispatch.

Parity

A generated API is complete only when behavior is equivalent across the supported languages for the same proto model. Parity is measured against proto annotations, semantic registry metadata, generated-contract tests, and soundness checks, not against any single language implementation.