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:
- Resolve the authority endpoint for the current attempt.
- Generate a pending private key and CSR.
- Open the certificate stream with a cancellable attempt context.
- Wait for the first delivery.
- Promote the pending key only after the matching certificate delivery is accepted by the generated handler seam.
- 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:
- Decode the proto message.
- 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.
- Validate inbound request constraints before handler dispatch.
- Enforce subject/object namespace rules when the proto declares namespace fields: a subject may act only at or below its namespace level.
- Call the handler.
- Validate each outbound response or stream item before sending it.
- Map failures to stable generated error/status codes.
- 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
ToJSONhelpers; - read paths use generated
FromJSONhelpers and thenValidate; - 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>, withseqzero-padded to twenty digits so names sort in roll order.memberis 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'sInserterto honour. A missing segment is an error, never a partial restore. Restore must name the writer's originalmember. - 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-storageindependencies.yaml; a service that setsarchivein a build without that pin fails at startup. - Observability. A failed upload logs a
WARNINGwith the object name and retry delay.RedoLog.shipPending()(objects not yet shipped, the lag) andRedoLog.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)writeslineverbatim followed by one\n, creatingrelPath's parent directories and the file itself. It fsyncs before returning.relPathis the caller's path: there is no store namespace segment and no forced.logsuffix.modeis the Unix permission applied on create (0leaves the default). Alinecontaining\nis rejected asINVALID_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 nextappendremoves it before writing.PlainFile.readAll,PlainFile.exists,PlainFile.listread a whole file, test for its presence, and list a directory's entries, sorted. A missing file inreadAllisNOT_FOUND; a missing directory inlistis empty.- All paths are relative to
base. An absolute path, or one that resolves outsidebase, is rejected asINVALID_ARGUMENT. - Errors are
DiskStoreException:iofor 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_itementries establish the initial state;snapshot_donecarries the revision at which that snapshot is complete;deltaentries carry later changes;- stale
from_revisionreturnsOUT_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
deltacarrying 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).privateread 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_tokendecodes to a keyset cursor (theorder_bykey of the last row). A malformed token isINVALID_ARGUMENT— never a silent reset to the first page (which would skip rows and read as success).- The effective page size is
page_sizewhen positive, elsedefault_page_size, clamped to the trust tier'sMaxPageSize(Ceilings.MaxItems: 100 anon / 10k C2S-auth / 1M S2S). This is the per-call row ceiling that SNAPSHOT'smax_itemsprovides statically and that streaming reads lack. - The handler receives
PageParams{limit, after}plus the request and returns aPage{items, nextAfter, total}(total = -1means "unknown", not "empty"). The wrapper encodesnextAfterintonext_page_token(empty on the last page) and setstotal_countonly 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:
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.
SubscribeandBrokerare canonical action verbs (not folded toWatch); the messaging verbs areSend/Publish/Subscribe/Broker. Stateful vs stateless rule:Create/Deletefor stored resources;Mint/Revokefor self-contained credentials (a JWT isMintToken, neverCreateSession). Protocol-mandated URL paths (OAuth/OIDC) live onrest_path; the name conforms to the registry. - AI/Agent (7): Generate, Embed, Classify, Rank, Assist, Guard, Evaluate —
all under the
AGENTentitlement 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_verbandrpc_nounfrom the canonicalVerb<Noun>split.method_pattern:GET,LIST,WATCH,CREATE,UPDATE,DELETE,ACTION,CLIENT_STREAM, orBIDI.access_kind:READorWRITE.verb_class:core,action, oragent.entitlement_scopes: grant scopes that admit the method, ordered broad to narrow.retry_policy:READ_ONLY,WATCH_RECONNECT,IDEMPOTENCY_REQUIRED, orNO_GENERATED_AUTO_RETRY.capability_scope: primary package capability bucket for capability-any expansion (READ,WRITE,WATCH,PAGE, orAGENT).verb_definition,verb_boundary, andaudit_meaning: governed dictionary metadata for documentation, review, and operator/security surfaces.rejected_verbs: known non-canonical verbs that fold torpc_verband are rejected by generation.allowed_patterns: method patterns the canonical verb may resolve to.handler_shape:UNARY,SERVER_STREAM,CLIENT_STREAM, orBIDI.
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.