@noodleseed/one 0.148.0 → 0.149.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/commands/assistant-ops.d.ts.map +1 -1
  2. package/dist/commands/assistant-ops.js +34 -0
  3. package/dist/commands/assistant-ops.js.map +1 -1
  4. package/dist/commands/assistant-surface-ops.d.ts +14 -1
  5. package/dist/commands/assistant-surface-ops.d.ts.map +1 -1
  6. package/dist/commands/assistant-surface-ops.js +33 -2
  7. package/dist/commands/assistant-surface-ops.js.map +1 -1
  8. package/dist/dev-local-runtime.d.ts +1 -0
  9. package/dist/dev-local-runtime.d.ts.map +1 -1
  10. package/dist/dev-local-runtime.js +3 -0
  11. package/dist/dev-local-runtime.js.map +1 -1
  12. package/node_modules/@noodle-borg/admission-limits/dist/counter-store.d.ts +20 -0
  13. package/node_modules/@noodle-borg/admission-limits/dist/counter-store.js +12 -0
  14. package/node_modules/@noodle-borg/admission-limits/dist/envelope.d.ts +34 -2
  15. package/node_modules/@noodle-borg/admission-limits/dist/envelope.js +35 -11
  16. package/node_modules/@noodle-borg/admission-limits/dist/in-memory-counter-store.d.ts +1 -0
  17. package/node_modules/@noodle-borg/admission-limits/dist/in-memory-counter-store.js +14 -1
  18. package/node_modules/@noodle-borg/admission-limits/dist/portable.d.ts +1 -0
  19. package/node_modules/@noodle-borg/admission-limits/dist/portable.js +1 -0
  20. package/node_modules/@noodle-borg/admission-limits/dist/visitor-bucket.d.ts +20 -0
  21. package/node_modules/@noodle-borg/admission-limits/dist/visitor-bucket.js +30 -0
  22. package/node_modules/@noodle-borg/agent-kit/dist/skill-tool-design-ref.js +4 -0
  23. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  24. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-guide.d.ts +12 -0
  25. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-guide.js +15 -0
  26. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-operator-view.d.ts +71 -0
  27. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-operator-view.js +59 -0
  28. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-store.d.ts +27 -0
  29. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-store.js +35 -0
  30. package/node_modules/@noodle-borg/assistant-gateway/dist/in-memory-embed-store.js +6 -0
  31. package/node_modules/@noodle-borg/assistant-gateway/dist/managed-spend.d.ts +80 -0
  32. package/node_modules/@noodle-borg/assistant-gateway/dist/managed-spend.js +186 -0
  33. package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.d.ts +16 -0
  34. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.d.ts +2 -0
  35. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.js +2 -0
  36. package/node_modules/@noodle-borg/assistant-gateway/dist/public-session.d.ts +14 -1
  37. package/node_modules/@noodle-borg/assistant-gateway/dist/public-session.js +23 -6
  38. package/node_modules/@noodle-borg/assistant-gateway/dist/public-turn.js +22 -0
  39. package/node_modules/@noodle-borg/assistant-gateway/dist/surface-budget.d.ts +15 -1
  40. package/node_modules/@noodle-borg/assistant-gateway/dist/surface-budget.js +14 -0
  41. package/node_modules/@noodle-borg/assistant-gateway/package.json +1 -1
  42. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-assistant.js +15 -1
  43. package/node_modules/@noodle-borg/module/dist/request-analytics.d.ts +19 -2
  44. package/node_modules/@noodle-borg/module/dist/request-analytics.js +15 -2
  45. package/node_modules/@noodle-borg/observability/dist/assistant-usage.js +68 -10
  46. package/node_modules/@noodle-borg/observability/dist/index.js +1 -0
  47. package/node_modules/@noodle-borg/observability/dist/request-event-query.js +48 -0
  48. package/node_modules/@noodle-borg/observability/dist/request-events-postgres.js +13 -2
  49. package/node_modules/@noodle-borg/observability/dist/request-events.js +3 -0
  50. package/node_modules/@noodle-borg/service/dist/routes/analytics.js +5 -29
  51. package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +57 -29
  52. package/node_modules/@noodle-borg/service/dist/routes/assistant-embeds.js +26 -38
  53. package/node_modules/@noodle-borg/service/dist/routes/assistant-model-binding.js +1 -4
  54. package/node_modules/@noodle-borg/service/dist/routes/assistant-public-session.js +7 -3
  55. package/node_modules/@noodle-borg/service/dist/routes/assistant.js +14 -8
  56. package/node_modules/@noodle-borg/service/package.json +1 -1
  57. package/node_modules/@noodle-borg/transport-http/dist/request-capture.js +2 -0
  58. package/node_modules/@noodleseed/assistant/package.json +1 -1
  59. package/package.json +2 -2
@@ -1,7 +1,8 @@
1
+ import { visitorBucket, } from '@noodle-borg/admission-limits/portable';
1
2
  import { isPublicEmbedId } from './embed-store.js';
2
3
  import { surfaceEnvelope } from './surface-budget.js';
3
- function refuse(status, code, message) {
4
- return { ok: false, status, code, message };
4
+ function refuse(status, code, message, embed) {
5
+ return { ok: false, status, code, message, ...(embed === undefined ? {} : { embed }) };
5
6
  }
6
7
  export async function mintPublicSession(request, envelope, ports) {
7
8
  if (typeof request.embedId !== 'string' || !isPublicEmbedId(request.embedId)) {
@@ -22,17 +23,33 @@ export async function mintPublicSession(request, envelope, ports) {
22
23
  return refuse(403, 'embed_not_found', 'embed is not available');
23
24
  const surface = await ports.resolveActiveSurface(embed);
24
25
  if (surface === undefined) {
25
- return refuse(409, 'surface_unavailable', 'assistant deployment is unavailable');
26
+ return refuse(409, 'surface_unavailable', 'assistant deployment is unavailable', embed);
26
27
  }
27
28
  // Origins come from the live artifact, so `noodle deploy` alone updates a pasted snippet.
28
29
  if (!surface.origins.includes(request.origin)) {
29
- return refuse(403, 'origin_not_allowed', 'origin is not allowed');
30
+ return refuse(403, 'origin_not_allowed', 'origin is not allowed', embed);
30
31
  }
31
32
  // The surface's own budget, which an operator may have lowered — or set to zero, which is the kill
32
33
  // switch. Resolved after the embed is known and before anything is spent.
33
34
  const budgetBounds = await ports.resolveBudgetBounds?.(embed);
34
35
  const surfaceLimits = surfaceEnvelope(envelope, embed, budgetBounds);
35
36
  const now = ports.now();
37
+ // Tier 4 before tier 3, which is before solvency. A visitor at their own hourly ceiling is refused
38
+ // without spending any of the shared address allowance, so one person behind a corporate NAT cannot
39
+ // consume the allowance the other three hundred are also using. An abuser who rotates the
40
+ // identifier simply never meets this tier and lands on the address bound below, which is the point:
41
+ // this is fairness, that is abuse control, and they are deliberately not the same limit.
42
+ const visitor = visitorBucket(request.visitorId);
43
+ if (visitor !== undefined) {
44
+ const perVisitor = await ports.counters.consume({
45
+ key: `mints:vis:${embed.embedId}:${visitor}`,
46
+ limit: surfaceLimits.mintsPerVisitorHour,
47
+ window: 'hour',
48
+ }, now);
49
+ if (!perVisitor.allowed) {
50
+ return refuse(429, 'visitor_session_budget_exhausted', 'assistant is unavailable right now', embed);
51
+ }
52
+ }
36
53
  // Fairness before solvency: an address at its hourly ceiling is refused without spending any of the
37
54
  // surface's day, so one visitor cannot burn the customer's budget on refusals.
38
55
  if (typeof request.addressBucket === 'string') {
@@ -42,14 +59,14 @@ export async function mintPublicSession(request, envelope, ports) {
42
59
  window: 'hour',
43
60
  }, now);
44
61
  if (!perAddress.allowed) {
45
- return refuse(429, 'address_session_budget_exhausted', 'assistant is unavailable right now');
62
+ return refuse(429, 'address_session_budget_exhausted', 'assistant is unavailable right now', embed);
46
63
  }
47
64
  }
48
65
  const mints = await ports.counters.consume({ key: `mints:${embed.embedId}`, limit: surfaceLimits.mintsPerDay }, now);
49
66
  if (!mints.allowed) {
50
67
  // Distinct from a hard error: the widget renders this calmly, and an operator sees a surface that
51
68
  // is switched off (limit 0) or spent, not a broken embed.
52
- return refuse(429, 'daily_session_budget_exhausted', 'assistant is unavailable right now');
69
+ return refuse(429, 'daily_session_budget_exhausted', 'assistant is unavailable right now', embed);
53
70
  }
54
71
  const session = await ports.createSession({
55
72
  embed,
@@ -125,6 +125,28 @@ export async function admitPublicTurn(request, defaults, ports) {
125
125
  message: 'assistant is unavailable right now',
126
126
  };
127
127
  }
128
+ // Platform spend last, when someone other than the customer is paying. Every counter here is
129
+ // all-or-nothing with no refund, so whichever runs first is charged for turns the ones after it
130
+ // refuse — and a session ending at its length limit is ordinary traffic, many times a day, while
131
+ // spend exhaustion is rare and for one day only. Charging the sponsor for every visitor's last
132
+ // turn is the worse of the two leaks, so this is the counter that goes last.
133
+ //
134
+ // It reports the surface's own exhaustion code on purpose: every published widget already treats
135
+ // `daily_turn_budget_exhausted` as final, and a new code would be read as retryable by all of them
136
+ // — precisely the load this ceiling exists to refuse. At the ladder's closing rung the surface
137
+ // ceiling is already zero, so that refusal arrives above; this is the backstop between rungs.
138
+ if (budgetBounds?.spend !== undefined) {
139
+ const { key, units, allowance } = budgetBounds.spend;
140
+ const spend = await ports.counters.consume({ key, limit: allowance, amount: units }, ports.now());
141
+ if (!spend.allowed) {
142
+ return {
143
+ ok: false,
144
+ status: 429,
145
+ code: 'daily_turn_budget_exhausted',
146
+ message: 'assistant is unavailable right now',
147
+ };
148
+ }
149
+ }
128
150
  return { ok: true, turnCount: session.turnCount };
129
151
  }
130
152
  export async function admitBridgeToolCall(request, defaults, ports) {
@@ -4,6 +4,20 @@ import type { PublicEmbedRecord } from './embed-store.js';
4
4
  export interface SurfaceBudgetBounds {
5
5
  readonly defaults: Pick<AdmissionEnvelope, 'turnsPerSession' | 'turnsPerDay' | 'mintsPerDay'>;
6
6
  readonly ceiling: Pick<AdmissionEnvelope, 'turnsPerDay' | 'mintsPerDay'>;
7
+ /**
8
+ * Platform spend accounting for this turn, present only when someone other than the customer is
9
+ * paying. Absent means customer-funded, and nothing is charged.
10
+ *
11
+ * `units` is what this turn costs at the tenant's current rung and `allowance` is its ceiling for
12
+ * the day; the ladder that produces both lives in `managed-spend.ts`. The ceilings above remain the
13
+ * primary mechanism — this counter is the exact backstop for the window in which a cached rung
14
+ * read still says "plenty left".
15
+ */
16
+ readonly spend?: {
17
+ readonly key: string;
18
+ readonly units: number;
19
+ readonly allowance: number;
20
+ };
7
21
  }
8
22
  /**
9
23
  * One surface's daily budget, resolved from the operator's override and the deployment's defaults.
@@ -17,5 +31,5 @@ export interface SurfaceBudgetBounds {
17
31
  * operator any more than by app code, so an over-eager `--turns-per-day 999999999` degrades to the
18
32
  * ceiling instead of becoming the ceiling. Zero survives clamping and is the kill switch.
19
33
  */
20
- export declare function surfaceEnvelope(base: AdmissionEnvelope, embed: Pick<PublicEmbedRecord, 'turnsPerDay' | 'mintsPerDay'>, bounds?: SurfaceBudgetBounds): AdmissionEnvelope;
34
+ export declare function surfaceEnvelope(base: AdmissionEnvelope, embed: Pick<PublicEmbedRecord, 'turnsPerDay' | 'mintsPerDay' | 'mintsPerAddressHour' | 'turnsPerAddressHour'>, bounds?: SurfaceBudgetBounds): AdmissionEnvelope;
21
35
  //# sourceMappingURL=surface-budget.d.ts.map
@@ -17,6 +17,20 @@ export function surfaceEnvelope(base, embed, bounds) {
17
17
  ...effectiveBase,
18
18
  ...(embed.turnsPerDay !== undefined ? { turnsPerDay: embed.turnsPerDay } : {}),
19
19
  ...(embed.mintsPerDay !== undefined ? { mintsPerDay: embed.mintsPerDay } : {}),
20
+ // The per-address bounds carry no hosted ceiling, because they govern abuse rather than spend —
21
+ // so the deployed default is what caps them, and an operator may only lower it. A surface that
22
+ // could raise its own abuse bound toward the structural maximum would be turning the bound off
23
+ // for everyone behind one address, which is not a budget an operator gets to spend.
24
+ ...(embed.mintsPerAddressHour !== undefined
25
+ ? {
26
+ mintsPerAddressHour: Math.min(embed.mintsPerAddressHour, effectiveBase.mintsPerAddressHour),
27
+ }
28
+ : {}),
29
+ ...(embed.turnsPerAddressHour !== undefined
30
+ ? {
31
+ turnsPerAddressHour: Math.min(embed.turnsPerAddressHour, effectiveBase.turnsPerAddressHour),
32
+ }
33
+ : {}),
20
34
  });
21
35
  if (bounds === undefined)
22
36
  return requested;
@@ -41,7 +41,7 @@
41
41
  "@noodle-borg/module": "0.0.0",
42
42
  "@noodle-borg/protocol": "0.0.0",
43
43
  "@noodle-borg/runtime": "0.0.0",
44
- "@noodleseed/assistant": "1.28.0",
44
+ "@noodleseed/assistant": "1.29.0",
45
45
  "zod": "^4.4.3"
46
46
  },
47
47
  "devDependencies": {
@@ -96,7 +96,7 @@ const EMBEDS_SUBCOMMAND = subcommand({
96
96
  const BUDGET_SUBCOMMAND = subcommand({
97
97
  ...SUBCOMMAND_FIELDS,
98
98
  name: 'budget',
99
- summary: 'Set what a public website surface may spend per day. Zero is the kill switch.',
99
+ summary: 'Set what a public website surface may spend, per day and per source address.',
100
100
  subcommands: [
101
101
  clientLeaf('set', 'Raise, lower, or switch off a public surface’s daily caps.', {
102
102
  ...LEAF_FIELDS,
@@ -115,6 +115,20 @@ const BUDGET_SUBCOMMAND = subcommand({
115
115
  value: '<count>',
116
116
  summary: 'Sessions this surface may open per UTC day. 0 stops it serving anyone.',
117
117
  }),
118
+ flag({
119
+ ...FLAG_FIELDS,
120
+ name: 'mints-per-address-hour',
121
+ type: 'string',
122
+ value: '<count>',
123
+ summary: 'Sessions one source address may open per hour. The abuse bound, not fairness.',
124
+ }),
125
+ flag({
126
+ ...FLAG_FIELDS,
127
+ name: 'turns-per-address-hour',
128
+ type: 'string',
129
+ value: '<count>',
130
+ summary: 'Model turns one source address may run per hour. The abuse bound, not fairness.',
131
+ }),
118
132
  flag({
119
133
  ...FLAG_FIELDS,
120
134
  name: 'surface',
@@ -5,6 +5,16 @@ export type RequestOutcome = 'ok' | 'tool_error' | 'mcp_error';
5
5
  export type RequestKind = 'usage' | 'discovery';
6
6
  /** How the session id was derived (stateless transport mints synthetic ids). */
7
7
  export type SessionSource = 'mcp' | 'synthetic' | 'none';
8
+ /**
9
+ * Which product surface the request arrived through. One vocabulary for the whole stream: the MCP
10
+ * endpoint, either embedded-assistant surface, and a browser agent calling through the WebMCP
11
+ * provider bridge ([ADR 0220](../../../docs/decisions/0220-webmcp-provider-bridge.md)). Attribution
12
+ * only — no code may read this to decide what a caller is allowed to do, because the bridge value
13
+ * originates in a client-declared marker.
14
+ */
15
+ export type RequestSurface = 'mcp' | 'assistant-public' | 'assistant-authenticated' | 'webmcp';
16
+ export declare const REQUEST_SURFACES: readonly ["mcp", "assistant-public", "assistant-authenticated", "webmcp"];
17
+ export declare function isRequestSurface(value: unknown): value is RequestSurface;
8
18
  /** Whether the caller was an anonymous or an authenticated identity (never the raw subject). */
9
19
  export type SubjectKind = 'anonymous' | 'authenticated';
10
20
  /**
@@ -31,6 +41,8 @@ export interface RequestEventInput {
31
41
  /** Bounded client family; always present in practice (`unknown` when nothing safe was supplied). */
32
42
  readonly clientFamily?: string;
33
43
  readonly accessMode?: AccessMode;
44
+ /** Originating surface; absent on rows written before schema v3, which read back as unknown. */
45
+ readonly surface?: RequestSurface;
34
46
  readonly subjectKind: SubjectKind;
35
47
  readonly method: string;
36
48
  readonly kind: RequestKind;
@@ -64,6 +76,7 @@ export interface RequestEventFilter {
64
76
  readonly outcome?: RequestOutcome;
65
77
  readonly toolName?: string;
66
78
  readonly clientName?: string;
79
+ readonly surface?: RequestSurface;
67
80
  readonly sessionId?: string;
68
81
  readonly since?: string;
69
82
  readonly until?: string;
@@ -75,6 +88,10 @@ export interface RequestEventSink {
75
88
  export interface RequestEventStore extends RequestEventSink {
76
89
  list(filter: RequestEventFilter): Promise<readonly RequestEvent[]>;
77
90
  }
78
- /** v2 (issue #1309): adds `clientFamily`, `queueMs`, `execMs`, and connector-attribution `details`. */
79
- export declare const REQUEST_EVENT_SCHEMA_VERSION = 2;
91
+ /**
92
+ * v2 (issue #1309): adds `clientFamily`, `queueMs`, `execMs`, and connector-attribution `details`.
93
+ * v3 (ADR 0220): adds `surface`. Additive and unbackfilled — a row at v2 or below simply has no
94
+ * surface, which a reader must render as unknown rather than as any particular surface.
95
+ */
96
+ export declare const REQUEST_EVENT_SCHEMA_VERSION = 3;
80
97
  //# sourceMappingURL=request-analytics.d.ts.map
@@ -1,3 +1,12 @@
1
+ export const REQUEST_SURFACES = [
2
+ 'mcp',
3
+ 'assistant-public',
4
+ 'assistant-authenticated',
5
+ 'webmcp',
6
+ ];
7
+ export function isRequestSurface(value) {
8
+ return REQUEST_SURFACES.includes(value);
9
+ }
1
10
  /**
2
11
  * Bounded client-family projection (issue #1309): a stable low-cardinality token derived from MCP
3
12
  * `clientInfo` when supplied, else from a closed table of known HTTP client user agents (`other`
@@ -5,6 +14,10 @@
5
14
  */
6
15
  export const CLIENT_FAMILY_UNKNOWN = 'unknown';
7
16
  export const CLIENT_FAMILY_OTHER = 'other';
8
- /** v2 (issue #1309): adds `clientFamily`, `queueMs`, `execMs`, and connector-attribution `details`. */
9
- export const REQUEST_EVENT_SCHEMA_VERSION = 2;
17
+ /**
18
+ * v2 (issue #1309): adds `clientFamily`, `queueMs`, `execMs`, and connector-attribution `details`.
19
+ * v3 (ADR 0220): adds `surface`. Additive and unbackfilled — a row at v2 or below simply has no
20
+ * surface, which a reader must render as unknown rather than as any particular surface.
21
+ */
22
+ export const REQUEST_EVENT_SCHEMA_VERSION = 3;
10
23
  //# sourceMappingURL=request-analytics.js.map
@@ -34,6 +34,40 @@ export function assistantTurnUsageRequestEvent(session, input) {
34
34
  ...(input.errorKind === undefined ? {} : { errorKind: input.errorKind }),
35
35
  });
36
36
  }
37
+ /**
38
+ * A mint refused before any session existed. There is no session id and no turn to count: the event
39
+ * carries the tenant it was refused for and the code that refused it, and nothing else.
40
+ */
41
+ export function assistantRefusedSessionUsageRequestEvent(input) {
42
+ return assistantUsageRequestEvent({
43
+ ...input.tenant,
44
+ deploymentId: input.deploymentId ?? '',
45
+ eventKind: 'session',
46
+ surface: 'public',
47
+ outcome: 'refused',
48
+ errorKind: input.code,
49
+ durationMs: input.durationMs,
50
+ });
51
+ }
52
+ /**
53
+ * Record a refused mint, when it belongs to a tenant.
54
+ *
55
+ * Only attributable refusals are recorded: an unknown or malformed embed id is prober noise and
56
+ * belongs in platform logs, not in a customer's usage. What an operator actually needs — the surface
57
+ * is out of budget, one address is at its hourly ceiling, the origin is not on the live allowlist —
58
+ * all know the embed by the time they refuse, which is why the decision lives here rather than at
59
+ * the call site: a route that forgets it produces silence, and silence reads as health.
60
+ */
61
+ export function captureRefusedAssistantSession(capture, refusal, durationMs) {
62
+ if (refusal.embed === undefined)
63
+ return;
64
+ const { org, app, env } = refusal.embed;
65
+ captureAssistantUsage(capture, assistantRefusedSessionUsageRequestEvent({
66
+ tenant: { org, app, env },
67
+ code: refusal.code,
68
+ durationMs,
69
+ }));
70
+ }
37
71
  export function assistantRefusedTurnUsageRequestEvent(session, errorKind, durationMs) {
38
72
  return assistantTurnUsageRequestEvent(session, {
39
73
  outcome: 'refused',
@@ -64,9 +98,11 @@ export function captureAssistantUsage(capture, event) {
64
98
  * credentials, and page context have no field to enter through.
65
99
  */
66
100
  export function assistantUsageRequestEvent(input) {
101
+ const refused = input.eventKind === 'session' ? input.outcome === 'refused' : false;
67
102
  const details = {
68
103
  eventKind: input.eventKind,
69
104
  surface: input.surface,
105
+ ...(refused ? { assistantOutcome: 'refused' } : {}),
70
106
  ...(input.modelSource === undefined ? {} : { modelSource: input.modelSource }),
71
107
  ...(input.eventKind === 'turn'
72
108
  ? {
@@ -90,26 +126,29 @@ export function assistantUsageRequestEvent(input) {
90
126
  env: input.env,
91
127
  deploymentId: input.deploymentId,
92
128
  requestId: randomUUID(),
93
- sessionId: input.sessionId,
129
+ ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }),
94
130
  sessionSource: 'synthetic',
95
131
  subjectKind: input.surface === 'public' ? 'anonymous' : 'authenticated',
96
132
  accessMode: input.surface === 'public' ? 'public' : 'authenticated',
133
+ // The shared stream vocabulary, not the assistant's own: `details.surface` keeps the internal
134
+ // spelling for the assistant's own aggregates, while this field is what the whole request
135
+ // stream groups by.
136
+ surface: input.surface === 'public' ? 'assistant-public' : 'assistant-authenticated',
97
137
  method: ASSISTANT_USAGE_METHOD,
98
138
  kind: 'usage',
99
- outcome: input.eventKind === 'session' || input.outcome === 'delivered'
100
- ? 'ok'
101
- : input.outcome === 'refused'
102
- ? 'tool_error'
139
+ outcome: input.outcome === 'refused'
140
+ ? 'tool_error'
141
+ : input.eventKind === 'session' || input.outcome === 'delivered'
142
+ ? 'ok'
103
143
  : 'mcp_error',
104
- ...(input.eventKind === 'turn' && input.errorKind !== undefined
105
- ? { errorKind: input.errorKind }
106
- : {}),
144
+ ...(input.errorKind === undefined ? {} : { errorKind: input.errorKind }),
107
145
  durationMs: finiteNonNegative(input.durationMs),
108
146
  details,
109
147
  };
110
148
  }
111
149
  export function aggregateAssistantUsage(events) {
112
150
  let minted = 0;
151
+ let refusedSessions = 0;
113
152
  let publicSessions = 0;
114
153
  let authenticatedSessions = 0;
115
154
  let delivered = 0;
@@ -125,7 +164,13 @@ export function aggregateAssistantUsage(events) {
125
164
  let noodleManaged = 0;
126
165
  let operator = 0;
127
166
  const latencies = [];
167
+ const refusalsByCode = new Map();
128
168
  const sessionDepth = new Map();
169
+ const countRefusal = (code) => {
170
+ if (code === undefined)
171
+ return;
172
+ refusalsByCode.set(code, (refusalsByCode.get(code) ?? 0) + 1);
173
+ };
129
174
  for (const event of events) {
130
175
  if (event.method !== ASSISTANT_USAGE_METHOD)
131
176
  continue;
@@ -133,6 +178,11 @@ export function aggregateAssistantUsage(events) {
133
178
  const eventKind = scalarString(details?.eventKind);
134
179
  const surface = scalarString(details?.surface);
135
180
  if (eventKind === 'session') {
181
+ if (scalarString(details?.assistantOutcome) === 'refused') {
182
+ refusedSessions += 1;
183
+ countRefusal(event.errorKind);
184
+ continue;
185
+ }
136
186
  minted += 1;
137
187
  if (surface === 'public')
138
188
  publicSessions += 1;
@@ -145,8 +195,10 @@ export function aggregateAssistantUsage(events) {
145
195
  const outcome = scalarString(details?.assistantOutcome);
146
196
  if (outcome === 'delivered')
147
197
  delivered += 1;
148
- else if (outcome === 'refused')
198
+ else if (outcome === 'refused') {
149
199
  refused += 1;
200
+ countRefusal(event.errorKind);
201
+ }
150
202
  else if (outcome === 'failed')
151
203
  failed += 1;
152
204
  else
@@ -174,7 +226,12 @@ export function aggregateAssistantUsage(events) {
174
226
  const depths = [...sessionDepth.values()].sort((a, b) => a - b);
175
227
  const sortedLatencies = latencies.sort((a, b) => a - b);
176
228
  return {
177
- sessions: { minted, public: publicSessions, authenticated: authenticatedSessions },
229
+ sessions: {
230
+ minted,
231
+ public: publicSessions,
232
+ authenticated: authenticatedSessions,
233
+ refused: refusedSessions,
234
+ },
178
235
  turns: {
179
236
  attempted: delivered + failed + refused,
180
237
  delivered,
@@ -197,6 +254,7 @@ export function aggregateAssistantUsage(events) {
197
254
  },
198
255
  tokens: { prompt, completion, reasoning, total },
199
256
  byModelSource: { noodleManaged, operator },
257
+ refusalsByCode: Object.fromEntries([...refusalsByCode].sort(([a], [b]) => a.localeCompare(b))),
200
258
  };
201
259
  }
202
260
  function scalarString(value) {
@@ -2,6 +2,7 @@ export * from './assistant-usage.js';
2
2
  export * from './intent-capture.js';
3
3
  export * from './request-event-buffer.js';
4
4
  export * from './request-event-metrics.js';
5
+ export * from './request-event-query.js';
5
6
  export * from './request-events.js';
6
7
  export * from './request-events-postgres.js';
7
8
  export * from './runtime.js';
@@ -0,0 +1,48 @@
1
+ import { isRequestSurface, REQUEST_SURFACES, } from '@noodle-borg/module';
2
+ /**
3
+ * Query parsing for the tenant-facing analytics event listing. It lives beside the store rather
4
+ * than in the HTTP route because the vocabulary it validates — outcomes, surfaces, the bounded page
5
+ * size — belongs to the analytics stream, not to one transport. The route keeps the HTTP concern:
6
+ * this returns a filter or a reason, and never a response.
7
+ */
8
+ /** Bounded page size: a tenant may ask for fewer, never for more. */
9
+ const MAX_REQUEST_EVENTS_LIMIT = 500;
10
+ const DEFAULT_LIMIT = 100;
11
+ const OUTCOMES = new Set(['ok', 'tool_error', 'mcp_error']);
12
+ export function parseRequestEventQuery(params, tenant) {
13
+ const limitParam = params.get('limit');
14
+ let limit = DEFAULT_LIMIT;
15
+ if (limitParam !== null) {
16
+ limit = Number(limitParam);
17
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_REQUEST_EVENTS_LIMIT) {
18
+ return {
19
+ ok: false,
20
+ error: `"limit" must be an integer from 1 to ${MAX_REQUEST_EVENTS_LIMIT}`,
21
+ };
22
+ }
23
+ }
24
+ const status = params.get('status') ?? undefined;
25
+ if (status !== undefined && !OUTCOMES.has(status)) {
26
+ return { ok: false, error: '"status" must be one of ok, tool_error, mcp_error' };
27
+ }
28
+ const surface = params.get('surface') ?? undefined;
29
+ if (surface !== undefined && !isRequestSurface(surface)) {
30
+ return { ok: false, error: `"surface" must be one of ${REQUEST_SURFACES.join(', ')}` };
31
+ }
32
+ const tool = params.get('tool') ?? undefined;
33
+ const client = params.get('client') ?? undefined;
34
+ return {
35
+ ok: true,
36
+ filter: {
37
+ org: tenant.org,
38
+ ...(tenant.app !== undefined ? { app: tenant.app } : {}),
39
+ ...(tenant.env !== undefined ? { env: tenant.env } : {}),
40
+ limit,
41
+ ...(status !== undefined ? { outcome: status } : {}),
42
+ ...(tool !== undefined ? { toolName: tool } : {}),
43
+ ...(client !== undefined ? { clientName: client } : {}),
44
+ ...(surface !== undefined ? { surface } : {}),
45
+ },
46
+ };
47
+ }
48
+ //# sourceMappingURL=request-event-query.js.map
@@ -31,6 +31,7 @@ export async function ensureRequestEventSchema(pool) {
31
31
  client_version text,
32
32
  client_family text,
33
33
  access_mode text,
34
+ surface text,
34
35
  subject_kind text NOT NULL,
35
36
  method text NOT NULL,
36
37
  kind text NOT NULL,
@@ -57,6 +58,12 @@ export async function ensureRequestEventSchema(pool) {
57
58
  ADD COLUMN IF NOT EXISTS client_family text,
58
59
  ADD COLUMN IF NOT EXISTS queue_ms double precision,
59
60
  ADD COLUMN IF NOT EXISTS exec_ms double precision
61
+ `);
62
+ // Schema v3 (ADR 0220): the originating surface. Additive and unbackfilled — existing rows keep
63
+ // a NULL surface and read back as unknown.
64
+ await pool.query(`
65
+ ALTER TABLE request_events
66
+ ADD COLUMN IF NOT EXISTS surface text
60
67
  `);
61
68
  await pool.query(`
62
69
  CREATE INDEX IF NOT EXISTS request_events_tenant_time_idx
@@ -97,10 +104,10 @@ export class PostgresRequestEventStore {
97
104
  await this.#pool.query(`INSERT INTO request_events
98
105
  (id, schema_version, created_at, org_slug, app_slug, environment, deployment_id,
99
106
  server_version, sdk_protocol_version, protocol_era, request_id, session_id, session_source,
100
- client_name, client_version, client_family, access_mode, subject_kind, method, kind,
107
+ client_name, client_version, client_family, access_mode, surface, subject_kind, method, kind,
101
108
  tool_name, resource_name, prompt_name, outcome, error_kind, duration_ms, queue_ms, exec_ms,
102
109
  output_tokens_est, country, details)
103
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31)`, [
110
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32)`, [
104
111
  randomUUID(),
105
112
  REQUEST_EVENT_SCHEMA_VERSION,
106
113
  this.#now(),
@@ -118,6 +125,7 @@ export class PostgresRequestEventStore {
118
125
  input.clientVersion ?? null,
119
126
  input.clientFamily ?? null,
120
127
  input.accessMode ?? null,
128
+ input.surface ?? null,
121
129
  input.subjectKind,
122
130
  input.method,
123
131
  input.kind,
@@ -153,6 +161,8 @@ export class PostgresRequestEventStore {
153
161
  add('tool_name', filter.toolName);
154
162
  if (filter.clientName !== undefined)
155
163
  add('client_name', filter.clientName);
164
+ if (filter.surface !== undefined)
165
+ add('surface', filter.surface);
156
166
  if (filter.sessionId !== undefined)
157
167
  add('session_id', filter.sessionId);
158
168
  if (filter.since !== undefined) {
@@ -203,6 +213,7 @@ function rowToEvent(row) {
203
213
  ...(row.client_version !== null ? { clientVersion: row.client_version } : {}),
204
214
  ...(row.client_family !== null ? { clientFamily: row.client_family } : {}),
205
215
  ...(row.access_mode !== null ? { accessMode: row.access_mode } : {}),
216
+ ...(row.surface !== null ? { surface: row.surface } : {}),
206
217
  ...(row.tool_name !== null ? { toolName: row.tool_name } : {}),
207
218
  ...(row.resource_name !== null ? { resourceName: row.resource_name } : {}),
208
219
  ...(row.prompt_name !== null ? { promptName: row.prompt_name } : {}),
@@ -28,6 +28,7 @@ function toRequestEvent(input) {
28
28
  ...(input.clientVersion !== undefined ? { clientVersion: input.clientVersion } : {}),
29
29
  ...(input.clientFamily !== undefined ? { clientFamily: input.clientFamily } : {}),
30
30
  ...(input.accessMode !== undefined ? { accessMode: input.accessMode } : {}),
31
+ ...(input.surface !== undefined ? { surface: input.surface } : {}),
31
32
  ...(input.toolName !== undefined ? { toolName: input.toolName } : {}),
32
33
  ...(input.resourceName !== undefined ? { resourceName: input.resourceName } : {}),
33
34
  ...(input.promptName !== undefined ? { promptName: input.promptName } : {}),
@@ -52,6 +53,8 @@ function matches(event, filter) {
52
53
  return false;
53
54
  if (filter.clientName !== undefined && event.clientName !== filter.clientName)
54
55
  return false;
56
+ if (filter.surface !== undefined && event.surface !== filter.surface)
57
+ return false;
55
58
  if (filter.sessionId !== undefined && event.sessionId !== filter.sessionId)
56
59
  return false;
57
60
  if (filter.since !== undefined && event.createdAt < filter.since)
@@ -1,4 +1,4 @@
1
- import { aggregateRequestEvents } from '@noodle-borg/observability';
1
+ import { aggregateRequestEvents, parseRequestEventQuery } from '@noodle-borg/observability';
2
2
  import { normalizedTimestamps, sendJson } from '../http-util.js';
3
3
  import { authorizeTenantControl, developerGrantRouteAccess } from './control-plane.js';
4
4
  /**
@@ -13,8 +13,6 @@ const WINDOWS_MS = new Map([
13
13
  ['7d', 7 * 24 * 60 * 60 * 1000],
14
14
  ['30d', 30 * 24 * 60 * 60 * 1000],
15
15
  ]);
16
- const OUTCOMES = new Set(['ok', 'tool_error', 'mcp_error']);
17
- const MAX_EVENTS_LIMIT = 500;
18
16
  /** Session replay cap: newest N of one session, rendered chronologically. */
19
17
  const SESSION_EVENTS_LIMIT = 1000;
20
18
  /** Aggregation reads a bounded slice; pre-aggregated rollups are the Stage-F scale path. */
@@ -60,33 +58,11 @@ export async function handleTenantEvents(req, res, gate, controlPlane, events, r
60
58
  const identity = await authorizeTenantControl(req, res, gate, controlPlane, ref.org, developerGrantRouteAccess(developerGrants, 'cloud:read'));
61
59
  if (identity === false)
62
60
  return;
63
- const limitParam = url.searchParams.get('limit');
64
- let limit = 100;
65
- if (limitParam !== null) {
66
- limit = Number(limitParam);
67
- if (!Number.isInteger(limit) || limit < 1 || limit > MAX_EVENTS_LIMIT) {
68
- return sendJson(res, 400, {
69
- error: `"limit" must be an integer from 1 to ${MAX_EVENTS_LIMIT}`,
70
- });
71
- }
72
- }
73
- const status = url.searchParams.get('status') ?? undefined;
74
- if (status !== undefined && !OUTCOMES.has(status)) {
75
- return sendJson(res, 400, { error: '"status" must be one of ok, tool_error, mcp_error' });
76
- }
77
- const tool = url.searchParams.get('tool') ?? undefined;
78
- const client = url.searchParams.get('client') ?? undefined;
79
- const filter = {
80
- org: ref.org,
81
- app: ref.app,
82
- env: ref.env,
83
- limit,
84
- ...(status !== undefined ? { outcome: status } : {}),
85
- ...(tool !== undefined ? { toolName: tool } : {}),
86
- ...(client !== undefined ? { clientName: client } : {}),
87
- };
61
+ const query = parseRequestEventQuery(url.searchParams, ref);
62
+ if (!query.ok)
63
+ return sendJson(res, 400, { error: query.error });
88
64
  // Store rejections surface as the dispatcher's 500 (validation above already handled 400s).
89
- return sendJson(res, 200, { ok: true, events: await events.list(filter) });
65
+ return sendJson(res, 200, { ok: true, events: await events.list(query.filter) });
90
66
  }
91
67
  export async function handleTenantSession(req, res, gate, controlPlane, events, ref, sessionId, developerGrants) {
92
68
  const identity = await authorizeTenantControl(req, res, gate, controlPlane, ref.org, developerGrantRouteAccess(developerGrants, 'cloud:read'));