@intentius/chant-lexicon-aws 0.44.10 → 0.44.13

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.
@@ -0,0 +1,589 @@
1
+ /**
2
+ * Render an AgentCore session history as dogwood replay-trace text (#1685).
3
+ *
4
+ * This module is pure: normalized events in, text out, no transport and no
5
+ * filesystem. It exists separately from `./trace-fetch.ts` for two reasons.
6
+ * The Bedrock AgentCore observability surface is in preview and moving, so the
7
+ * thing most likely to be rewritten is the fetch, and the grammar it renders
8
+ * into is the thing least likely to move. And the line grammar has two traps in
9
+ * it that are worth testing byte-for-byte without a mock HTTP stack in the way.
10
+ *
11
+ * ## The grammar
12
+ *
13
+ * From the #1657 verification (§6, read from
14
+ * `dogwood-language/src/interpreter/log_parse.rs` at the pinned SHA
15
+ * `5063bcc2d6d6cf5024d1b0498e6cc8ef52cbcf0c`), one event per line:
16
+ *
17
+ * ```
18
+ * @<timestamp> [scope(principal: <uid>, resource: <uid>)]
19
+ * [entities(<uid>: { <attrs> } [in [<uid>, …]], …)]
20
+ * [request_context(<group>: { … }, …)]
21
+ * <Ns>::Action::"<Name>"::<kind>(<field>: <value>, …)
22
+ * ```
23
+ *
24
+ * The three envelopes are optional and must appear in that order; the trailing
25
+ * group is the logged record. Blank lines are skipped and there is no comment
26
+ * syntax, so a `//` inside a value survives.
27
+ *
28
+ * ## The two traps
29
+ *
30
+ * 1. **`request_context(…)` and the logged record are different bags.** The
31
+ * Cedar request is built from the first, temporal predicates match against
32
+ * the second, and a field supplied to only one weakens either the Cedar
33
+ * check or the temporal check while the replay still exits 0 with a verdict
34
+ * that looks authoritative. So every payload group an event carries is
35
+ * written to *both*, always — there is no per-event opt-out here, because an
36
+ * AgentCore history has no way to express "this field is deliberately
37
+ * Cedar-invisible". A decision-kind event with no payload at all is a
38
+ * refusal, not a shrug: see {@link renderAgentCoreTrace}.
39
+ * 2. **Actions must be fully qualified.** `AgentCore::Action::"Transfer"`,
40
+ * never `Transfer` — a short name leaves the temporal predicate unmatched
41
+ * while Cedar still authorizes. A bare tool name from the history is
42
+ * qualified here; an already-qualified one is validated and passed through.
43
+ *
44
+ * ## Decoupling
45
+ *
46
+ * Nothing here imports the cedar lexicon, and the cedar lexicon imports nothing
47
+ * from here. The contract between them is the text. The value and event types
48
+ * below are deliberately shaped so an object built here is *structurally*
49
+ * assignable to the cedar side's `TraceValue`/`TraceEvent` for anyone who wants
50
+ * that, but neither package depends on the other to get it, and the rendering
51
+ * is implemented twice on purpose. Where a choice was free — the space inside
52
+ * `{ … }`, the `, ` between fields, the order of the record's own injections —
53
+ * it matches `lexicons/cedar/src/dogwood/trace.ts` byte for byte, so a trace
54
+ * fetched from AWS and a trace built by hand look the same to a reader and to
55
+ * the parser.
56
+ */
57
+
58
+ /* ── Values ───────────────────────────────────────────────────────────────── */
59
+
60
+ /** `Ns::Type::"id"` in value position. */
61
+ export interface AgentCoreEntityRef {
62
+ readonly traceValue: "entity";
63
+ readonly uid: string;
64
+ }
65
+
66
+ /** `1.50` — Cedar's decimal surface form, which a JS number cannot carry. */
67
+ export interface AgentCoreDecimal {
68
+ readonly traceValue: "decimal";
69
+ readonly text: string;
70
+ }
71
+
72
+ /** Surface text passed through untouched — the escape hatch, used sparingly. */
73
+ export interface AgentCoreRaw {
74
+ readonly traceValue: "raw";
75
+ readonly text: string;
76
+ }
77
+
78
+ /** A tagged value: one rendered specially rather than by JS type. */
79
+ export type AgentCoreTagged = AgentCoreEntityRef | AgentCoreDecimal | AgentCoreRaw;
80
+
81
+ /**
82
+ * Which objects are *really* tagged values.
83
+ *
84
+ * Identity, not shape. A tagged value renders as surface text — an `entity`
85
+ * becomes a bare uid, a `raw` becomes its `text` verbatim — so if membership
86
+ * were decided by a `traceValue` key, any agent that wrote
87
+ * `{"traceValue": "raw", "text": "…"}` into a payload could inject arbitrary
88
+ * unescaped text into both bags of the trace: a forged `callerPrincipal`, an
89
+ * unbalanced paren, a whole extra field. The payloads this module reads are
90
+ * written by the agent under observation, which is the last party that should
91
+ * get to decide what its own trace says. So only the three constructors below
92
+ * confer the tag, and {@link renderValue} refuses a record that merely looks
93
+ * tagged rather than rendering it either way.
94
+ */
95
+ const TAGGED = new WeakSet<object>();
96
+
97
+ function tag<T extends AgentCoreTagged>(value: T): T {
98
+ TAGGED.add(value);
99
+ return value;
100
+ }
101
+
102
+ /** Anything that can sit in a trace field, in Cedar surface forms. */
103
+ export type AgentCoreTraceValue =
104
+ | string
105
+ | number
106
+ | boolean
107
+ | AgentCoreTagged
108
+ | readonly AgentCoreTraceValue[]
109
+ | { readonly [key: string]: AgentCoreTraceValue };
110
+
111
+ /** A named group of fields — what one `request_context` envelope entry holds. */
112
+ export type AgentCoreFields = { readonly [key: string]: AgentCoreTraceValue };
113
+
114
+ /** `Ns::Type::"id"`, `Ns::Action::"Name"` — a fully qualified uid. */
115
+ const QUALIFIED_UID = /^[A-Za-z_][A-Za-z0-9_]*(::[A-Za-z_][A-Za-z0-9_]*)+::"[^"]*"$/;
116
+ /** A bare field, group, namespace or event-kind name. */
117
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
118
+
119
+ /**
120
+ * A history that cannot become an honest trace.
121
+ *
122
+ * Thrown rather than worked around. Every case this covers is one where the
123
+ * alternative is a trace that replays green and proves less than it looks like
124
+ * it proves, which is the failure mode the whole module is arranged against.
125
+ */
126
+ export class AgentCoreTraceError extends Error {
127
+ constructor(
128
+ message: string,
129
+ /** 0-based position in the event list, when one event is to blame. */
130
+ readonly index?: number,
131
+ ) {
132
+ super(message);
133
+ this.name = "AgentCoreTraceError";
134
+ }
135
+ }
136
+
137
+ function assertUid(value: string, what: string, index?: number): string {
138
+ if (!QUALIFIED_UID.test(value)) {
139
+ throw new AgentCoreTraceError(
140
+ `agentcore trace: ${what} must be fully qualified, like Ns::Type::"id" — got "${value}"`,
141
+ index,
142
+ );
143
+ }
144
+ return value;
145
+ }
146
+
147
+ function assertIdent(value: string, what: string, index?: number): string {
148
+ if (!IDENT.test(value)) {
149
+ throw new AgentCoreTraceError(`agentcore trace: ${what} must be an identifier — got "${value}"`, index);
150
+ }
151
+ return value;
152
+ }
153
+
154
+ /** `Ns::Type::"id"`. Validated at construction, so a short name cannot slip through. */
155
+ export function agentCoreEntityRef(uid: string): AgentCoreEntityRef {
156
+ return tag({ traceValue: "entity", uid: assertUid(uid, "an entity reference") });
157
+ }
158
+
159
+ /**
160
+ * A Cedar decimal, written as it should appear (`"1.50"`).
161
+ *
162
+ * `context` names the field it came from when there is one, so a payload that
163
+ * cannot be represented says *where* rather than only *what*.
164
+ */
165
+ export function agentCoreDecimal(text: string, context?: string): AgentCoreDecimal {
166
+ if (!/^-?\d+\.\d+$/.test(text)) {
167
+ const where = context ? ` at ${context}` : "";
168
+ throw new AgentCoreTraceError(`agentcore trace: a decimal looks like "1.50" — got "${text}"${where}`);
169
+ }
170
+ return tag({ traceValue: "decimal", text });
171
+ }
172
+
173
+ /** Surface text, rendered verbatim. For values this module has no shape for. */
174
+ export function agentCoreRaw(text: string): AgentCoreRaw {
175
+ return tag({ traceValue: "raw", text });
176
+ }
177
+
178
+ function isTagged(value: AgentCoreTraceValue): value is AgentCoreTagged {
179
+ return typeof value === "object" && value !== null && TAGGED.has(value);
180
+ }
181
+
182
+ /** The five escapes a Cedar string literal needs. Mirrors the cedar lexicon's. */
183
+ function escapeString(value: string): string {
184
+ return value
185
+ .replace(/\\/g, "\\\\")
186
+ .replace(/"/g, '\\"')
187
+ .replace(/\n/g, "\\n")
188
+ .replace(/\r/g, "\\r")
189
+ .replace(/\t/g, "\\t");
190
+ }
191
+
192
+ /** Render one value in the Cedar surface form the trace parser reads. */
193
+ export function renderValue(value: AgentCoreTraceValue): string {
194
+ if (typeof value === "string") return `"${escapeString(value)}"`;
195
+ if (typeof value === "boolean") return value ? "true" : "false";
196
+ if (typeof value === "number") {
197
+ if (!Number.isInteger(value)) {
198
+ throw new AgentCoreTraceError(
199
+ `agentcore trace: ${String(value)} is not an integer — a decimal must be written with agentCoreDecimal("1.50") so its scale survives`,
200
+ );
201
+ }
202
+ // Beyond 2^53 a JS number has already lost the digits an i64 would keep,
203
+ // and `String(1e21)` is `"1e+21"`, which is not a Cedar integer literal at
204
+ // all. Either way the trace would carry a number that is not the number the
205
+ // agent saw.
206
+ if (!Number.isSafeInteger(value)) {
207
+ throw new AgentCoreTraceError(
208
+ `agentcore trace: ${String(value)} is outside the range a JS number represents exactly, so the trace would carry a different number than the history did — carry it as a string`,
209
+ );
210
+ }
211
+ return String(value);
212
+ }
213
+ if (Array.isArray(value)) return `[${value.map(renderValue).join(", ")}]`;
214
+ if (isTagged(value)) return value.traceValue === "entity" ? value.uid : value.text;
215
+ if ("traceValue" in value) {
216
+ // See TAGGED. Rendering it as a record would be a lie about what the agent
217
+ // wrote; rendering it as a tagged value would let the agent write its own
218
+ // trace. Neither is on offer.
219
+ throw new AgentCoreTraceError(
220
+ `agentcore trace: a payload carries a "traceValue" field, which is the marker this module uses for entity refs, decimals and raw surface text. ` +
221
+ "Rendering it either way would let the observed agent decide what its own trace says, so it is refused — rename the field at the source, " +
222
+ "or build the value with agentCoreEntityRef/agentCoreDecimal/agentCoreRaw.",
223
+ );
224
+ }
225
+ return renderFields(value as AgentCoreFields);
226
+ }
227
+
228
+ /** `{ a: 1, b: "x" }` — a record body, braces included. */
229
+ export function renderFields(fields: AgentCoreFields): string {
230
+ const parts = Object.entries(fields).map(
231
+ ([key, value]) => `${assertIdent(key, "a trace field name")}: ${renderValue(value)}`,
232
+ );
233
+ return `{ ${parts.join(", ")} }`;
234
+ }
235
+
236
+ /* ── Normalized events ────────────────────────────────────────────────────── */
237
+
238
+ /**
239
+ * One decision-relevant thing that happened in an AgentCore session, in the
240
+ * shape the fetch normalizes to and the renderer reads.
241
+ *
242
+ * This is deliberately *not* the dogwood event shape. It is the semantic shape
243
+ * of an agent session — who acted, on what, with which payload — so that a
244
+ * change to the observability surface under preview churn is a change to the
245
+ * mapping in `./trace-fetch.ts` and not to the grammar below.
246
+ */
247
+ export interface AgentCoreSessionEvent {
248
+ /** When it happened, epoch **milliseconds**. Converted to the trace's i64 here. */
249
+ readonly timeMs: number;
250
+ /** The session it belongs to. Lands in the logged record as `sessionId`. */
251
+ readonly sessionId: string;
252
+ /** Unique within the session. Lands in the logged record as `requestId`. */
253
+ readonly eventId: string;
254
+ /**
255
+ * The dogwood event kind. `request` / `response` / `error` conventionally;
256
+ * the truth is whichever kinds the project's `.dwschema` marks `decision`,
257
+ * and that file is not visible from here.
258
+ */
259
+ readonly kind: string;
260
+ /**
261
+ * The tool or operation. A bare name (`"Transfer"`) is qualified with the
262
+ * namespace; an already-qualified `Ns::Action::"Name"` is validated and used
263
+ * as-is. Never rendered short.
264
+ */
265
+ readonly action: string;
266
+ /** Who acted — an actor id, or a fully qualified uid to override the type. */
267
+ readonly actor: string;
268
+ /** What was acted on — a runtime/gateway id, or a fully qualified uid. */
269
+ readonly target: string;
270
+ /** The call's input payload. Lands in both bags as the `input` group. */
271
+ readonly input?: AgentCoreFields;
272
+ /** The call's result. Lands in both bags as the `output` group. */
273
+ readonly output?: AgentCoreFields;
274
+ /** A failure's detail. Lands in both bags as the `error` group. */
275
+ readonly error?: AgentCoreFields;
276
+ /**
277
+ * Anything the source carried that has no field of its own. Lands in both
278
+ * bags as the `attributes` group.
279
+ */
280
+ readonly attributes?: AgentCoreFields;
281
+ }
282
+
283
+ /** How `@<i64>` is derived from `timeMs`. */
284
+ export type AgentCoreTimeOrigin =
285
+ /** Epoch seconds. Absolute, comparable across sessions, large numbers. */
286
+ | "epoch-seconds"
287
+ /** Seconds since the earliest event, so the first line reads `@0`. */
288
+ | "relative-seconds";
289
+
290
+ /** What {@link renderAgentCoreTrace} takes. */
291
+ export interface AgentCoreTraceOptions {
292
+ /** Cedar namespace for actions and entity types. Default `"AgentCore"`. */
293
+ readonly namespace?: string;
294
+ /** Entity type for `actor`. Default `"Actor"`. */
295
+ readonly principalType?: string;
296
+ /** Entity type for `target`. Default `"Runtime"`. */
297
+ readonly resourceType?: string;
298
+ /** Default `"epoch-seconds"`. */
299
+ readonly origin?: AgentCoreTimeOrigin;
300
+ /**
301
+ * Kinds that produce a decision, and so build a Cedar request. Default
302
+ * `["request"]`, the convention the default event schema follows. A
303
+ * history-only event never becomes a Cedar request, so an absent payload on
304
+ * one is not a weakening.
305
+ */
306
+ readonly decisionKinds?: readonly string[];
307
+ /** Weakenings to tolerate. Empty by default — see {@link renderAgentCoreTrace}. */
308
+ readonly allow?: readonly AgentCoreTraceIssueKind[];
309
+ }
310
+
311
+ /** What {@link auditAgentCoreEvents} can find. */
312
+ export type AgentCoreTraceIssueKind =
313
+ /** A decision-kind event with no payload: the Cedar request carries no context. */
314
+ | "no-request-context"
315
+ /** Two events in one session sharing an id: `requestId` stops identifying anything. */
316
+ | "duplicate-event-id";
317
+
318
+ /** One finding against a normalized history. */
319
+ export interface AgentCoreTraceIssue {
320
+ readonly kind: AgentCoreTraceIssueKind;
321
+ /** 0-based position in the *sorted* event list. */
322
+ readonly index: number;
323
+ readonly timeMs: number;
324
+ readonly message: string;
325
+ }
326
+
327
+ const DEFAULTS = {
328
+ namespace: "AgentCore",
329
+ principalType: "Actor",
330
+ resourceType: "Runtime",
331
+ origin: "epoch-seconds" as AgentCoreTimeOrigin,
332
+ decisionKinds: ["request"] as readonly string[],
333
+ };
334
+
335
+ /** A payload group name → the field on the normalized event. Order is the render order. */
336
+ const GROUPS = ["input", "output", "error", "attributes"] as const;
337
+
338
+ function payloadGroups(event: AgentCoreSessionEvent): Record<string, AgentCoreFields> {
339
+ const out: Record<string, AgentCoreFields> = {};
340
+ for (const group of GROUPS) {
341
+ const fields = event[group];
342
+ if (fields && Object.keys(fields).length > 0) out[group] = fields;
343
+ }
344
+ return out;
345
+ }
346
+
347
+ /**
348
+ * `Ns::Action::"Name"` from a bare tool name, or a qualified action validated.
349
+ *
350
+ * The fully-qualified requirement is the second §6 trap: a short name leaves
351
+ * every temporal predicate unmatched while Cedar still authorizes, so the
352
+ * replay is green and blind.
353
+ */
354
+ export function qualifyAction(action: string, namespace: string, index?: number): string {
355
+ if (action.includes("::")) return assertUid(action, "a trace action", index);
356
+ if (action.length === 0 || action.includes('"')) {
357
+ throw new AgentCoreTraceError(
358
+ `agentcore trace: a bare action name cannot be empty or contain a quote — got "${action}"`,
359
+ index,
360
+ );
361
+ }
362
+ return `${assertIdent(namespace, "a namespace", index)}::Action::"${action}"`;
363
+ }
364
+
365
+ /** `Ns::Type::"id"` from a bare id, or a qualified uid validated. */
366
+ export function qualifyUid(id: string, namespace: string, type: string, what: string, index?: number): string {
367
+ if (id.includes("::")) return assertUid(id, what, index);
368
+ if (id.length === 0 || id.includes('"')) {
369
+ throw new AgentCoreTraceError(
370
+ `agentcore trace: ${what} cannot be empty or contain a quote — got "${id}"`,
371
+ index,
372
+ );
373
+ }
374
+ return `${assertIdent(namespace, "a namespace", index)}::${assertIdent(type, "an entity type", index)}::"${id}"`;
375
+ }
376
+
377
+ /**
378
+ * Order the history the way the interpreter reads it.
379
+ *
380
+ * dogwood accumulates history in *file* order, so an out-of-order line changes
381
+ * what a temporal window sees. A fetch that pages backwards (CloudWatch Logs
382
+ * hands out the newest first) would otherwise produce a trace whose windows are
383
+ * quietly wrong, so ordering is settled here rather than being a caller's
384
+ * problem. Ties keep their original relative order.
385
+ */
386
+ function sortByTime(events: readonly AgentCoreSessionEvent[]): AgentCoreSessionEvent[] {
387
+ return events.map((event, index) => ({ event, index }))
388
+ .sort((a, b) => a.event.timeMs - b.event.timeMs || a.index - b.index)
389
+ .map(({ event }) => event);
390
+ }
391
+
392
+ function assertWellFormed(event: AgentCoreSessionEvent, index: number): void {
393
+ if (typeof event.timeMs !== "number" || !Number.isFinite(event.timeMs)) {
394
+ throw new AgentCoreTraceError(
395
+ `agentcore trace: event ${index} has no usable timestamp (${String(event.timeMs)}) — a trace timepoint is an i64 and there is no honest default for a missing one`,
396
+ index,
397
+ );
398
+ }
399
+ for (const [field, value] of [
400
+ ["sessionId", event.sessionId],
401
+ ["eventId", event.eventId],
402
+ ["actor", event.actor],
403
+ ["target", event.target],
404
+ ["action", event.action],
405
+ ["kind", event.kind],
406
+ ] as const) {
407
+ if (typeof value !== "string" || value.length === 0) {
408
+ throw new AgentCoreTraceError(
409
+ `agentcore trace: event ${index} has no ${field} — the history is malformed, and a trace with a guessed ${field} replays green while proving nothing`,
410
+ index,
411
+ );
412
+ }
413
+ }
414
+ }
415
+
416
+ /**
417
+ * The weakening checks, applied to a normalized history.
418
+ *
419
+ * Everything here is something that makes a replay *weaker* rather than
420
+ * something that makes it fail, which is exactly the class a green replay run
421
+ * hides. Structural malformation throws instead: see {@link AgentCoreTraceError}.
422
+ */
423
+ export function auditAgentCoreEvents(
424
+ events: readonly AgentCoreSessionEvent[],
425
+ options: AgentCoreTraceOptions = {},
426
+ ): AgentCoreTraceIssue[] {
427
+ const decisionKinds = new Set(options.decisionKinds ?? DEFAULTS.decisionKinds);
428
+ const issues: AgentCoreTraceIssue[] = [];
429
+ const seen = new Set<string>();
430
+
431
+ sortByTime(events).forEach((event, index) => {
432
+ assertWellFormed(event, index);
433
+ const at = { index, timeMs: event.timeMs };
434
+
435
+ if (decisionKinds.has(event.kind) && Object.keys(payloadGroups(event)).length === 0) {
436
+ issues.push({
437
+ ...at,
438
+ kind: "no-request-context",
439
+ message: `${event.action}::${event.kind} carries no input, output, error or attributes, so its request_context envelope would be empty and every context.* test in a policy silently misses`,
440
+ });
441
+ }
442
+
443
+ const key = `${event.sessionId}${event.eventId}`;
444
+ if (seen.has(key)) {
445
+ issues.push({
446
+ ...at,
447
+ kind: "duplicate-event-id",
448
+ message: `session ${event.sessionId} reports eventId "${event.eventId}" twice, so requestId no longer identifies a decision point and an expectation written against one addresses both`,
449
+ });
450
+ }
451
+ seen.add(key);
452
+ });
453
+
454
+ return issues;
455
+ }
456
+
457
+ /* ── Rendering ────────────────────────────────────────────────────────────── */
458
+
459
+ /** One line's worth: the envelopes and the logged record, fully resolved. */
460
+ export interface AgentCoreTraceLine {
461
+ readonly timestamp: number;
462
+ readonly action: string;
463
+ readonly kind: string;
464
+ readonly scope: { readonly principal: string; readonly resource: string };
465
+ readonly requestContext: AgentCoreFields;
466
+ readonly record: AgentCoreFields;
467
+ }
468
+
469
+ /**
470
+ * Resolve one normalized event into the envelopes and the logged record.
471
+ *
472
+ * Both bags get every payload group; the record additionally gets the event
473
+ * schema's own injections (`callerPrincipal`, `callerResource`, `sessionId`,
474
+ * `requestId`), which are never part of the Cedar request. Field order matches
475
+ * the §6 example line: groups first, then the injections.
476
+ */
477
+ export function toTraceLine(
478
+ event: AgentCoreSessionEvent,
479
+ timestamp: number,
480
+ options: AgentCoreTraceOptions = {},
481
+ index?: number,
482
+ ): AgentCoreTraceLine {
483
+ const namespace = options.namespace ?? DEFAULTS.namespace;
484
+ const principal = qualifyUid(
485
+ event.actor,
486
+ namespace,
487
+ options.principalType ?? DEFAULTS.principalType,
488
+ "an actor",
489
+ index,
490
+ );
491
+ const resource = qualifyUid(
492
+ event.target,
493
+ namespace,
494
+ options.resourceType ?? DEFAULTS.resourceType,
495
+ "a target",
496
+ index,
497
+ );
498
+ const groups = payloadGroups(event);
499
+
500
+ return {
501
+ timestamp,
502
+ action: qualifyAction(event.action, namespace, index),
503
+ kind: assertIdent(event.kind, "an event kind", index),
504
+ scope: { principal, resource },
505
+ requestContext: groups,
506
+ record: {
507
+ ...groups,
508
+ callerPrincipal: agentCoreEntityRef(principal),
509
+ callerResource: agentCoreEntityRef(resource),
510
+ sessionId: event.sessionId,
511
+ requestId: event.eventId,
512
+ },
513
+ };
514
+ }
515
+
516
+ /** One line as the trace parser reads it. Never ends in a newline. */
517
+ export function renderTraceLine(line: AgentCoreTraceLine): string {
518
+ if (!Number.isSafeInteger(line.timestamp)) {
519
+ throw new AgentCoreTraceError(`agentcore trace: a timepoint is an i64 — got ${String(line.timestamp)}`);
520
+ }
521
+
522
+ const parts = [`@${line.timestamp}`];
523
+ parts.push(`scope(principal: ${line.scope.principal}, resource: ${line.scope.resource})`);
524
+
525
+ const context = Object.entries(line.requestContext).map(
526
+ ([group, value]) => `${assertIdent(group, "a request-context group")}: ${renderValue(value)}`,
527
+ );
528
+ if (context.length > 0) parts.push(`request_context(${context.join(", ")})`);
529
+
530
+ const record = Object.entries(line.record).map(
531
+ ([key, value]) => `${assertIdent(key, "a trace field name")}: ${renderValue(value)}`,
532
+ );
533
+ parts.push(`${line.action}::${line.kind}(${record.join(", ")})`);
534
+
535
+ return parts.join(" ");
536
+ }
537
+
538
+ /** A rendered trace, and what the audit tolerated on the way. */
539
+ export interface AgentCoreTrace {
540
+ /** The text, one event per line, newline-terminated. */
541
+ readonly text: string;
542
+ /** The resolved lines, in render order. */
543
+ readonly lines: readonly AgentCoreTraceLine[];
544
+ /** Findings the caller allowed. Empty unless `allow` named something. */
545
+ readonly issues: readonly AgentCoreTraceIssue[];
546
+ }
547
+
548
+ /**
549
+ * Render a normalized AgentCore history as dogwood trace text.
550
+ *
551
+ * The inversion of the §6 traps: every payload group lands in both bags, every
552
+ * action comes out fully qualified, the history is ordered the way the
553
+ * interpreter reads it, and a history that would produce a weakened trace
554
+ * throws instead. Naming a kind in `allow` is how a caller says the weakening
555
+ * is the point — the alternative was a trace that replays green and proves
556
+ * less than it looks like it proves.
557
+ */
558
+ export function renderAgentCoreTrace(
559
+ events: readonly AgentCoreSessionEvent[],
560
+ options: AgentCoreTraceOptions = {},
561
+ ): AgentCoreTrace {
562
+ const sorted = sortByTime(events);
563
+ // The audit runs `assertWellFormed` over the same sorted list, so a
564
+ // structurally broken history throws here before anything is rendered.
565
+ const issues = auditAgentCoreEvents(sorted, options);
566
+ const allow = new Set(options.allow ?? []);
567
+ const blocking = issues.filter((issue) => !allow.has(issue.kind));
568
+ if (blocking.length > 0) {
569
+ const detail = blocking.map((i) => ` [${i.kind}] event ${i.index}: ${i.message}`).join("\n");
570
+ const kinds = [...new Set(blocking.map((i) => `"${i.kind}"`))].join(", ");
571
+ throw new AgentCoreTraceError(
572
+ `agentcore trace: this history would weaken its own replay rather than fail it:\n${detail}\n` +
573
+ `Fix the history, or pass { allow: [${kinds}] } to say the weakening is the point.`,
574
+ );
575
+ }
576
+
577
+ const origin = options.origin ?? DEFAULTS.origin;
578
+ const base = origin === "relative-seconds" && sorted.length > 0 ? (sorted[0]?.timeMs ?? 0) : 0;
579
+ const lines = sorted.map((event, index) =>
580
+ toTraceLine(event, Math.floor((event.timeMs - base) / 1000), options, index),
581
+ );
582
+
583
+ // An empty history renders as empty text, not as a bare newline: dogwood
584
+ // skips blank lines, so the two replay identically, and "" is the honest
585
+ // answer to "what did this session do". Whether an empty trace is worth
586
+ // replaying is the caller's call — see `trace-fetch.ts`'s `requireEvents`.
587
+ const text = lines.length > 0 ? lines.map(renderTraceLine).join("\n") + "\n" : "";
588
+ return { text, lines, issues };
589
+ }
@@ -135,8 +135,14 @@ function regionScope(
135
135
  * string has a slot for one; it borrows the same `us-east-1` default that
136
136
  * {@link serviceUrl} already used to build the host, so the signature agrees
137
137
  * with the endpoint it is sent to.
138
+ *
139
+ * Exported because this decision — sign, or carry the scope and no signature —
140
+ * belongs to the lexicon's read transport rather than to any one API on it.
141
+ * `agentcore/trace-fetch.ts` reads `bedrock-agentcore` through the same seam,
142
+ * and a second copy of this would be a second place for the emulator carve-out
143
+ * to drift.
138
144
  */
139
- function requestHeaders(
145
+ export function requestHeaders(
140
146
  service: string,
141
147
  url: string,
142
148
  body: string,
@@ -74,9 +74,51 @@ describe("observeByIdentity (#1647)", () => {
74
74
  expect(queried.assets).toBeDefined();
75
75
  });
76
76
 
77
- test("an emulator without Cloud Control keeps the absent verdict — never a hole, or pre-first-apply plans stop proposing create", async () => {
77
+ test("an emulator without Cloud Control at all keeps the absent verdict — never a hole, or pre-first-apply plans stop proposing create", async () => {
78
78
  const { resources } = await observeByIdentity(["assets"], bucket, {}, {
79
- http: async () => ccError("UnsupportedOperation", "Operation GetResource is not supported."),
79
+ http: async () => ccError("UnsupportedOperation", "not supported"),
80
+ });
81
+ expect(resources.assets).toBeUndefined();
82
+ });
83
+
84
+ // Floci serves ListResources but answers UnsupportedOperation for
85
+ // GetResource (read-client's own note) — and the emulator is where the carve
86
+ // walkthrough films the observe beat. Verified against Floci 1.5.34 on the
87
+ // behold demo: the list leg is what turns the miss into a read.
88
+ test("GetResource-unsupported falls back to ListResources and matches the identifier (Floci)", async () => {
89
+ const targets: string[] = [];
90
+ const { resources } = await observeByIdentity(["assets"], bucket, {}, {
91
+ http: async (_url, init) => {
92
+ const target = (init.headers as Record<string, string>)["x-amz-target"] ?? "";
93
+ targets.push(target);
94
+ if (target.endsWith("GetResource")) return ccError("UnsupportedOperation", "Operation GetResource is not supported.");
95
+ return {
96
+ status: 200,
97
+ text: JSON.stringify({
98
+ ResourceDescriptions: [
99
+ { Identifier: "some-other-bucket", Properties: JSON.stringify({ BucketName: "some-other-bucket" }) },
100
+ { Identifier: "acme-platform-assets-prod", Properties: JSON.stringify({ BucketName: "acme-platform-assets-prod" }) },
101
+ ],
102
+ }),
103
+ };
104
+ },
105
+ });
106
+ expect(targets.some((t) => t.endsWith("ListResources"))).toBe(true);
107
+ expect(resources.assets).toMatchObject({
108
+ type: "AWS::S3::Bucket",
109
+ physicalId: "acme-platform-assets-prod",
110
+ status: "EXTERNAL",
111
+ ownership: "foreign",
112
+ });
113
+ });
114
+
115
+ test("the list leg missing the identifier keeps the absent verdict", async () => {
116
+ const { resources } = await observeByIdentity(["assets"], bucket, {}, {
117
+ http: async (_url, init) => {
118
+ const target = (init.headers as Record<string, string>)["x-amz-target"] ?? "";
119
+ if (target.endsWith("GetResource")) return ccError("UnsupportedOperation", "not supported");
120
+ return { status: 200, text: JSON.stringify({ ResourceDescriptions: [] }) };
121
+ },
80
122
  });
81
123
  expect(resources.assets).toBeUndefined();
82
124
  });