Skip to content

Commit ce32af5

Browse files
committed
Remote register: fetch authoritative score/level from API (0.13.2)
Previously remoteRegister() returned a synthetic `{ level: 0 }` as a placeholder — the comment said "authoritative values arrive after first enforce()", but callers that cached the level (e.g. the Mastra processor's agentLevel field) then sent that 0 on every subsequent enforce. Rules gating on `agent_level >= N` fired incorrectly for higher-level agents until the process restarted. Fix: POST to /api/v1/agents on register() and use the API's real compositeScore / governanceLevel in the returned receipt. The API already deduplicates by id/name, so calling this against a pre-existing agent returns the authoritative record — no need for a separate lookup. Falls back to the old synthetic receipt when the API is unreachable or returns non-200, so register() still never throws for the caller. The next enforce() carries authoritative data either way.
1 parent b0f1ed5 commit ce32af5

3 files changed

Lines changed: 124 additions & 21 deletions

File tree

packages/governance/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "governance-sdk",
3-
"version": "0.13.1",
3+
"version": "0.13.2",
44
"description": "AI Agent Governance for TypeScript — policy enforcement, scoring, compliance, and audit for AI agents",
55
"type": "module",
66
"main": "./dist/index.js",

packages/governance/src/remote-enforce.test.ts

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -265,28 +265,47 @@ describe("remote enforce", () => {
265265

266266
// ─── Remote register ────────────────────────────────────────────
267267

268-
describe("remote register (local no-op)", () => {
268+
describe("remote register (POST /api/v1/agents)", () => {
269269
const config = { serverUrl: "https://api.example.com", apiKey: "test-key" };
270270

271-
test("returns synthetic result using agent name as ID", async () => {
271+
test("POSTs to /api/v1/agents and returns authoritative score + level", async () => {
272+
setupFetchMock(201, {
273+
id: "agent-abc",
274+
name: "my-agent",
275+
compositeScore: 72,
276+
governanceLevel: 3,
277+
status: "approved",
278+
});
272279
const remote = createRemoteEnforcer(config);
273280
const result = await remote.register({
274281
name: "my-agent",
275282
framework: "mastra",
276283
owner: "team-a",
277284
});
278285

279-
assert.equal(result.id, "my-agent");
280-
assert.equal(result.status, "registered");
286+
assert.equal(mockFetch.mock.calls.length, 1);
287+
const url = mockFetch.mock.calls[0].arguments[0];
288+
assert.equal(url, "https://api.example.com/api/v1/agents");
289+
assert.equal(result.id, "agent-abc");
290+
assert.equal(result.score, 72);
291+
assert.equal(result.level, 3);
292+
assert.equal(result.status, "approved");
281293
assert.equal(result.assessment.agentName, "my-agent");
282294
});
283295

284-
test("does not call fetch — registration is handled by enforce endpoint", async () => {
285-
setupFetchMock(200, {});
296+
test("falls back to synthetic receipt when cloud is unreachable", async () => {
297+
setupFetchMock(500, {});
286298
const remote = createRemoteEnforcer(config);
287-
await remote.register({ name: "a", framework: "mastra", owner: "t" });
299+
const result = await remote.register({
300+
name: "my-agent",
301+
framework: "mastra",
302+
owner: "team-a",
303+
});
288304

289-
assert.equal(mockFetch.mock.calls.length, 0);
305+
// Non-200 — we fall through so register never throws on the caller.
306+
assert.equal(result.id, "my-agent");
307+
assert.equal(result.status, "registered");
308+
assert.equal(result.level, 0);
290309
});
291310
});
292311

@@ -320,8 +339,14 @@ describe("createGovernance remote integration", () => {
320339
assert.equal(mockFetch.mock.calls.length, 1);
321340
});
322341

323-
test("register returns synthetic result when serverUrl is set (no fetch)", async () => {
324-
setupFetchMock(200, {});
342+
test("register POSTs to /api/v1/agents when serverUrl is set", async () => {
343+
setupFetchMock(201, {
344+
id: "agent-xyz",
345+
name: "test",
346+
compositeScore: 55,
347+
governanceLevel: 2,
348+
status: "approved",
349+
});
325350

326351
const gov = createGovernance({
327352
serverUrl: "https://api.example.com",
@@ -334,10 +359,15 @@ describe("createGovernance remote integration", () => {
334359
owner: "team",
335360
});
336361

337-
// Register is a local no-op — API auto-registers on enforce
338-
assert.equal(result.id, "test");
339-
assert.equal(result.status, "registered");
340-
assert.equal(mockFetch.mock.calls.length, 0);
362+
// Register now fetches authoritative score/level from the API rather
363+
// than returning a synthetic level: 0 placeholder.
364+
assert.equal(result.id, "agent-xyz");
365+
assert.equal(result.level, 2);
366+
assert.equal(mockFetch.mock.calls.length, 1);
367+
assert.equal(
368+
mockFetch.mock.calls[0].arguments[0],
369+
"https://api.example.com/api/v1/agents",
370+
);
341371
});
342372

343373
test("local methods still work when serverUrl is set", async () => {

packages/governance/src/remote-enforce.ts

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -178,15 +178,88 @@ export function createRemoteEnforcer(config: RemoteConfig) {
178178
}
179179

180180
/**
181-
* In cloud mode, this returns a SYNTHETIC confirmation. There is no
182-
* dedicated remote register endpoint — the API auto-registers agents
183-
* on the first `enforce()` call. The returned `score` / `level` are
184-
* placeholder zeros; authoritative values arrive after first enforce.
181+
* Register (or look up) an agent against the cloud API.
185182
*
186-
* If you need a registration receipt before any enforcement happens,
187-
* use local mode (no `serverUrl`) or call the cloud REST API directly.
183+
* POSTs to `/api/v1/agents` with the registration payload. The API
184+
* auto-dedupes by id/name, so calling this on a pre-existing agent
185+
* is idempotent — it returns the existing record's authoritative
186+
* score + level. This fixes the previous placeholder behaviour where
187+
* remoteRegister returned `level: 0` unconditionally, which caused
188+
* agent_level-conditioned rules to fire incorrectly for higher-level
189+
* agents on every enforce().
190+
*
191+
* If the cloud call fails for any reason (network, auth, 5xx), we
192+
* still return a synthetic "registered" receipt so the caller isn't
193+
* blocked on a non-essential register step. The next enforce() will
194+
* carry authoritative data regardless.
188195
*/
189196
async function remoteRegister(input: AgentRegistration): Promise<RemoteRegisterResult> {
197+
try {
198+
const response = await fetch(`${baseUrl}/api/v1/agents`, {
199+
method: "POST",
200+
headers: {
201+
"Content-Type": "application/json",
202+
"Authorization": `Bearer ${apiKey}`,
203+
},
204+
body: JSON.stringify({
205+
id: input.id,
206+
name: input.name,
207+
framework: input.framework,
208+
owner: input.owner,
209+
description: input.description,
210+
tools: input.tools,
211+
channels: input.channels,
212+
hasAuth: input.hasAuth,
213+
hasGuardrails: input.hasGuardrails,
214+
hasObservability: input.hasObservability,
215+
hasAuditLog: input.hasAuditLog,
216+
}),
217+
signal: AbortSignal.timeout(timeout),
218+
});
219+
if (response.ok) {
220+
const data = await response.json() as {
221+
id?: string;
222+
name?: string;
223+
compositeScore?: number;
224+
governanceLevel?: number;
225+
status?: string;
226+
};
227+
const id = data.id ?? input.name;
228+
const score = typeof data.compositeScore === "number" ? data.compositeScore : 0;
229+
// Clamp to the valid GovernanceLevel range (0-4). The API is the
230+
// source of truth here, but we validate defensively.
231+
const rawLevel = typeof data.governanceLevel === "number" ? data.governanceLevel : 0;
232+
const level = (rawLevel >= 0 && rawLevel <= 4
233+
? Math.round(rawLevel)
234+
: 0) as 0 | 1 | 2 | 3 | 4;
235+
const status: "registered" | "assessed" | "approved" | "flagged" | "deprecated" | "quarantined" =
236+
data.status === "assessed" || data.status === "approved" ||
237+
data.status === "flagged" || data.status === "deprecated" ||
238+
data.status === "quarantined"
239+
? data.status
240+
: "registered";
241+
return {
242+
id,
243+
score,
244+
level,
245+
status,
246+
assessment: {
247+
agentId: id,
248+
agentName: data.name ?? input.name,
249+
compositeScore: score,
250+
level: { level, label: "live", autonomy: "governed", minScore: 0, maxScore: 100 },
251+
status,
252+
dimensions: [],
253+
recommendations: [],
254+
assessedAt: new Date().toISOString(),
255+
},
256+
};
257+
}
258+
// 409 / 4xx — fall through to the synthetic receipt. The next
259+
// enforce() is still authoritative.
260+
} catch {
261+
// Network/timeout — same fall-through.
262+
}
190263
return {
191264
id: input.name,
192265
score: 0,

0 commit comments

Comments
 (0)