Skip to content

v0.2.3 - #3

Merged
dbotwinick merged 31 commits into
mainfrom
develop
Aug 29, 2026
Merged

v0.2.3#3
dbotwinick merged 31 commits into
mainfrom
develop

Conversation

@dbotwinick

Copy link
Copy Markdown
Contributor

This pull request introduces significant enhancements to the Aether protocol, focusing on portable runtime authorization, workflow schedule authority, and improved release and CI processes. The changes add new protocol messages and fields to support granular, gateway-enforced resource access checks, authority continuations, and workflow authority lifetimes. Additionally, the release documentation and CI workflows are updated to streamline and automate module tagging and Docker image builds.

Protocol and Authorization Enhancements:

  • Added portable runtime logical-resource authorization primitives: ResourceAccessRequest, AccessDecisionReceipt, AccessCheckOperation, AccessCheckResponse, BatchAccessCheckOperation, and BatchAccessCheckResponse messages, enabling gateway-evaluated access checks and receipts throughout the protocol (api/proto/aether.proto).
  • Extended core message types (SendMessage, IncomingMessage, MessageEnvelope, ProxyHttpRequest, TaskAssignment) with fields for checked access requests, gateway-authored access receipts, and authority continuations, allowing for fine-grained, auditable authorization flows (api/proto/aether.proto). [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [11] [12]
  • Introduced AuthorityContinuationScope and AuthorityContinuationRequest for explicit, attenuated authority delegation in message delivery, and ForwardedAuthorization for securely transporting derived authority context (api/proto/aether.proto). [1] [2]

Workflow Engine and Task Management:

  • Added workflow schedule authority primitives: WorkflowScheduleAuthorityScope, WorkflowRequestContext, and WorkflowAuthorityLifetimeMode, allowing the gateway to mint and manage schedule-scoped authority for workflow executions. Extended WorkflowOperation with fields for caller OBO authority, requested schedule authority, and trusted request context (api/proto/aether.proto). [1] [2]
  • Enhanced task creation and assignment with new fields for parent task binding, offline target policies, required downstream authority hops, and workflow schedule audience binding, supporting more flexible and secure task orchestration (api/proto/aether.proto). [1] [2] [3] [4]

Release and CI Process Improvements:

  • Updated release documentation in CLAUDE.md to clarify that a single root tag push triggers all publish workflows and that module tags are now managed automatically by CI, replacing previous manual or scripted processes (CLAUDE.md).
  • Refactored the Go module publish workflow to automatically reconcile and push per-module tags, granting write permissions and ensuring tags are created only when missing (.github/workflows/publish-go.yml). [1] [2]
  • Changed the Docker build workflow to use the production Dockerfile.aetherlite by default, aligning container builds with production standards (.github/workflows/build-docker.yml).

Documentation Updates:

  • Added documentation on workflow schedule authority and clarified the use of production and development Docker images for AetherLite, including security caveats for development tags (docs/aetherlite.md). [1] [2]

Flips ci.go.module_tags from verify to push, so a release is one action: push
the root `vX.Y.Z` tag. publish-go gates on the Go tests and then creates and
pushes the api/, sdk/go/ and server/ tags that Go resolves nested modules by.

Earned rather than assumed: v0.2.2 ran the same job in verify mode against tags
cut by hand and confirmed all four were present on the release commit, so the
only new behaviour here is creating them instead of checking them. The job keeps
the mismatch guard — a module tag that already exists on a different commit
still fails rather than being moved.

Pushing only the root tag also removes the failure that made v0.2.2 look inert:
GitHub creates no push event when more than three tags arrive at once, and
tag-release.sh pushed four together, so no workflow ran until the root tag was
re-pushed alone. With CI creating the module tags, a release can never exceed
one tag per push.

scripts/tag-release.sh is deleted — publish-go supersedes it, and keeping a
second way to cut tags invites exactly the four-at-once push that just failed.
The CHANGELOG reference to it is left alone as an accurate record of how 0.1.58
was released. The release process is now documented in CLAUDE.md, including the
three-tag limit, since that is not obvious and the consequence is silent.

One incidental change: the module tags are now lightweight rather than annotated.
Go resolves either, and nothing in this repo reads tag metadata.

contents: write is scoped to the module-tags job alone; actions/checkout keeps
its credentials by default, so the push authenticates with GITHUB_TOKEN.
The Python admin client exposed only list_agents/get_agent; the proto + Go
gateway (agent_handler.go) already support REGISTER/UPDATE/DELETE via
AgentOperation. Add register_agent/update_agent/delete_agent (async + sync) that
build an AgentRegistrationInfo (shared _build_agent_registration_info helper —
implementation/orchestrator_profile/description/launch_params/capabilities/
extensions/resource_schema; registered_at/updated_at stay server-owned) and send
via the existing agent_op transport. Enables superadmin agent-registry CRUD.
Adds WithHandlerMiddleware(func(http.Handler) http.Handler) plus Server.WrapHandler.

Motivation: the internal plane serves /auth/verify — the ext_authz call on the
path of EVERY request through the gateway — and there was no way to instrument it
from outside this package. In the Scitrera multi-tenant binary that made auth-go
invisible in tracing: its TracerProvider and exporter were wired and metrics were
flowing, but nothing on this plane ever created a span.

The middleware is a plain func rather than an OTel dependency here, so tracing
libraries stay in whichever binary wants them and this package's dependency graph
is unchanged.

Applied after AttachLogin so login routes are covered. The wrapped chain ends at
the mux POINTER, so routes registered later are still served through it.
WrapHandler is a no-op on nil, letting Run pass an unset option straight through.
handleIncrement set a counter's expiry only when some caller observed
counterVal == 1, through a SEPARATE Set after the atomic Increment. Any key that
came into existence another way never received a TTL at all: that Set failing,
two first-increments racing so neither observed 1, or a key written by some other
path. Such a key incremented forever and pinned its principal at "limit exceeded"
permanently, with no self-healing — the state lives in KV, so restarting the
client, the server, or anything between them changed nothing.

Observed in production 2026-08-05: a MemoryLayer per-user rate-limit counter
stuck at 10078 against a limit of 10000, returning 429 indefinitely across
restarts of MemoryLayer, platform-server and platform-bridge. A freshly-keyed
principal was unaffected, which is what isolated it to the key rather than the
limiter or the traffic. Notably the count was barely over the limit — it had crept
past gradually, not been driven there by a flood.

Now SetNX writes the key with its TTL BEFORE the increment, so a counter cannot
exist without an expiry, and it is atomic rather than a read-modify-write. The
old post-increment Set is kept purely as a fallback for SetNX failing, where
counterVal == 1 proves the key was absent and writing "1" cannot lose a
concurrent update.

Also fixes silent counter loss: the old Set clobbered the value back to "1",
discarding increments that landed between the Increment and the Set.
Orchestrators were denied KV outright, so an orchestrator could not read the
tenant ProvisionSpec or per-tenant launcher credentials it needs to build a
worker's environment. The worker was launched anyway, with no environment, and
the agent then died resolving its provider secrets.

The failure was also hard to see: the gateway logged the real cause
(PermissionDenied) while the orchestrator only ever received a generic
"[KV_ERROR] internal error processing KV operation".

This contradicted acl_seed.py, which already seeds NARROW orchestrator grants
(orc::<impl>::* -> kv_key/provision/*, *ikv:provision:*, *ikv:api_key:MODAL_*).
Those grants were unreachable: the type gate rejected orchestrators before
checkKeyPermission was ever consulted, so they could never take effect.

Follows the WorkflowEngine and MetricsBridge precedent directly — this ONLY opens
the type gate; every key an orchestrator touches still requires an explicit ACL
grant, so the reachable surface stays exactly what acl_seed.py defines.

The existing test asserted the old denial deliberately, so it is rewritten to
assert the new intent rather than deleted: orchestrators get a private KV space
for passing init args to the tasks they create, still ACL-scoped per key.
Publish could lose a message with NO observable trace. When subs[topic]
is empty the fan-out loop simply does not execute and Publish returns
nil, so the gateway's routeMessage -- which only logs on publish *error*
-- records a fully successful send while nothing was delivered. An agent
that is connected but whose subscription never registered is therefore
indistinguishable from a healthy one.

This is not hypothetical: an app-open tool call is published to
an application-specific agent identity topic, the agent is connected
with a live session and a successful setupClientSubscriptions, and the
message never arrives. The two drop paths that DO log (the full-channel
warn here, and handleDeliverShed's "delivery shed by backpressure" in
the session layer) both stayed silent, which leaves the zero-subscriber
case as the only remaining explanation -- and it was unobservable.

Adds:
- Warn when publishing to a topic with zero live subscribers. For an
  identity topic this is always a fault, never a normal state.
- Debug fan-out line (topic, seq, subscriber count) as its positive
  counterpart.
- Debug on subscriber registration (topic, consumer, exclusive, policy,
  start_seq, resulting subscriber count) so "was anyone listening?" is
  answerable directly rather than by inference.
- Debug on replay completion (start_seq -> replayed_up_to). A large
  replay is itself a suspect: replay pushes straight at the handler,
  which enqueues non-blocking into the client's delivery buffer, so a
  backlog can shed the messages that follow it.
- Warn when an exclusive subscription is REJECTED because the consumer
  is already active. consumerName is the identity string, identical
  across every incarnation of an agent, so a leaked lock blocks all
  future subscriptions to that topic until the gateway restarts -- and
  the error was previously only returned, never logged.
- Warn (was: silent skip) when a subscriber is already done.

Observability only; no behavioural change.
Two rate limits gate a client's outbound messages: the per-client limiter and
the per-workspace limiter (fed by the per-identity quota). In lite mode BOTH
ignored configuration, so message throughput was pinned at 100/s no matter what
the config said — and the drop is invisible to the sender, since the rejection
comes back asynchronously as ERR_RATE_LIMITED rather than from Send.

- gateway.message_rate_limit (+ message_rate_burst) was never applied: cmd/gateway
  appends gateway.WithMessageRateLimit, cmd/aetherlite did not, so the gateway's
  built-in newQuotaEnforcer(100, 200) stood regardless of the key.
- The quotas: block was hardcoded, so max_message_rate_per_identity — which feeds
  the workspace limiter — was likewise stuck at 100.

Both now read config with the same fallbacks cmd/gateway uses, so an unconfigured
deployment behaves exactly as before. Measured against a 1500-message burst on
one connection: 100/1500 delivered before, 1500/1500 in order after.
ScopeSpec has two independent axes: Sharing decides which AGENTS
rendezvous on a key, Identity decides WHOSE data it is. buildJSKey is
explicit that omitting the agent segments for shared scopes exists "so
that all agents in the tenant rendezvous on the same storage key" --
the sharing axis was never meant to relax the user boundary.

The user boundary had nothing enforcing it. op.UserId is client-supplied
and ValidateScopeSpec only checks it is non-empty, so any caller could
name another user and address their namespace directly. What stood in
for it was an ACL default-deny on the shared user scopes: a mitigation
at the wrong layer, and one that cannot tell a legitimate same-user read
from a cross-user one. It blocks both, which is why the shared scopes
could not be opened for durable per-user tool approvals without also
permitting cross-user access.

Under an on-behalf-of grant the subject IS the user, so the axis is
derivable rather than assertable: user_id is filled in when omitted and
must match the subject when supplied. A mismatch is denied and logged
with both ids.

Deliberately narrow. Callers acting under their OWN authority are
untouched -- platform-server writes per-user session state for the
browser's user and is bounded by its explicit kv_scope grants -- and
direct user principals cannot reach KV at all (the type gate in
HandleKVOperation), so OBO is the only path that could ever assert a
foreign user id.

Tests cover both shared and exclusive user scopes, derivation, non-user
scopes, non-user subjects, and the own-authority carve-out; verified to
fail without the pin. gateway/kv/acl suites pass.
A successful automatic reconnect returns nil from the receive-error handler. Continue the loop so Run services the replacement stream instead of reporting a false graceful exit. Cover both transport errors and graceful-disconnect signals.
@dbotwinick
dbotwinick merged commit e63a20e into main Aug 29, 2026
40 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant