Skip to content

AA: pkg/policy rule engine, LISTEN/NOTIFY wake-up, atryum_report_stuck - #171

Draft
cachafla wants to merge 22 commits into
aa/extension-pointsfrom
claude/aa-policy-cf868a
Draft

cachafla wants to merge 22 commits into
aa/extension-pointsfrom
claude/aa-policy-cf868a

Conversation

@cachafla

@cachafla cachafla commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #170 (aa/extension-points). Open-source pieces Agent Authority needs next.

pkg/policy: the rule engine

API exactly as agreed with AA governance: Action, Cond, Rule, Call, Judge, Result, Evaluate(ctx, firm, agent, call, judge). Added constants ActionAllow, ActionNotify, ActionDeny, ActionHuman, ActionJudge, plus ContainsSecret(v any) bool for facts.contains_secret.

  • Tool and Server are |-separated globs; * matches anything; an empty Server matches any server.
  • First match per level decides. A judge error (or a nil judge) sends the call to human. An unknown verdict, meaning "" or anything other than allow/deny/human, keeps scanning that level.
  • The stricter level wins (deny > human > notify > allow), and on a tie the firm result stands. No match at either level means human.
  • A rule with an unknown action fails safe to human. A missing field never satisfies a condition.
  • The tests port the prototype's firm rules, every published charter's rules and all REPLAY_SHAPES, and they agree with the recorded decisions. They also check the k8s v5 draft flips.

Wake held calls on any replica (Postgres)

InvocationRepo.UpdateResultFrom sends pg_notify('atryum_invocation_decided', id) in the same transaction when it moves a row out of pending_approval. Each process keeps one pgx connection on LISTEN (store.ListenForDecisions) and reconnects every 5s after an error, waking all its waiters on reconnect. A held call registers before its row goes pending. Polling stays as the fallback: every 2s with Postgres, and every 250ms on SQLite as before. The ponytail: gap comment is gone. The remaining gap, resuming a held call after its process dies, keeps its own comment.

atryum_report_stuck (behind WithStuckReports())

⚠️ Named atryum_report_stuck, not atryum.report_stuck. Anthropic rejects dotted tool names, and Atryum's other synthetic tools already use underscores for this reason. The proxy offers it in tools/list, with or without an external decider. A call records an invocation of the tool, and the event hook sees invocation.created and then invocation.stuck_reported. It never reaches the decider or an upstream. task and boundary are required.

Resolving a held call from an embedder

atryum.WithResolvePending(func(atryum.ResolvePending)) hands the embedder ResolvePending(ctx, tx *sql.Tx, invocationID string, approve bool, actor, reason string) error once the service is built. It is the same compare-and-set path Approve and Deny use, and they now delegate to Service.ResolvePending. With a non-nil tx, the decision, its event row, the event hook and the NOTIFY all join the embedder's transaction and commit with it; a nil tx lets Atryum commit on its own. It also keeps the ai_escalated_* approval statuses. A call that is no longer pending returns an error wrapping atryum.ErrNotPending. To support this, store.InTx joins a transaction already in ctx (store.WithTx), and InvocationRepo.Get reads through that transaction.

Review round 1 (commits 3d2ad4d to c52d3ab)

  • ⚠️ Semantic change in pkg/policy. If every matching rule in a level is a judge that gave no verdict, that level's result is now human (Rule = the last deferring judge). Before, the level counted as no match, so a deferring firm judge handed the call to the agent level. This matches Atryum's own engine and the prototype.
  • Numbers format without an exponent, so 1e6 no longer becomes "1e+06". All Go integer, unsigned and float types and numeric strings compare as numbers, in takes any slice or array, and the coercion rules are documented on Cond. Compiled globs are cached, and the docs note that Result.Rule points into the caller's slice.
  • ContainsSecret changes:
    • New detections: prefixed key names (DB_PASSWORD=, OPENAI_API_KEY=, aws_secret_access_key), URL credentials, sk-, github_pat_, bare JWTs and PGP keys.
    • Now ignored: booleans, nulls, masks, placeholders, values under 8 characters and names that only refer to a secret.
    • AKIA/ASIA is case-sensitive, and input that can't be inspected counts as a secret.
  • Listener changes:
    • Held calls poll every 2s only while connected, otherwise every 250ms (DecisionWaker reports Connected()).
    • The connection is pinged after 10 quiet seconds, reconnect warnings log at most once a minute, and wake-ups after a reconnect are spread over a second.
    • RecordExecution cancelled now uses compare-and-set, so it also notifies.
    • The PgBouncer transaction-mode limit is documented.
  • Stuck report changes:
    • It has a new status, reported, and the hook sees only invocation.stuck_reported (no invocation.created), written in one transaction with the row.
    • The server must be configured, fields are capped (task and boundary 1 KB, detail 4 KB, other arguments dropped), and the agent gets a generic error instead of database text.
    • The UI labels the new status.
  • The Postgres pool now defaults to 20 open, 10 idle, 5 min idle and 30 min lifetime. It's overridable with [server.database_pool] and atryum.WithDBPool(atryum.DBPool{...}).

Review round 2 (d648985, 7486f88, 4b2adc5)

  • ContainsSecret now matches key names by whole words, split at camelCase, _, -, . and /. A secret word only counts when the words after it also describe a value (key, access, value…). So secretName, secretKeyRef, secretRef, existingSecret, tlsSecretName, Reloader annotations, nextPageToken and secretary no longer trigger it. A realistic Deployment fixture must not trigger, and a Secret with a stringData password must.
  • New catches: aws_secret_access_key VALUE, --password VALUE, mysql -pX, curl -u user:pass and a bare token key with a long value.
  • ResolvePending docs now cover rolling back on any error, staying on Atryum's database and schema, and REPEATABLE READ/SERIALIZABLE. A resolve that loses the race (SQLSTATE 40001 on the compare-and-set) returns ErrNotPending.
  • New config key server.database_listen = false skips the LISTEN connection, for PgBouncer in transaction mode. Held calls then poll every 250ms.
  • The compiled-glob cache starts over past 4096 patterns.

Managed Agents start order (9fa8401, gofmt in 6283bb2)

The bridge now records running before sending allow to Anthropic.

  • If recording fails or the embedder's hook refuses it, Claude gets a deny with the reason instead, the call is recorded as cancelled, and a warning is logged.
  • A replay re-sends the allow for a call already executing, or the deny for one cancelled at start.
  • Standalone Atryum without a hook still allows the call, just in the new order.
  • Tests run the real invocation service on SQLite: a hook that refuses invocation.executing must lead to a deny and never an allow, running must be recorded before any allow, and a replay after a failed send must re-send the allow and record running only once.

Idempotency keys scoped per agent (8ea0af7, security)

  • Migration 032 (030 before the Nibz/claude/api keys #168 rebase) replaces the global unique index on idempotency_key with one on (COALESCE(agent_id, ''), idempotency_key).
  • Invoke and Submit look the key up under the identity the invocation is recorded with: the verified agent, else the agent_id a Submit caller declares, else one anonymous scope.
  • A retry of the same request (path, server, tool, and input compared as JSON) returns the original invocation. A different request returns invocation.ErrIdempotencyConflict (HTTP 409) and reveals nothing about the earlier call. Another agent's key never finds that invocation; that agent gets its own.
  • A lost insert race gets the same answer instead of a raw unique-index error.
  • Managed Agents now looks up its keys under the session's agent.
  • Tests: cross-agent reuse, an identical retry, different input, tool or path, both Invoke and Submit, the declared agent_id without auth, 409 on both HTTP routes, and the index on SQLite and Postgres.
  • Follow-up (221027a, 76b7285):
    • Inputs compare with UseNumber, so integers beyond 2^53 stay distinct.
    • A new test sends 16 concurrent identical submits across two service instances and gets one invocation, on SQLite and Postgres.
    • Migration 030 builds its Postgres index without CONCURRENTLY; the migration comment and CHANGELOG say so and explain how to pre-build it.

Rebased onto #168 via the new #170 — 15 October

  • Migrations renumbered: 030_idempotency_per_agent is now 032_idempotency_per_agent (Nibz/claude/api keys #168 owns 030 and 031).
  • Name-checked runner (7cc83fd): the migration runner now checks each recorded migration's name as well as its number. A database migrated at AA's old pin records 30 as 030_idempotency_per_agent. It now fails at startup with an error that says to recreate it, instead of silently skipping Nibz/claude/api keys #168's 030. Every released version since v0.0.2 kept its names, so released databases pass. Tested on SQLite and Postgres.
  • Recorded auth (19971d8): ToolCall.Auth and Invocation.Auth (atryum.Auth) carry the issuer, the method (jwt, api_key, or no_auth for a self-declared agent ID) and the key ID. Migration 033_invocation_auth adds auth_issuer, auth_method and auth_key_id to invocations. Tests cover a JWT, an API key, the no-auth hint, a declared agent_id and an anonymous caller, checking the decider, the hook and the stored row.
  • Grants: Atryum's migrations set no grants.
  • The commit AA pinned before the rebase stays fetchable as the tag aa/pin-76b7285.

Review of the rebase (0dbfb68)

  • Managed Agents calls now record Auth{Method: "managed_agent", Issuer: "anthropic:<workspace>", KeyID: <session ID>}. The identity is built from the bridge's own session registration and set on its own context, so no request field can set it. Before, these calls recorded no_auth.
  • Stuck reports now record the reporting caller's Auth.
  • Case-insensitive bearer scheme: with agent keys off, the atr_ refusal now compares the Bearer scheme case-insensitively. Before, bearer atr_… passed as anonymous when no IdP was configured.
  • Tests cover all three, and each fails with its fix removed.

Testing

  • go test ./... passes.
  • On SQLite, a resolve rolled back in the embedder's transaction leaves the call held, a committed one releases it, and a second resolve returns ErrNotPending.
  • With ATRYUM_POSTGRES_TESTS=1, the new two-replica test passes. Replica B now resolves inside its own transaction, and the test checks that the hook got that transaction against local Postgres 16. Its holder polls once an hour, so only the notification can wake it in time. With pg_notify disabled, the test fails.

🤖 Generated with Claude Code

cachafla and others added 21 commits October 15, 2026 08:45
Firm and agent rules, first match per level, strictest level wins, judge
rules with fail-to-human, and a minimal secret-pattern fact helper. Tests
port the AA MVP prototype's rules and replay shapes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recording a decision on a pending_approval row sends NOTIFY
atryum_invocation_decided in the same transaction; each process LISTENs on
one connection and wakes its held call at once. Polling stays as the
fallback (every 2s with Postgres; SQLite keeps 250ms polling).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An agent reports that it cannot finish within its authority (task, boundary,
detail). The report is recorded as an invocation of the tool, the event hook
sees invocation.stuck_reported, and nothing is authorized or proxied. The
tool is named with an underscore, like Atryum's other synthetic tools,
because Anthropic rejects dotted tool names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pkg/atryum.WithResolvePending hands the embedder ResolvePending(ctx, tx, id,
approve, actor, reason), backed by Service.ResolvePending, the path Approve
and Deny now share. store.InTx joins a transaction already in ctx
(store.WithTx), so the decision, its event, the event hook and the NOTIFY
commit with the embedder's writes. A call that is no longer pending returns
an error wrapping ErrNotPending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Every matching rule in a level being a judge with no verdict now makes
  that level human instead of no match, so a deferring firm judge can no
  longer hand the call to a laxer agent rule.
- Floats format without exponent (1e6 was "1e+06" and missed eq/in/prefix
  rules on amounts); all Go integer, unsigned and float types and numeric
  strings compare as numbers; in accepts any slice or array.
- Compiled glob patterns are cached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Catches prefixed key names (DB_PASSWORD=, OPENAI_API_KEY=,
aws_secret_access_key), URL credentials, sk- keys, github_pat_ tokens, bare
JWTs and PGP private keys. Ignores booleans, nulls, masks, placeholders,
values under 8 characters and names that only refer to a secret; AKIA/ASIA
is case-sensitive. Input that cannot be inspected counts as a secret.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Held calls poll every 2s only while the listener is connected, 250ms
  otherwise (DecisionWaker now reports Connected).
- The listening connection is pinged after 10 quiet seconds, so a dead
  connection is caught in seconds; reconnect warnings log at most once a
  minute; the re-check after a reconnect is spread over a second.
- RecordExecution cancelled moves the row compare-and-set from the status it
  read, which also announces a pending call leaving pending_approval.
- Document that PgBouncer in transaction mode cannot deliver notifications.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stuck report now has its own status, reported, and the event hook sees
one invocation.stuck_reported change written with the row (no
invocation.created), so embedders and dashboards don't count it as an
action. The MCP path's server must be configured, task and boundary are
capped at 1 KB and detail at 4 KB, other arguments are dropped, and the
agent gets a generic error instead of database text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20 open and 10 idle connections, idle ones closed after 5 minutes, each
replaced after 30 (it was unbounded, which exhausted connections under
load). Overridable with [server.database_pool] and pkg/atryum.WithDBPool;
set fields win, the option over the config file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Names are split at camelCase, _, -, . and /, and hold a secret only when a
secret word is followed by value words alone, so secretName, secretKeyRef,
secretRef, existingSecret, tlsSecretName, Reloader annotations and
secretary no longer trip facts.contains_secret on ordinary Kubernetes
manifests (now a fixture). Also catches aws_secret_access_key VALUE,
--password VALUE, mysql -pSECRET, curl -u user:pass and a bare token key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Pending's transaction rules

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
database_listen = false skips the Postgres LISTEN connection, for
PgBouncer in transaction mode; held calls then poll every 250 ms. The
compiled-glob cache starts over past 4096 patterns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bridge sent allow to Anthropic first and then recorded running,
debug-logging a refusal, so a start the embedder refused (an expired or
used-up lease) still ran. It now records running first and sends allow
only if that succeeds; otherwise it records the call cancelled, sends a
deny with the reason and logs a warning. A replay re-sends the allow for a
call already running, or the deny for one cancelled at start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A key used to return whatever invocation held it: another agent sending
the same key received that invocation's status, approval and result, and
a retry with a different tool or input silently got the old call.

Keys are now unique per agent (migration 030 replaces the global unique
index with one on COALESCE(agent_id, ''), idempotency_key) and looked up
under the identity the invocation is recorded with. A same-agent retry of
the same request (path, server, tool, input as JSON) returns the original;
a different request returns ErrIdempotencyConflict (HTTP 409) and reveals
nothing; another agent's key never finds that invocation. A lost insert
race is answered the same way. The Managed Agents bridge looks its keys
up under the session's agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lock

- sameJSON decodes with UseNumber, so inputs differing only in an integer
  beyond 2^53 no longer count as the same request.
- 16 concurrent identical submits by one agent, across two service
  instances, make one invocation on SQLite and Postgres (fails without
  lostCreateRace).
- Migration 030 builds its Postgres index without CONCURRENTLY; the
  migration comment and CHANGELOG say so and how to pre-build it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#168 took migrations 030 and 031, so the idempotency migration is now 032
(renamed during the rebase). The runner now also checks each recorded
migration's name: a database migrated at AA's old pin records 30 as
030_idempotency_per_agent and would otherwise skip #168's 030 silently.
It now fails at startup with an error that says to recreate the database.
Every released version kept its name, so released databases pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ToolCall.Auth and Invocation.Auth carry the issuer, the method (jwt,
api_key, or no_auth for a self-declared agent ID) and the API key's id,
so a decider can tell an IdP token from an Atryum agent key now that #168
admits both. Migration 033 stores them on invocations (auth_issuer,
auth_method, auth_key_id); the store now keeps one column list for every
invocation query. pkg/atryum exports the Auth alias.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cachafla
cachafla force-pushed the aa/extension-points branch from e479127 to 4e3d884 Compare October 15, 2026 15:51
@cachafla
cachafla force-pushed the claude/aa-policy-cf868a branch from 76b7285 to e5a985c Compare October 15, 2026 15:51
- Calls from a Managed Agents session the bridge registered are submitted
  with an identity built from that registration: Method managed_agent,
  Issuer anthropic:<workspace>, KeyID the session ID. Before, they recorded
  no_auth, which a decider could not tell from a self-declared agent ID.
  Request input cannot set it.
- Stuck reports record the reporting caller's Auth (it was empty).
- The atr_ refusal with agent keys off compares the Bearer scheme
  case-insensitively, so 'bearer atr_...' no longer passes as anonymous.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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