tacplus User Guide¶
Reference for developers using tacplus to build services — writing proto files, configuring tacplus.yaml, and understanding what gets generated. For the conceptual model, see THEORY.md. For generated API contracts, see API.md. For working on the tacplus compiler itself, see DEVELOPMENT.md.
Table of Contents¶
- Patterns for Service Implementers
- tacplus.yaml Reference
- CLI Commands
- Annotation Reference
- Conditional Requirements
- Formal Verification Annotations
- REST Method Control
- Governed RPC Verbs
- Tags
- State System
- Auth System
- Record / Event / Store / Projection Annotations
- registry.json and the Semantic IR
- Generated Libraries
- Service Lifecycle
- Distributed Tracing
- Well-Known Names (Conventions)
Patterns for Service Implementers¶
See API.md for generated API contracts and DEVELOPMENT.md for compiler-side pattern rules. Code examples for each pattern are below.
Use the Generated Service Manager¶
// Correct: create the generated manager, then run your handler
func main() {
sm, err := s2sm.New()
if err != nil {
log.Fatal(err)
}
if err := sm.Run(internal.NewHandler()); err != nil {
log.Fatal(err)
}
}
// Wrong: manually wiring gRPC servers, TLS, auth interceptors
lis, _ := net.Listen("tcp", addr)
grpc.NewServer(grpc.Creds(...), grpc.UnaryInterceptor(...))
Use Generated gRPC Wrappers¶
// Correct: implement the generated Handler interface and run the manager —
// the generated wrappers dispatch transport/auth/validation to your handler.
sm.Run(&myServiceImpl{sm: sm}) // myServiceImpl implements the generated Handler
// Wrong: manually implementing grpc.ServiceDesc, calling grpc.Dial directly
Use Generated Clients and State Accessors¶
// Correct: reach downstream service clients and state through the manager.
api := sm.Grpc().Clients().Other().OtherApiClient() // typed downstream client
sc := sm.StateClients() // typed access to state boundaries
// Wrong: dialing the service/state gRPC address and calling raw RPCs
Use Generated Serializers and Deserializers¶
// Correct: use the generated helper
config, err := mypkg.ConfigFromBytes(data)
bytes, err := mypkg.ConfigToJSON(config)
// Wrong: json.Unmarshal(&config, data) — bypasses aliases, defaults, error typing
Use Generated Validation¶
// Correct: validation helpers are package-level functions, not methods
if err := mypkg.ValidateFooRequest(req); err != nil {
return nil, err
}
// Wrong: manual string length checks, regex matching, etc. for annotated fields
Hand-Written Code Is Allowed Exactly Here¶
// internal/handler.go — the ONLY hand-written file: implement the generated
// Handler interface, then run the manager.
func NewHandler(ctx context.Context, sm *s2sm.ServiceManager) error {
return sm.Run(&myServiceImpl{sm: sm})
}
Service implementations (myServiceImpl) contain business logic. Everything else — transport, auth, state, serialization, health — is generated.
tacplus.yaml Reference¶
tacplus.yaml is the project-local generation config. It should declare only facts that cannot logically be defined in proto: the target language, generated package/module name, output location, proto source, config composition, and which client transport surfaces this package emits. Service shape belongs in proto annotations: service categories, auth policy, state imports/exports, gRPC client dependencies, REST routes, and store/state behavior are all derived from annotated proto definitions.
Only language and output-dir are required. pkg is commonly set explicitly, but may be derived from the host project when omitted.
Top-Level Fields¶
| Field | Type | Required | Description |
|---|---|---|---|
language |
string | Yes | Full-service targets: go, rust, java. Full-client targets: python, typescript. Lite client targets: swift, csharp. |
output-dir |
string | Yes | Output subdirectory relative to project dir |
pkg |
string | No | Canonical package name (Java-style reverse DNS, e.g. com.example.myservice). Each segment must be a valid identifier (letters, digits, underscores — no hyphens). Converted to native format per language (see table below). |
Package Name Conversion¶
Given a canonical pkg (e.g. com.example.myservice) and output-dir name
(default model), the native package/module/crate name is derived per language:
| Language | Rule | Example (com.example.myservice, output model) |
|---|---|---|
| java | pkg.output |
com.example.myservice.model |
| go | reverse TLD to host, /-join rest, append output |
example.com/myservice/model |
| rust | last segment + - + output |
myservice-model |
| typescript | last segment + - + output |
myservice-model |
| python | last segment + _ + output |
myservice_model |
| swift | PascalCase(last segment) + PascalCase(output) | MyserviceModel |
| csharp | PascalCase(last segment) + PascalCase(output) | MyserviceModel |
Swift and C# use the same PascalCase folding: segments split on -, _, and
. are title-cased and concatenated. This means distinct canonical packages
that differ only in segments before the last (e.g. com.a.identity vs
com.b.identity) produce the same native name for rust/ts/python/swift/csharp.
Proto package names must be globally unique; choose last segments that are
unique across the system to avoid collisions in these languages.
| src | string | No | Primary proto source — local path or git URL. No default — must be supplied via --src or tacplus.yaml. |
| sources | string list | No | Additional proto sources merged with src into one generation (e.g. a shared base plus a reusable service's protos). All sources form one proto tree; a file at the same path in two sources must be byte-identical (deduplicated) or it is a hard error, and dependencies.yaml is merged across sources (conflicting pins error). Repeatable --src on the CLI overrides src+sources. |
| import | string | No | Path to base config to import (resolved relative to this file) |
Client Transport Surfaces¶
Client transport surfaces are a function of the proto graph, not YAML config.
Tacplus generates the surface for a declared client dependency; there is no
clients.*.enabled switch (it is obsolete and rejected — see the note below).
Proto declares the dependency:
option (spec.grpc_c2s_client) = {service: "pkg.Service"};→ a C2S (native gRPC) client manager (c2sm).option (spec.grpc_w2s_client) = {service: "pkg.Service"};→ a W2S (gRPC-over-REST / web) client manager (w2sm).- a
service X { option (spec.service_c2d) = {...} }in the package → a C2D (client-to-local-daemon) client manager (c2dm).
If a dependency is not declared, no manager for that surface is emitted. C2S and W2S are distinct surfaces from distinct declarations (they are not merged); W2S is the runtime web-transport alternative to C2S, and the application chooses which client to use at runtime.
Language targets do not all support the same surface set:
- Full-service targets (
go,rust,java) generate service runtimes and any declared C2S/W2S/C2D client surfaces. - Full-client targets (
python,typescript) generate C2S, W2S, C2D, and REST client surfaces. Service-side output — S2S clients, server wrappers, ServiceManager runtime, EventBus subscriber/publisher runtime, health runtime, SMB runtime, service state, or service stores — is rejected loudly. - Lite client targets (
swift,csharp) generate C2D, C2S, and W2S client surfaces. Swift's W2S carries unary methods over HTTP/JSON and server-streaming methods over SSE (as anAsyncThrowingStream, with no deadline — cancel the enclosingTaskto end it). C#'s W2S is unary-only. A streaming method the target cannot carry — client-streaming and bidi for Swift, which the gateway serves over WebSocket, and every streaming method for C# — is emitted as a compile-time-unavailable stub, not omitted, so a mixed unary+streaming service still produces a compilable client and no declared method silently disappears. Use the Go/Java/Rust W2S client for those. Unlike the full-client tier they currently emit no client-side request validation and no default request deadline. Any service-side surface is rejected loudly.
Full-service targets generate a c2dm manager for services marked with
(spec.service_c2d), with typed gRPC clients over local daemon IPC. Go and Rust
dial a Unix-domain socket. Java accepts a caller-supplied local-daemon
ManagedChannel so the embedding app chooses the local IPC adapter.
Swift and C# emit lite client packages that include a C2D surface: Swift emits a
SwiftPM package with a Unix-domain socket C2DClient helper, while C# emits a
.NET package that defaults to named pipes on Windows and Unix-domain sockets
elsewhere. They also emit C2S and W2S client surfaces (Swift's W2S adds server-streaming
over SSE; C#'s is unary-only). Python
and TypeScript emit a C2D client-side surface for (spec.service_c2d) services,
alongside their C2S/W2S/REST client surfaces.
W2S is a full client surface, not a REST-only subset. Unary RPCs use HTTP/REST, server-streaming RPCs use SSE, and client-streaming or bidirectional RPCs use WebSockets. Tacplus surfaces blocked or timed-out transports as errors; polling fallback is application behavior.
The obsolete clients.c2s/w2s/c2d.enabled YAML block is rejected with a
config error. Two builds of the same proto graph, service identity, language, and
package name produce the same surfaces regardless of local build settings —
declare client dependencies in proto, not in tacplus.yaml.
How pkg Becomes A Native Package Name¶
pkg is a canonical reverse-DNS name (com.example.identity). Each target
language renders it differently, with the output-dir name appended as the
generated-model segment (model below):
| Language | Transform | com.example.identity → |
|---|---|---|
| java | canonical + . + output name |
com.example.identity.model |
| go | TLD and host swapped, rest joined with / |
example.com/identity/model |
| rust, typescript | last segment + - + output name |
identity-model |
| python | last segment + _ + output name |
identity_model |
| swift, csharp | last segment, PascalCased, + PascalCased output name | IdentityModel |
Only java and go carry the whole canonical name. The other five keep the last
segment alone, and swift/csharp additionally fold -, _ and . away, so
com.example.identity, com.other.identity and com.other.ident_ity all render
as the same swift module. tacplus cannot detect that: one build sees one pkg.
Distinct services must therefore differ in the LAST segment of pkg, not only in
its prefix — the D-5 convention that proto package names are globally unique
applies to that segment.
Config Composition¶
Configs can import a base file and override specific fields:
# tacplus-base.yaml
language: go
src: https://gitlab.com/org/proto.git
pkg: com.example.myservice
output-dir: tacplus
# tacplus.yaml
import: tacplus-base.yaml
pkg: com.example.myservice.ci
Multiple config files via CLI:
Merge rules:
- Strings: overlay wins if non-empty
- Lists: overlay replaces entirely if non-empty
CLI Flag Equivalents¶
| YAML Key | CLI Flag |
|---|---|
src |
--src |
CLI flags override YAML values when both are specified.
CLI Commands¶
| Command | Purpose |
|---|---|
tacplus build |
Generate proto types and services for a single language |
tacplus validate |
Validate proto files |
tacplus verify |
Run formal verification on Rust or Java code |
tacplus registry |
Update the service / vservice / trait registry from proto definitions |
tacplus upgrade |
Upgrade tacplus to the latest patch release |
tacplus version |
Print the version and build information |
Common Usage¶
# Generate for a single language from a config + proto source
tacplus build --config tacplus.yaml --src /path/to/proto --tag latest
# Layer multiple configs (later files override earlier)
tacplus build -c tacplus-base.yaml -c tacplus-overrides.yaml
# Go integration via go:generate
//go:generate tacplus build --config tacplus.yaml --src ./proto --tag latest
go generate ./...
Annotation Reference¶
RPC naming is governed: every RPC is Verb<Noun> against a closed verb
registry with fold rules (Search/Query→List, New/Add→Create,
…). The authored compiler dictionary lives at
pkg/internal/common/verbs/registry.json; the registry, pattern taxonomy, and
fold test are documented in API.md → RPC Patterns And Verbs. Name new
RPCs canonically; the classifier rejects non-canonical names during descriptor
parsing.
Field Constraint Annotations¶
| Annotation | What It Validates |
|---|---|
assert_required |
Non-zero value (scalars), non-empty (strings/repeated) |
assert_ipaddr_type |
IPv4, IPv6, subnet mask, CIDR network |
assert_net_port |
Valid TCP/UDP port (1-65535) |
assert_url |
HTTP, HTTPS, or either |
assert_email |
Email address format |
assert_fqdn |
Fully qualified domain name |
assert_host |
IP address OR FQDN |
assert_listen_addr |
host:port format |
assert_base64 |
Valid base64 encoding |
assert_enc_type |
RSA/EC/Ed25519 key or X.509 cert in PEM |
assert_semver |
Semantic version (e.g., "1.2.3") |
assert_duration |
Positive or non-negative duration |
assert_file_path |
Absolute, relative, or either |
assert_enum_defined |
Enum is not the zero/unspecified value |
assert_max_size |
Maximum byte size for string/bytes (default 32KB) or element count for repeated/map (runtime default 1024; user protos should set explicit bounds) |
assert_gt / assert_ge / assert_lt / assert_le |
Numeric comparisons |
assert_numeric |
Complex numeric constraints (in, not_in, multiple_of) |
assert_float |
Float constraints with epsilon tolerance |
assert_sql |
Valid SQL statement of permitted type (SELECT, etc.), rejects injection patterns |
assert_invariant |
Formal invariant (constrains a #[kani::proof] harness via kani::assume() in Rust, a JBMC harness via CProver.assume() in Java) |
assert_min_size |
Minimum byte length (string/bytes) or element count (repeated/map) |
gRPC Client Dependency Annotations¶
These are file-level options declared once per proto file. Proto package names are globally unique (a gRPC requirement), so no file path is needed — tacplus resolves the package name to its proto file at build time.
// S2S: this service calls identity.Mint (service-to-service, uses service credentials)
option (spec.grpc_s2s_client) = {service: "identity.Mint"};
// Client-facing service dependency. tacplus.yaml selects C2S, W2S, or both.
option (spec.grpc_c2s_client) = {service: "gateway.TokenMint"};
// Older transport-named spelling, also treated as a client-facing dependency.
option (spec.grpc_w2s_client) = {service: "dashboard.Dashboard"};
Multiple clients of the same type can be declared with repeated options:
option (spec.grpc_s2s_client) = {service: "identity.Mint"};
option (spec.grpc_s2s_client) = {service: "eventbus.Admin"};
tacplus generates typed client wrappers for each declared dependency and makes them available via the service manager (sm.Grpc().Clients().<Host>().<Service>Client() in Go; in Rust use sm.s2s().<host>().<service>_client() from an S2S service, or sm.grpc().<host>().<service>_client() from a c2s/w2s/c2d client).
Local Daemon Services¶
Services marked with (spec.service_c2d) = {socket_path: "/run/mydaemon.sock"}; are local daemon RPC surfaces. socket_path is required — there is no default (a shared default would collide between daemons on the same host).
When a package's proto graph declares in-scope (spec.service_c2d) services,
full-service targets emit a c2dm manager and client-only targets emit their
language-specific C2D helpers for those services (no YAML flag is involved). C2D
clients do not attach generated bearer tokens; the local IPC boundary is the
operating-system transport and its permissions.
Serialization Annotations¶
| Annotation | Effect |
|---|---|
json_key |
Override JSON output key (when proto field was renamed) |
json_aliases |
Additional keys accepted during JSON deserialization |
default_string / default_int / etc. |
Default values generated per language |
string_const / int_const / etc. |
Constants generated at file level |
Enum Annotations¶
| Annotation | Effect |
|---|---|
json_return_value |
Override JSON representation of an enum value |
Conditional Requirements¶
Fields can be conditionally required based on sibling field values:
// Required when disable_tls == false
string cert_path = 5 [(spec.required_if_false) = "disable_tls"];
// Required when action == "ACTION_REDIRECT"
string redirect_url = 3 [(spec.required_if_eq) = {field: "action", value: "ACTION_REDIRECT"}];
Formal Verification Annotations¶
A field can carry a formal invariant via (spec.assert_invariant), which is
emitted into a generated proof harness:
In Rust the harness is a #[kani::proof] that constrains the field with
kani::assume(...); in Java it is a JBMC harness that constrains it with
CProver.assume(...). The tacplus verify command runs these proofs (Rust or
Java only).
(spec.proof_requires)/(spec.proof_ensures)exist and are parsed, but no generator consumes them yet — they emit nothing today.
REST Method Control¶
RPC methods can declare additional HTTP methods beyond the implicit POST:
rpc GetConfig(GetConfigRequest) returns (Config) {
option (spec.rest_method) = {methods: [REST_METHOD_GET]};
option (spec.rest_path) = ".well-known/openid-configuration";
}
rest_path creates an alias REST route in addition to the convention path. Available methods: GET, PATCH, PUT, DELETE.
A c2s RPC whose REST route is a browser form target, such as an identity
provider's form_post callback, declares rest_browser:
rpc BrokerFormPostCallback(FormPostCallbackRequest) returns (FormPostCallbackResponse) {
option (spec.rest_browser) = {form_body: true, redirect_field: "redirect_url"};
}
form_bodymakes the route accept anapplication/x-www-form-urlencodedbody as well as JSON. Each pair becomes a string value keyed by proto field name. A key sent twice gets a 400.redirect_fieldnames a singular string field of the response. The route answers303 See OtherwithLocationset to that field andCache-Control: no-store, instead of JSON.
Both are emitted into the generated route table (Rust
rest_dispatch::RouteInfo form_body / redirect_field, Go
restdispatch.RouteInfo FormBody / RedirectField, Java
RestDispatch.RouteInfo formBody / redirectField; the redirect field is
spelled as its proto3 JSON name in all three) and carried out by its
form_body_to_json and redirect_response (Rust), FormBodyToJSON and
RedirectResponse (Go) or formBodyToJson and redirectResponse (Java),
which a REST host calls. Generation refuses the option on a streaming RPC, on
an RPC with no c2s REST route, and when redirect_field is missing from the
response or is not a singular string.
Governed RPC Verbs¶
RPC method names should be Verb<Noun> with a canonical verb from the tacplus
registry. The verb defines the method pattern, static access kind, retry
posture, audit meaning, and entitlement capability. Do not invent synonyms for
existing patterns, and do not overload one RPC so a branch changes its
capability, for example a read method that conditionally writes.
Core verbs:
| Verb | Pattern | Access | Use |
|---|---|---|---|
Get |
GET |
READ |
Fetch one resource by identity |
List |
LIST |
READ |
Collection read declared with method_collection |
Watch |
WATCH |
READ |
Snapshot/delta change stream |
Create |
CREATE |
WRITE |
Bring durable resource state into existence |
Update |
UPDATE |
WRITE |
Modify an existing resource |
Delete |
DELETE |
WRITE |
Remove resource state |
Action verbs: Mint, Revoke, Authorize, Send, Publish, Subscribe,
Broker, Execute, Cancel, Retry, Reset, Ping, Proxy, and Merge.
Agent verbs: Generate, Embed, Classify, Rank, Assist, Guard, and
Evaluate.
Known synonyms such as Search, Query, Fetch, Add,
Remove, Logout, Summarize, and Rerank are rejected with canonical
rename guidance. (Subscribe and Broker are canonical action verbs, not
synonyms — a bidi/stream subscription is Subscribe<Noun>, not Watch<Noun>.) If a real operation does not fit the closed set, document the
new pattern and update tacplus instead of hiding the distinction in consumer
code.
Migration guidance:
- Rename folded verbs to their canonical form:
Search/Query/Find/FilterbecomeList,Fetch/LoadbecomeGet, andAdd/NewbecomeCreate. - Match the wire shape to the verb:
Listrequiresmethod_collection(SNAPSHOTorPAGE),Watchrequires a server-streamingmethod_collectionwithWATCH, and client/bidirectional streams must use action or agent verbs. - Treat the classifier error as the source of truth. Do not rely on missing
registry.jsonfields or package-wide entitlement wildcards as a migration bridge; generation now fails before those artifacts are emitted.
registry.json exposes the resolved fields on each RpcInfo: rpc_verb,
rpc_noun, method_pattern, access_kind, verb_class,
entitlement_scopes, retry_policy, capability_scope, verb_definition,
verb_boundary, audit_meaning, rejected_verbs, allowed_patterns, and
handler_shape.
Tags¶
tacplus embeds sys/tags.proto for Kubernetes-style tag sets and selectors.
Tags are runtime model data, not compiler annotations, so they live outside
sys/spec.proto. Import sys/tags.proto when a service needs portable
key/value grouping or selector matching:
import "sys/tags.proto";
message Workload {
string id = 1;
tags.TagSet tags = 2;
}
message ListWorkloadsRequest {
tags.TagSelector selector = 1;
}
The embedded proto shape is:
package tags;
message TagSet {
map<string, string> tags = 1;
}
message TagSelector {
map<string, string> match_tags = 1;
repeated TagSelectorRequirement match_expressions = 2;
}
message TagSelectorRequirement {
string key = 1;
TagSelectorOperator operator = 2;
repeated string values = 3;
}
enum TagSelectorOperator {
TAG_SELECTOR_OPERATOR_UNSPECIFIED = 0;
TAG_SELECTOR_OPERATOR_IN = 1;
TAG_SELECTOR_OPERATOR_NOT_IN = 2;
TAG_SELECTOR_OPERATOR_EXISTS = 3;
TAG_SELECTOR_OPERATOR_DOES_NOT_EXIST = 4;
}
sys/ and chassis/ are reserved tacplus namespaces: sys/ holds the embedded
declarative proto primitives (import sys/spec.proto, sys/tags.proto, etc.),
and chassis/ holds the generated runtime machinery. Tacplus owns and injects
both — a proto authored under sys/ or chassis/ in your source tree is
rejected at build time. Import from sys/; never author files there.
Generated package locations follow the embedded library convention. Proto-derived
types live under the declarative sys/ namespace (emitted as types/sys/...);
the runtime machinery (helpers and library code) lives under the chassis/
namespace, separated from the declarative types:
- Go: protobuf types in
types/sys/tags, helpers inchassis/tags. - Java: protobuf types in
{package}.types.sys.tags, helpers in{package}.chassis.tags. - Rust: protobuf types under the generated
types::sys::tagsmodule, helpers in the generatedchassis/tagscrate. - Python: protobuf types under
types.sys.tags, helpers inchassis.tags. - TypeScript: protobuf types in the generated
types/sys/tagspackage, helpers in thechassis/tagspackage.
The generated runtimes include helpers for:
- subset checks: every key/value in one
TagSetexists in another; - selector matching:
match_tagsplusmatch_expressions; - explicit key/value and selector validation.
Subset helpers return true when every key/value pair in want exists with the
same value in have. Empty want returns true. Empty or absent have matches
only empty want. Extra tags in have are ignored.
Selector semantics follow Kubernetes:
match_tagsis equivalent to anInexpression with one value;- all requirements are ANDed;
Inrequires a present key whose value is invalues;NotInmatches when the key is missing or its value is not invalues;Existsmatches any present key and takes no values;DoesNotExistmatches an absent key and takes no values.UNSPECIFIEDis invalid and validation fails loudly on it.
Validation is explicit. Matching helpers do not validate keys or selectors implicitly, so callers can separate "is this selector well-formed?" from "does this selector match these tags?" Validation follows Kubernetes label syntax:
- Key: optional DNS-subdomain prefix plus
/, then a required name segment. - Name segment: 1-63 characters, alphanumeric at both ends, with
-,_, and.allowed between. - Value: 0-63 characters; when non-empty, it follows the same boundary and character rules as the key name segment.
- Kubernetes-reserved prefixes are not reserved by tacplus unless a future tacplus-owned prefix policy says otherwise.
State System¶
service_state¶
A message annotated with (spec.service_state) becomes a state root — a named partition of distributed state:
message MyServiceState {
option (spec.service_state) = {label: ["myservice"], export: true};
Config config = 1;
repeated Session sessions = 2 [(spec.indexed) = true];
}
message Session {
string id = 1;
// In a collection, the state store computes TTL = exp - now and auto-evicts
// the entry on expiry. epoch_expiration is for int64 collection fields only.
int64 expires_at = 2 [(spec.epoch_expiration) = true];
}
Every message-typed field of a state root is a boundary — stored independently
and convergent (lockless, last-writer-wins / append-only). The repeated label
field routes the state to its backing store services. The export flag makes the state available
for read-only import by other services.
tacplus generates:
- A gRPC state service with Get/Set/Watch/Delete RPCs for every boundary
- state boundary bindings for each boundary field
- Typed client wrappers so other services access state through generated accessors
- Watch streams — live subscriptions that push state changes to subscribers
Boundary Types¶
Fields within a state root can be:
| Type | How to Declare | Behavior |
|---|---|---|
| Singleton | Non-repeated field | One value per service. Singleton: a single value, no entry key. |
| Collection | Repeated field with (spec.indexed) on one sub-field |
The indexed field is the entry key. |
| Queue | (spec.queue) = true |
FIFO delivery. One consumer gets each item. |
| Priority Queue | (spec.priority_queue) = "severity" |
Consumed in priority order. |
| Topic | (spec.topic) = true |
Pub/sub. Every subscriber gets every message. |
| View | (spec.view_sources) or (spec.view_member) |
Read-only union of multiple collections. |
Cross-Service State Access¶
1. The owning service exports its state:
message OwnerState {
option (spec.service_state) = {label: ["ownerservice"], export: true};
OwnerRecord current_record = 1;
}
2. The consuming service imports the needed field at field level:
message ConsumerState {
option (spec.service_state) = {label: ["consumerservice"]};
ownerservice.OwnerRecord current_owner_record = 1 [
(spec.state_import) = "ownerservice.OwnerState.current_record"
];
}
tacplus derives the imported state root from the field-level projection, then generates read-only typed accessors (Get/Watch) for imported state. The projection currently supports singular message fields. The consuming service never gets Set/Delete — imports are always read-only. The access is enforced server-side: the backing store validates the caller's token against the proto-declared permissions. Writing a custom gRPC client to call the raw Set RPC will fail.
Views Across Boundaries¶
view_member aggregates collections across state roots into a single read-only view:
// In ServiceA's state
repeated jwk.Jwk verifying_keys = 1 [
(spec.view_member) = "JwkVerifyingKeysView"
];
// In ServiceB's state
repeated jwk.Jwk verifying_keys = 1 [
(spec.view_member) = "JwkVerifyingKeysView"
];
tacplus generates a JwkVerifyingKeysViewService with Watch/Get RPCs that unions both collections through a single Watch stream.
Views serve two purposes: they expose a single read surface of the same type across multiple producing services, and they let consumers bind against a typed interface without knowing specific implementation details of each producer. The same-type restriction is intentional — views are not arbitrary mashup layers.
Persistence and Eviction¶
| Annotation | Effect |
|---|---|
(spec.persistence) = true |
Requests boundary durability. No store implements it today — kernel is in-memory coordination and holds no durable user data, and generated state clients write nothing to local disk. On a kernel-labelled state root it is a generation error: durable data belongs in stored records (use (spec.record) + (spec.record_store)). Elsewhere it is accepted and carried descriptively to registry.json (BoundaryInfo.persistence) for a future store to act on. Unrelated to typed disk persistence, which is keyed off (spec.service_store). |
(spec.eviction_max_size) = 5000 |
Bounds the number of entries the generated Go state wrapper will accept for the boundary. |
(spec.epoch_expiration) = true |
An int64 field inside a collection item is treated as a Unix epoch expiry timestamp; the state store computes TTL at write time. The future and within-a-year check runs when the value is written or accepted on the wire, not when a stored copy is loaded from disk: stored data ages, so a restarted service loads an expired record and its owner retires it. On a scalar state-root field it is rejected at generation time. On an int64 inside a singleton-only boundary message it is accepted but ignored (documented carve-out — see spec.proto). |
(spec.instance_keyed) = true |
Server composes the entry key from the caller's token claims (service_id@instance_id), enforcing per-instance write isolation. |
(spec.eviction_ttl_seconds), (spec.eviction_max_idle_seconds), (spec.eviction_policy), (spec.eviction_max_size_policy), (spec.sync_backup_count) and (spec.async_backup_count) (and the state_* message-level defaults for the same three concerns) are declared in spec.proto but not implemented — no generator reads them. Generation rejects them rather than accept a retention or durability intent it will not honour. For per-entry expiry use (spec.epoch_expiration) on an int64 inside the collection item.
Leader Election¶
Boundaries annotated with (spec.leader) = true participate in distributed leader election. Only the leader processes writes. Lease renewal and failover are generated.
service_store¶
A message annotated with service_store becomes a durable key-value store backed by the state store with synchronous replication. Unlike service_state (convergent, Watch-based), service_store is:
- Write-with-quorum — writes acked only after replication to 2+ nodes
- Read-on-demand — no Watch streams, no client-side caching
- No boundary decomposition — the message IS the value
message CredPair {
option (spec.service_store) = {label: ["myservice"]};
string client_id = 1 [(spec.store_key) = true];
Jwk public_key = 2;
}
tacplus generates Put/Get/Delete/List RPCs.
Quorum Writes¶
Fields annotated with (spec.quorum_write) = true require a write to be durable on (spec.min_durable_copies) distinct nodes (default 2) before it is acked. Below that floor the write is refused with FAILED_PRECONDITION, never degraded; a single-node development tree declares min_durable_copies: 1. Valid on fields within service_state or service_store messages.
For a service_store, the generated disk helpers carry the store's floor (the strictest declared on any of its fields, since a row is written whole) and a gate the store's write path calls with the number of durable copies it can reach: MinDurableCopies<Type> / Require<Type>Quorum(n) (Go), MIN_DURABLE_COPIES_<TYPE> / require<Type>Quorum(n) (Java), MIN_DURABLE_COPIES_<TYPE> / require_<type>_quorum(n) (Rust). A store whose rows are partitioned across a cluster narrows its disk-log roll baseline to its own rows by implementing diskstore.LocalRows (Go, Java), or by overriding Handler::<store>_local_rows (Rust).
Java can also ship the redo log to a Google Cloud Storage bucket: set grpcconfig.disk_persistence.archive (gcs_bucket, prefix, checkpoint_interval_seconds) and pin com.google.cloud:google-cloud-storage in dependencies.yaml. Restore uses ArchiveRestore.restore. See docs/API.md, Archive to an object store.
Commands¶
Fields annotated with (spec.command) = true become mutation RPCs on the state service. The field type is the request message; the response is always google.protobuf.Empty. Use boundary_callers to restrict which services can invoke the command cross-namespace.
Automatic TLS (auto_tls)¶
State roots can opt into automatic TLS certificate lifecycle:
tacplus generates a bidi stream subscription to the token minting service's TLS cert endpoint. The service generates keypairs locally; the signing service delivers certs, triggering renewal at half-life.
Event Bus Integration¶
State roots can declare event-bus topic subscriptions:
And expose topic RPCs through the API gateway:
File-Level Service Identity¶
Every service proto declares its identity at file level:
option (spec.service) = {name: "identity", store: true}; // backing store service
option (spec.service) = {name: "gateway", gateway: true}; // API gateway (browser TLS)
option (spec.service) = {name: "dashboard"}; // application service
The name is the canonical service identity used across the mesh. store: true declares a backing store service. gateway: true changes TLS handling to use public certificates (for browser-facing TLS termination).
File-Level Contract Identity (vservice, trait)¶
A proto file can instead declare a contract — not a process, but a named surface
that some physical service implements and that callers are entitled against by that
name. Two options carry the same identity shape (a canonical name used as the
entitlement key, plus an optional id — the stable TokenEntitlements field
number for that contract):
option (spec.trait) = { // an RPC contract
name: "query" // entitlement key AND the audience callers authenticate against
host: "recordstore" // routing label of the physical implementor
s2s_service: "RecordMap" // emitted peer-facing service name
c2s_service: "RecordQuery" // emitted client-facing service name
auth_services: ["argus"] // peer identities allowed onto the S2S half
};
host becomes the (spec.service_s2s) / (spec.service_c2s) label on the emitted
services, so the gateway dials the host — but the audience the caller's token is
checked against is name, never the host label. The audience is the contract, not
the implementor, so a second implementor of the same trait needs no client change.
A label that no loaded service serves fails generation, and so does a label on a
file that declares no trait.
Everything the surface needs is in the declaration rather than in generator source:
delete the file and the surface is not generated at all. query is the shipped
example — it asks the record store for the record query plane; see
RECORD_EVENT_MODEL.md.
Auth System¶
Service-Level Auth Annotations¶
service_s2s — declares a service-to-service gRPC service. Only authenticated service identities can call these RPCs. label names the physical service hosting a vservice or trait interface:
service InternalAdmin {
option (spec.service_s2s) = {};
}
service RecordStoreHosted {
option (spec.service_s2s) = {label: "recordstore"};
}
service_c2s — declares a client-to-service gRPC service, exposed through the API gateway to end users. auth_entitled_users is the service-level default policy. label is the twin of the service_s2s one — it names the physical host for a trait's client-facing surface, while the audience the gateway checks the caller's token against stays the file's (spec.trait).name:
// Entitlement check applies to every RPC (the default C2S policy)
service Dashboard {
option (spec.service_c2s) = {auth_entitled_users: true};
}
// A trait's client-facing surface: dialed at the record store, entitled against the trait
service RecordQuery {
option (spec.service_c2s) = {auth_entitled_users: true, label: "recordstore"};
}
A service may declare only one caller surface (ValidateServiceSurfaces), so a contract that must serve both peers and users is two sibling services — which is why the query trait declares both an s2s_service and a c2s_service.
rpc — declares one RPC's call surface: who calls it (principal), where it
is reachable from (exposure), its non-exclusive protocol flags, the service
identities allowed to call it (auth_services), and the request field carrying
the target namespace (target_namespace_field). Everything that can legitimately
differ between two RPCs of the same service lives here:
service Auth {
option (spec.service_c2s) = {auth_entitled_users: true};
// Public endpoint (no token required at all)
rpc Discovery(Req) returns (Resp) {
option (spec.rpc) = {principal: RPC_PRINCIPAL_USER, exposure: RPC_EXPOSURE_EXTERNAL, flags: [RPC_FLAG_PUBLIC_NO_AUTH]};
}
// Any authenticated user, no entitlement check
rpc Exchange(Req) returns (Resp) {
option (spec.rpc) = {principal: RPC_PRINCIPAL_USER, exposure: RPC_EXPOSURE_EXTERNAL, flags: [RPC_FLAG_AUTH_ANY_USER]};
}
}
service RestrictedAPI {
option (spec.service_s2s) = {};
// Only these two service identities may call
rpc SensitiveOperation(Req) returns (Resp) {
option (spec.rpc) = {principal: RPC_PRINCIPAL_SERVICE, auth_services: ["serviceA", "serviceB"]};
}
// Authenticates the caller from the request body, so no bearer token
rpc Bootstrap(Req) returns (Resp) {
option (spec.rpc) = {principal: RPC_PRINCIPAL_SERVICE, flags: [RPC_FLAG_SELF_AUTHENTICATES], auth_services: ["*"]};
}
}
One rpc for users AND listed services. auth_services on a user rpc of a
service_c2s service adds those service identities to the users it already
admits. It is still one rpc on one surface, with one handler:
service Chat {
option (spec.service_c2s) = {auth_entitled_users: true};
// Entitled users post as today; the "assistant" service may post too.
rpc CreateChatMessage(Req) returns (Resp) {
option (spec.rpc) = {principal: RPC_PRINCIPAL_USER, flags: [RPC_FLAG_AUTH_ENTITLED_USERS], auth_services: ["assistant"]};
}
}
A user token is authorized by its entitlements, a service token by
auth_services (matched against the token's location-agnostic service path, so
"assistant" matches every tenant's assistant), and an unlisted service is refused.
"*" admits service callers only. An ephemeral workload (a pigeon, minted as
<service>/workloads/pigeon-<run_id>) is admitted only by a pattern that names
/workloads/. Each generated server language exports the pattern for any pigeon,
PIGEON_SERVICE_PATH (Go PigeonServicePath, Rust/Python/Java/TypeScript
PIGEON_SERVICE_PATH), and a function that scopes it to one parent service:
PigeonServicePathFor(service) in Go, pigeon_service_path_for(service) in Rust and
Python, pigeonServicePathFor(service) in Java and TypeScript.
The calling service declares option (spec.grpc_s2s_client) = {service:
"pkg.Chat"}; and uses the generated client. The handler's Caller is the
verified token's identity, so (spec.principal) / (spec.namespace) request
fields bind to the service's SPIFFE ID and its namespace, never to a value the
caller chose.
Every user RPC is also an MCP tool¶
An rpc on a (spec.service_c2s) service is published as an MCP tool
automatically. No annotation asks for it. The predicate is:
a c2s rpc that is not
RPC_PRINCIPAL_SERVICE, minusRPC_FLAG_PUBLIC_NO_AUTHandRPC_FLAG_SELF_AUTHENTICATES
— that is, an rpc whose caller is an authenticated human. An rpc with no
(spec.rpc) at all, user-facing only by the service's auth_entitled_users,
is the common case and is auto-exposed like any other. Token-minting
(RPC_FLAG_SELF_AUTHENTICATES) and public no-auth endpoints never are: the
first mints credentials, the second has no caller identity to scope a tool call
by.
There is deliberately no opt-out annotation. A service's whole user surface is its tool catalogue, and catalogue size is the MCP proxy's progressive-disclosure problem, not a filter configured here.
Auto-exposure is additive. The rpc keeps its gRPC call, its REST route, its verb classification and its entitlement grant, and the tool shares the handler method the generated service wrapper already calls — so a service author writes nothing extra, and the tool cannot drift from the call.
Two shapes are excluded from auto-exposure rather than refused, because auto-exposure is not a request the author made:
- streaming, on either side — a tool call is one request and one response, and the generated session carrier is itself a bidi rpc on its own service;
(spec.method_collection)in any mode butPAGE— a SNAPSHOT read hands the handler a materialized collection, a cursor or a stream, none of which a tool call has a frame for.PAGEis auto-exposed; see below.
Declaring RPC_PRINCIPAL_MCP on either shape is still a hard generation
failure, because there the tool was asked for.
A PAGE read is a paged tool¶
A COLLECTION_MODE_PAGE rpc is published as a tool like any other user rpc, and
the model drives the pager itself — the same way any other client of the gRPC
call does. Nothing new is annotated and nothing is truncated:
page_sizeandpage_tokenare already ordinary fields of a PAGE request (collection validation requires them), so they are ordinary tool arguments. The generated schema states the window:page_sizecarries the rpc's resolveddefault_page_sizein its description and the trust tier's ceiling as its JSON Schemamaximum. Both are optional.- The result is the page's items plus
next_page_token, exactly the response message the gRPC call returns. An emptynext_page_tokenmeans there is no next page;total_countis carried when the response declares it. - The dispatch calls the same three-argument handler the gRPC PAGE wrapper
calls —
(caller, request, PageParams)— buildingPageParamsfrom the tool call'spage_size/page_tokenwith the same token codec and the same trust-tier clamp. A malformedpage_tokenisINVALID_ARGUMENT, never a silent reset to page 1, and the(spec.store_principal)/(spec.store_namespace)egress filters run over the page exactly as they do on the gRPC path.
So a service author who writes a paged read writes nothing else: the page is paged on both surfaces, off one handler.
Because carrier injection is a proto-text operation it is language-blind, so tacplus refuses (MCP-6) to generate a service that owns tools for a language that emits a service wrapper but has no MCP dispatch backend — today Python. Go, Java and Rust have the backend. TypeScript, Swift and C# are client-only: they emit no wrapper at all, cannot host, and are not gated — a TypeScript client of a tool-owning service (the webapp) generates normally.
RPC_PRINCIPAL_MCP: a tool with no gRPC twin¶
RPC_PRINCIPAL_MCP remains, for the tool that has no rpc of its own: the rpc
declares an MCP tool and nothing else. tacplus generates its schema and a
handler interface method the service implements by hand, and maps it to no gRPC
RPC — what the handler body does (call its own RPC, call another service, fold a
Watch into one snapshot, nothing) is ordinary application code. Because
principal holds one value, such a tool is never also a gRPC call; a service
wanting both surfaces for related functionality declares two rpcs, or — far more
often now — just writes the user RPC and lets auto-exposure publish it.
service Tickets {
option (spec.service_c2s) = {auth_entitled_users: true};
// GetTicketBrief summarizes one ticket for a model.
rpc GetTicketBrief(GetTicketRequest) returns (GetTicketResponse) {
option (spec.rpc) = {
principal: RPC_PRINCIPAL_MCP,
exposure: RPC_EXPOSURE_EXTERNAL,
flags: [RPC_FLAG_AUTH_ENTITLED_USERS]
};
}
}
Everything else is reuse, deliberately:
- Authorization is unchanged.
flags/auth_services/target_namespace_fieldmean the same thing against whateverCallerthe MCP session captured at handshake as they do against a gRPC caller. There are no MCP-specific authorization fields, because two independently-maintained authorization surfaces are exactly what drifts. (spec.principal)/(spec.store_principal)on the request message are honored identically. That is the mechanism behind "the owner predicate is server-computed and never an LLM-writable tool argument": the wrapper stamps the field from the caller and refuses a request that supplied a different one.- The tool name and description come from the rpc itself — name = the RPC
name in snake_case (
GetTicketBrief->get_ticket_brief), description = the rpc's own leading//doc comment — so there is no second description to drift from the real one. This is true of an auto-exposed user RPC too: its doc comment is what the model reads to decide whether to call it, so a c2s rpc worth calling is worth a sentence.mcp_tool_nameoverrides only the name, for the rare RPC whose name makes a bad tool name; it must be snake_case, and declaring it on an rpc that publishes no tool at all fails generation rather than being ignored. - Streaming is a hard generation failure, unconditionally: a tool call has no
wire representation for a stream. A handler may drive a
Watchinternally and fold it into one response. - The session-carrying transport is never declared. A service owning at least
one tool — declared or auto-exposed — is what generates it; its exposure is the
most-permissive
exposureacross those rpcs. Under auto-exposure that means in practice every c2s service gets a carrier, an MCP SDK dependency and an entitlement grant for it. The registry records both asServiceInfo.mcpandServiceInfo.mcp_exposure.
The transport itself is one extra rpc on that service,
Broker<Service>McpSession, marked mcp_session_carrier in the registry.
tacplus does not synthesize it in the model: it injects a real declaration
rpc Broker<Service>McpSession(stream mcp.McpRequest) returns (stream mcp.McpResponse) {
option (spec.rpc) = {
principal: RPC_PRINCIPAL_USER,
exposure: <the service's mcp_exposure>,
flags: [RPC_FLAG_AUTH_ENTITLED_USERS]
};
}
into the service's own proto before protoc runs. So it is an ordinary rpc
everywhere downstream: a real grpc_path, the hosting service's auth_model,
a REST route, an entitlement grant, an authorizing server wrapper, a method on
the tonic trait and on grpc-java's ImplBase, and clients in every language —
none of it MCP-specific. The frames are the shared sys types mcp.McpRequest
and mcp.McpResponse (sys/mcp.proto), each one complete JSON-RPC 2.0 message
in a payload string, bounded at 1 MiB in and 4 MB out.
In Rust, tacplus generates the whole termination above that rpc and the
carrier is therefore NOT a handler method: the generated service wrapper
authorizes the session exactly as it authorizes any bidi rpc, refuses one with
no verified caller, and hands the stream to a generated rmcp worker
transport. What the service implements is only the tools, as ordinary typed
handler methods. Alongside the wrapper the Rust generator emits, per service:
<SERVICE>_MCP_TOOLS— the published tool set: name, the rpc's own doc comment, and a JSON Schema derived from the request message. Fields the server computes are ABSENT from that schema rather than merely ignored —(spec.principal),(spec.store_principal),(spec.namespace),(spec.store_namespace), and the injectedtrace_id/nooppair — and a tool call that names one is refused.<Service>McpSurface— anrmcp::ServerHandleransweringtools/listandtools/call. A call decodes into the rpc's own typed request, runs the same request validation, principal/namespace stamps and egress row filters the gRPC wrapper runs, dispatches to<Service>Handler::<rpc>, and encodes the typed response as the tool result.serve_<service>_mcp_session(...)and its worker — one MCP session over one gRPC stream, bounding outbound frames atmcp.McpResponse.payload's(spec.assert_max_size)so an over-size tool result fails that one request instead of the whole session.- a
#[cfg(test)]module asserting the tool set is published, that a tool call reaches its typed handler with the arguments decoded, and that a client-supplied server-computed field is refused.
The Rust model crate depends on rmcp only when a service in the build
declares an MCP tool.
In Go, tacplus generates the same termination, against the official
github.com/modelcontextprotocol/go-sdk. The carrier is likewise NOT a handler
method: the generated wrapper authorizes it, refuses a session with no verified
caller, and hands the validated stream to a generated transport. Beside the
wrapper the Go generator emits <service>_mcp.go, holding:
<Service>McpTools()— the published tool set as[]*mcp.Tool, each with the generated JSON Schema, omitting the same server-computed fields.<Service>McpSurface— the per-session surface. Atools/calldecodes into the rpc's own protobuf request withprotojson(which REJECTS an unknown field, soadditionalProperties: falseis enforced server-side too), runs the generated request validation, stamps(spec.principal)/(spec.namespace)from the verifiedCaller, dispatches to<Service>Handler.<Rpc>, filters(spec.store_principal)/(spec.store_namespace)rows out of the response, and frames the typed result. A tool failure is aCallToolResultwithIsError, not a JSON-RPC protocol error, so the model can read and correct its own call.<Service>McpSurface.Call(ctx, name, arguments)— the session-free entry point. An in-process caller (a service acting as its own MCP client, e.g. an agentic loop dispatching its own tool calls) reuses the generated argument decode, validation and caller-scoping through it; thetools/callhandlers are thin wrappers around it.serve<Service>McpSession(...)and anmcp.Transportover the gRPC bidi stream — one complete JSON-RPC frame per stream message, bounded atmcp.McpResponse.payload's(spec.assert_max_size).<service>_mcp_test.go— a generated gate that drives a whole session (initialize,tools/list, onetools/call) over that transport and asserts the typed handler was reached and a client-supplied caller-scoped field was refused.
Go's generated code lands in the consuming module, so tacplus writes no
manifest for it: go mod tidy resolves the SDK, and the version inventory
lives in dependencies.yaml. The SDK's own module requires go >= 1.25.0, so
a consumer module below that cannot build a service with an MCP tool.
In Java, tacplus generates the same termination, against the official
io.modelcontextprotocol.sdk:mcp. The carrier is likewise NOT a handler
method: it goes through the wrapper's ordinary bidi branch — same
authorization, same caller extraction, same frame validation — with a
fail-closed check that the session has a verified caller, and delegates to a
generated class. Beside the wrapper the Java generator emits <Service>Mcp,
holding:
<Service>Mcp.tools()— the published tool set asList<McpSchema.Tool>, each built from the generated JSON Schema, omitting the same server-computed fields.<Service>Mcp.Surface— the per-session surface, bound to one verifiedCaller. Itscall(name, arguments)is the session-free entry point an in-process caller uses; thetools/callhandlers are thin wrappers around it. It holds the caller as a FIELD rather than reading it off the gRPCContext, because the SDK dispatches a tool call on its own scheduler thread whereAuth.callerFromContext()is not in scope. Atools/calldecodes into the rpc's own protobuf request withJsonFormat.parser()(which REJECTS an unknown field, soadditionalProperties: falseis enforced server-side too), runs the generated request validation, stamps(spec.principal)/(spec.namespace)from thatCaller, dispatches to<Service>Handler.<rpc>, filters(spec.store_principal)/(spec.store_namespace)rows out of the response — with the record plane's ownRuntime.getRuntime().halt(1)backstop on a foreign-tenant row — and frames the typed result. A tool failure is aCallToolResultwithisError, not a JSON-RPC protocol error, so the model can read and correct its own call.<Service>Mcp.serve(...)and anMcpServerTransportProviderover the gRPC bidiStreamObserverpair — one complete JSON-RPC frame per stream message, bounded atmcp.McpResponse.payload's(spec.assert_max_size). An undecodable frame is answered with a JSON-RPC parse error and the session survives it.<Service>McpTest— a generated main-method self-test (the shape every other generated Java test takes) that drives each tool through the dispatch, asserts the typed handler was reached with the server-STAMPED value the call never carried, asserts a client-supplied caller-scoped field is refused before the handler is reached, and drives a whole session over the carrier as raw JSON-RPC frames.
Java's generated code lands in the consuming Maven module, so tacplus writes no
pom.xml for it: the consumer declares the SDK, and the version inventory
lives in dependencies.yaml. The SDK requires Java 17 or later.
Entitlements Model¶
For service_c2s services with auth_entitled_users: true, user tokens carry
the generated service_entitlements claim. The claim has the shape of the
generated entitlements.TokenEntitlements proto: a flat grants list of
generated entitlement tokens, plus optional azp scoping.
{
"sub": "user@example.com",
"aud": "myservice",
"service_entitlements": {
"grants": [
"DASHBOARD_DASHBOARD_GET_DASHBOARD",
"DASHBOARD_DASHBOARD_WATCH_DASHBOARD"
],
"azp": ["webapp"]
}
}
Generated grant helpers can expand package/capability wildcard tokens into
exact RPC grant tokens. Do not use a string wildcard such as "*" for generated
C2S authorization.
Tacplus now also emits governed RPC metadata (entitlement_scopes and
capability_scope) and carries a language-agnostic helper contract for
expanding package/capability wildcard grants into exact RPC grants. Generated
per-language helpers expand exact and wildcard grant tokens, and generated
runtime authorizers normalize service_entitlements.grants through those
helpers before checking the exact package/service/RPC grant for a call.
Identity Profiles¶
Identity profiles are named sets of entitlements. The token minting service resolves profile names to concrete RPCs during token minting.
message IdentityProfile {
string id = 1; // e.g., "admin", "viewer"
repeated ServiceEntitlement entitlements = 2; // service_name + allowed RPC enum values
}
Service-to-Service Auth¶
S2S authentication uses credential pairs (Ed25519 key pairs). Each service:
- Has a public/private Ed25519 key pair
- Signs a bootstrap JWT with its private key (
sub = service_name,kid = public_key.kid) - Presents the bootstrap JWT to the token minting service
- Receives refresh + access tokens for subsequent S2S calls
The token minting service validates the signature against the registered public key and maps the kid to a service identity via ServiceEntitlementMapping.
For services running in cloud environments (AWS, GCP, Azure), cloud-native identity mechanisms (SigV4-presigned STS requests, OIDC identity tokens) replace credential pairs.
Record / Event / Store / Projection Annotations¶
These annotations wire the eventbus, the record stores, and their projections.
The authoritative, exhaustive contract is in
RECORD_EVENT_MODEL.md; the hand-written surface is:
Message-level:
| Annotation | Meaning |
|---|---|
(spec.event) = {c2s?, s2s?} |
eventbus topic + publish RPC (at least one producer surface required) |
(spec.record) = {key, owner, c2s?, s2s?, residency?, min_group_size?} |
delivery: an event that is also stored in record_store and is projectable — key is the id field, owner the ownership field, residency the store's lifecycle as two clocks ({idle_seconds, deleted_max_age_seconds}; unset falls back to a live platform default, never "never"), min_group_size the smallest result size the record's size-only query surfaces will disclose (see below) |
(spec.transformation) = {…} |
transformation: a pure typed conversion of this message into a derived row type. Needs no record, no topic and no subscription, and carries no residency — declaring a transformation and declaring delivery are independent |
(spec.public_view) = true |
public view: every field declares (spec.visibility); tacplus generates <T>Public (the SHOW fields only) and the conversion to it — see below |
Field-level (on a record message's fields):
| Annotation | Meaning |
|---|---|
(spec.query_field) = {op: EQ\|RANGE} |
expose the field as a typed filter in the query trait's record query plane |
(spec.redact) = {mode: DROP\|PSEUDONYMIZE\|MAY_REDACT\|MAY_ANONYMIZE} |
marks a field for data entitlement (see below). DROP/PSEUDONYMIZE always apply (hard floor); MAY_REDACT/MAY_ANONYMIZE let the caller decide at egress. Valid on scalars, message-typed fields (redact clears the whole sub-message), and repeated fields; ANONYMIZE requires string/bytes |
(spec.visibility) = VISIBILITY_SHOW\|VISIBILITY_BACKGROUND |
on a field of a public_view message: SHOW may be rendered outside the company, BACKGROUND never leaves it (see below) |
(spec.deleted_flag) = true |
marks this record type's soft-delete bool field (exactly one per record type). The query plane's Open<T>QueryRequest/Refine<T>Request carry include_deleted (default false: flagged-deleted rows excluded); the row is purged residency.deleted_max_age_seconds after the flag was set |
Data entitlement (egress redaction)¶
Data entitlement sanitizes a record on its way out of a service. It is a lattice —
pass-through < anonymize < redact — where the effective transform for a field is
max(annotation floor, caller level): the caller can only ratchet a field tighter.
- Four annotations.
DROP(always cleared),PSEUDONYMIZE(always keyed-HMAC'd),MAY_REDACT/MAY_ANONYMIZE(the caller decides per call via an entitlement). - Recursive. A message-typed field can be redacted as a whole (the entire sub-message is cleared, cascading to everything beneath it) or field-by-field, to any depth.
- Generated surface. For each record
T, tacplus generates the proto message<T>Entitlement(a per-fieldEntitlementLevelthe caller sets, recursing into nested<Sub>Entitlement) and, in the full-service languages (Go/Rust/Java), the transformSanitize<T>(m, ent, hmacKey)returning a redacted copy. - Redacted indicator. Because a redacted scalar is indistinguishable from a
genuine zero, tacplus injects a companion
<field>_redactedbool (a stable high field number) into the record;Sanitizesets it true when it redacts the field. - Anonymize is a keyed HMAC-SHA256 — the same value+key yields the same surrogate (so pseudonymized records still join) but cannot be reversed without the key.
- Format-preserving. A field with a format constraint anonymizes to a valid
value of that format:
(spec.assert_email)→ a synthetic email,assert_ipaddr_typeIPV4/IPV6 → a valid IP (private / documentation range),assert_fqdn→ a valid hostname. Fields without a format constraint get thepseud:<hex>token.
Minimum group size (size-only query refusal)¶
(spec.record).min_group_size = N is the smallest result size this record's
size-only query surfaces will disclose. 0 / unset is no guard: the annotation is
additive, so a record that does not declare it gets exactly the plane it got before.
- What it guards. The three RPCs of the generated query plane that return a size and
no rows —
Get<T>CountandSubscribe<T>Count(RecordCount.count), andUpdate<T>Query(QueryStats.size, the match count of the newly installed predicate; left open, it is a one-RPC bypass of theGet<T>Countguard). Both halves of the plane are guarded, the S2SRecordMapand the C2SRecordQuery, unary and streaming alike.Update<T>QueryandSubscribe<T>Countare emitted only for a filterableT, so an unfilterable record is guarded onGet<T>Countalone. - Where the line is. It does not cover
List<T>'stotal_count: that size accompanies rows the caller has just been handed and discloses nothing the rows themselves do not.Delete<T>Query'sQueryStats.sizeis a hardcoded zero and carries no stamp. A future change that makes a paged read return counts without rows has crossed that line. - The refusal is an error, never a value. The generated wrapper — never the handler —
throws
PERMISSION_DENIEDwhen the size is belowN. There is no sentinel count and nosuppressedflag: a caller that knows nothing about suppression must not be able to read the refusal as data. The message names the annotation and the declared threshold and nothing tighter — "only 2 matched" would disclose exactly what the guard withholds. A stream is terminated with that error, on its first frame or on a later crossing, rather than silently filtered: a live count that just goes quiet atN-1discloses the crossing by silence. - Declared on the record, enforced off the method. The record-map injector propagates
the value onto each guarded RPC as
option (spec.min_group_size) = N;, and the downstream generators read that method option — none of them re-derivesTfrom an RPC name. - Two generation-time errors. A negative threshold fails generation, and so does a
threshold declared into a fleet where no
(spec.trait)hosts a record query plane (no trait whose host declares(spec.record_store)) — a threshold with no guarded surface behind it is decoration, and an unread annotation fails generation here the same way it does everywhere else. Enforcement is generated for Go, Java and Rust service wrappers; each generator fails the build on a stamped method whose guard it cannot render rather than serve a size-only RPC unguarded.
Public view (SHOW / BACKGROUND fields)¶
(spec.public_view) = true marks a message whose fields split between what may be
shown outside the company (a prospect's page) and what never leaves it. Example:
message Prospect {
option (spec.public_view) = true;
string company_name = 1 [(spec.visibility) = VISIBILITY_SHOW];
string deal_notes = 2 [(spec.visibility) = VISIBILITY_BACKGROUND];
Contact champion = 3 [(spec.visibility) = VISIBILITY_SHOW]; // Contact is itself public_view
}
- No default. Every field of a
public_viewmessage must declare a visibility; an unannotated field fails generation, so a new field forces a decision. - Generated surface.
<T>Public(proto, so every language gets the type) holds the SHOW fields at their own names and numbers; a SHOW field typed as anotherpublic_viewmessage becomes<Sub>Public. The conversion parses T's bytes as<T>Publicwith unknown fields discarded: Go<T>ToPublic, Javato<T>Public, Rust/Python<t>_to_public, TypeScript<t>ToPublic. Swift and C# generate no message helpers and get the type only.<T>Publiccarries no injected trace fields. - What a renderer is handed. Only
<T>Public. T's own read paths (state, record plane, MCP, REST, events) are authenticated company surfaces and return T whole — BACKGROUND means "never leaves the company", not "never leaves the service". - Generation-time errors. A SHOW field (singular, repeated or map value) typed as
a message that is not a top-level
public_viewmessage; a SHOW oneof member;(spec.visibility)outside apublic_viewmessage; either annotation on a nested declaration; a declared message named<T>Public; and any RPC callable without a caller token (RPC_FLAG_PUBLIC_NO_AUTH,RPC_FLAG_SELF_AUTHENTICATES, C2Sauth_disabled,service_c2d) whose request or response reaches apublic_viewmessage.
Service-level (once per service):
| Annotation | Service |
|---|---|
(spec.record_store) = {} |
the full-record store |
Example — a stored, projectable record:
message SensorEvent {
option (spec.record) = {key: "event_id", owner: "email", s2s: {auth_services: ["argus"]}};
string event_id = 1;
string email = 2 [(spec.query_field) = {op: EQ}];
int64 timestamp_ms = 3 [(spec.query_field) = {op: RANGE}];
}
You write only the annotations above. The projection output messages and
their internal timeseries_type marker, the eventbus topic +
publish RPCs, the per-store subscriptions, and the query plane are all
generated — never hand-written. The read surface of a record_store is the
generated refinable-query plane over record keys —
Create<T>Query / Update<T>Query / Get<T>Count / Get<T>Sample / List<T> /
Watch<T> / Subscribe<T>Count / Delete<T>Query, the same governed spelling on
both caller surfaces — not a hand-written subscription handler. That plane is asked
for by a (spec.trait) declaration, not by the record_store annotation alone: see
RECORD_EVENT_MODEL.md.
registry.json and the Semantic IR¶
tacplus generates a canonical registry.json that acts as the normalized semantic model of the service.
registry.json includes, in normalized form:
- Services, methods, types
- State roots and views
- Labels and ownership relationships
- Auth metadata and generation intent
- RPC verb, noun, pattern, access-kind, collection, retry policy, capability, handler shape, entitlement-scope, and governed dictionary metadata
- A root-level
entitlement_grantscatalog for entitlement-checked C2S methods: exact package/service/RPC grants plus package and capability wildcards with explicitexpands_tolists - tacplus's own documentation set, embedded verbatim under
docs— including the generatedAGENT.md(rendered from tacplus's template against this registry). Consumer-authoredagent.mdfiles are NOT read: nothing in tacplus ingests them, so authoring one has no effect on generation or onregistry.json.
Proto files are the authored source. registry.json is the compiled semantic
model. Consumer tooling that needs tacplus semantics should read
registry.json, not parse proto files directly.
Wildcard entitlement grants are convenience inputs for policy, operators, and
UI. Consumers should expand them to the exact package/service/RPC grants listed
in entitlement_grants.expands_to before making an authorization decision.
Runtime Discovery¶
When served from a well-known REST endpoint (e.g., by an API gateway), registry.json makes the semantic model available at both:
- Build time — for generation, linting, testing, docs, and analysis
- Runtime — for discovery, tooling, agents, and system introspection
TypeID / RpcID Enums¶
After generation, each processed proto file gets injected enums:
enum TypeID {
TYPE_ID_UNSPECIFIED = 0;
TYPE_ID_MY_MESSAGE = 1;
// ... one per message
}
enum RpcID {
RPC_ID_UNSPECIFIED = 0;
RPC_ID_MY_SERVICE_MY_METHOD = 1;
// ... one per RPC method
}
These stable numeric IDs are used in the per-proto registry for O(1) type and RPC lookups.
AGENT.md (generated)¶
tacplus generates AGENT.md from an internal template (docs/AGENT.md.tmpl)
and embeds it into registry.json under the docs key. It is a generated
artifact — there is no mechanism for consumer projects to supply or override it.
The agentic analysis pass uses it to check for cross-language parity drift,
suspicious service shapes, and architectural anti-patterns.
Generated Libraries¶
tacplus generates runtime libraries from templates. Full-service targets get language-appropriate implementations of:
| Library | Purpose |
|---|---|
servicemanager |
The ServiceManager (sm): top-level runtime surface wiring config, clients, state, and health |
grpcserver |
gRPC + HTTP server with TLS, auth interceptor, static file serving |
serviceconnection |
Managed gRPC endpoint connections (go/java) |
service_im |
Service identity manager: acquires the service's own identity (credential pair or cloud-native), mints and refreshes S2S tokens, and pins the bootstrap trust domain |
token |
JWT creation, parsing, signature verification |
jwk |
JWK key management, immutable key wrapper, key rotation |
key |
Ed25519 key generation and management |
x509 |
X.509 certificate and CA operations |
spiffe |
SPIFFE ID parsing/construction and trust-domain helpers |
logger |
Structured logging with dynamic level control |
grpcconfig |
Runtime config loading from shared state |
healthreporter |
Periodic health/metrics publishing to ServiceHealthState |
httpclient |
Typed HTTP client (CA/mTLS, timeouts, retry) with proto-validated responses |
diskstore |
Durable disk-backed snapshot store (UC1 self-persistence) |
collection |
Keyset-pagination (PAGE) collection helpers |
ip |
IP address parsing and validation helpers |
tags |
Kubernetes-style tag sets and selectors |
am |
Typed accounting event helpers and claim projection support |
jwkview |
Watch client for signing key views (go only) |
These are not generic utility libraries. Each is generated with the service's module path baked in and the correct proto imports resolved.
Identity is split into two managers (D-4). service_im (above) is emitted for
full-service targets and owns the service's own identity. client_im is
emitted wherever a client surface is generated — full-client (python,
typescript) and lite client (swift, csharp) targets included — and owns
the token lifecycle for non-service code: CLI tools and daemons that
authenticate as users (OAuth) or via credential pairs.
Client-only targets do not have a supported service runtime library surface.
Accepted builds should use only the generated C2D/C2S/W2S client helpers, the
client_im identity manager, and any client-side primitives they import.
Service Lifecycle¶
When tacplus processes a full-service target whose proto definitions declare
services and state, it generates a service entry point and a service-to-service
manager (s2sm). The hand-written code is minimal:
// internal/handler.go -- the ONLY hand-written file (Go example): implement the
// generated Handler interface, then run the manager.
func NewHandler(ctx context.Context, sm *s2sm.ServiceManager) error {
return sm.Run(&myServer{sm: sm})
}
The equivalent service runtime exists in the full-service targets (go, rust,
java). The generated ServiceManager handles:
- Connect to the backing store cluster
- Subscribe to signing key views (for token verification)
- Fetch runtime config from
ServiceConfigState - Create the Authorizer (token signature verification + entitlement checking)
- Start the gRPC server with TLS and auth interceptor
- Start the health reporter (periodic metrics publishing)
- Call the hand-written entry point
The generated entry point is ~20 lines in any language: parse flags, create ServiceManager, call Run().
Hand-written code is allowed only where platform law intentionally ends.
Distributed Tracing¶
The trace_id infrastructure field (field 200) is present on every proto message. When a service receives a request that contains a non-empty trace_id, it must copy that value into every outgoing RPC it makes in the course of fulfilling that request. The generated wrapper has no visibility into your handler's downstream calls — propagation is the implementer's responsibility.
This applies across all service boundaries: if an external request arrives with a trace_id and your handler calls another internal service, that internal call must carry the same trace_id.
Required Pattern¶
// Go
func (s *myServiceImpl) HandleRequest(ctx context.Context, req *pb.MyRequest) (*pb.MyResponse, error) {
downstream := &otherpb.OtherRequest{
// ... business fields ...
TraceId: req.TraceId, // propagate — always copy if non-empty
}
return s.sm.Grpc().Clients().Other().OtherApiClient().DoSomething(ctx, downstream)
}
// Rust
async fn handle_request(&self, req: MyRequest) -> Result<MyResponse, ProtoError> {
let downstream = OtherRequest {
trace_id: req.trace_id.clone(), // propagate
..Default::default()
};
self.sm.s2s().other().other_api_client().do_something(downstream).await
}
The same pattern applies in Java, TypeScript, and Python. Omitting the trace_id on any downstream call breaks the trace chain for that span.
Well-Known Names (Conventions)¶
Three names from the platform tacplus was first built for are, for now, expected by the generator itself rather than declared in the proto graph. They are conventions: a proto set that follows them generates correctly, one that does not will not get the behavior described. Replacing each with a proto-declared equivalent is tracked in https://github.com/tacplus-dev/tacplus/issues/1.
| Name | Where it is expected | What it means |
|---|---|---|
eventbus (proto package) |
EventBus wire-contract gate (pkg/internal/proto/eventbus_wire_contract.go) |
The canonical EventBus proto is found by its package name. The gate compares the synthesized eventbus_support.proto against it and fails generation on any field-level drift. If no package named eventbus is in the build inputs, the gate has nothing to compare against and is skipped. |
"kernel" (value of (spec.service_state).label) |
State placement rules (pkg/internal/proto/state.go) |
A state root carrying this label is a kernel boundary: (spec.persistence) on any of its fields is a generation error unless the root also sets identity_snapshot = true (Rule 3b, see the spec.service_state reference above). Other label values carry no generator meaning. |
AZP_KERNEL (enum spec.Azp) |
Token claims (pkg/proto/sys/spec.proto) |
The authorized-party value for tokens minted from trusted internal context by the platform's token service. The name reflects the first platform's service name; the meaning is "internal mint", not a dependency on a service called kernel. |