sentinelayer-cli 0.43.0 → 0.44.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.
package/README.md CHANGED
@@ -164,11 +164,13 @@ sl session action <session-id> working_on --target-sequence <n>
164
164
  ```
165
165
 
166
166
  For a room that requires agent admission, the agent declares its goal, requested
167
- actions, and TTL. The CLI creates an agent-held Ed25519 identity, waits for a
168
- human decision, then waits for the minted AIdenID email and verified signed
169
- purpose receipt. Only after that evidence is ready does it claim the approved
170
- grant with proof of possession and join. The private key and scoped credential
171
- are stored locally and are never printed:
167
+ actions, and TTL. The CLI creates an agent-held Ed25519 identity and waits for a
168
+ human decision. When the API reports identity readiness on the approved
169
+ admission, the CLI also waits for the minted AIdenID email and verified signed
170
+ purpose receipt before it claims; when the API does not report it, the approved
171
+ grant is claimed directly. The claim uses proof of possession, then the agent
172
+ joins. The private key and scoped credential are stored locally and are never
173
+ printed:
172
174
 
173
175
  ```bash
174
176
  # Agent: request and wait for the owner's decision (maximum grant TTL is 24h).
@@ -190,20 +192,27 @@ sl session access approve <session-id> <admission-id> \
190
192
  # sl session access revoke <session-id> <admission-id> --reason "work complete"
191
193
  ```
192
194
 
193
- Approval queues the AIdenID email and purpose-receipt work. `session access
194
- list --json` reports the real `email`, `purpose`, and Jev status; it does not
195
- invent successful issuance while those jobs are pending or unavailable, and
196
- the API withholds the claim challenge until both identity artifacts are ready.
195
+ `session access list --json` reports the `email`, `purpose`, and Jev status the
196
+ API returns; the CLI does not invent successful issuance. On an API that gates
197
+ the claim on identity evidence, approval queues the AIdenID email and
198
+ purpose-receipt work and the claim challenge is withheld until both are ready.
199
+ On an API that does not, approval issues the claim challenge directly.
197
200
  The agent must re-run `session join` with the same goal, scope, and TTL after an
198
201
  asynchronous request so it can claim and verify its live session-bound receipt.
199
- New rooms default to fail-closed `required` mode. Existing rooms retain their
200
- stored `legacy` mode until an owner explicitly sets `required`; the mode command
201
- is the activation step for those pre-existing rooms.
202
+ A room's admission mode is whatever the API stored for it; `session access list`
203
+ shows it, and an owner changes it with `session access mode <session-id>
204
+ required` (or `legacy`).
205
+
206
+ The owner actions (`approve`, `deny`, `revoke`, `mode`) run on the owner's own
207
+ session. They are unavailable in an agent context, meaning `SENTINELAYER_AGENT_ID`
208
+ is set or agent admission credentials are stored on the machine; owners use the
209
+ web dashboard from such machines. `session access` commands are not exposed
210
+ through the MCP CLI bridge.
202
211
 
203
212
  Sentinelayer includes a deterministic session coordination surface for multi-agent coding loops:
204
213
 
205
214
  - session event stream and replay (`start`, `join`, `say`, `read`, `status`, `leave`, `list`, `kill`)
206
- - low-noise message actions (`react ack|like|dislike`, `action working_on|disregard`, `reply`/`comment`, `actions`) plus one monotonic per-agent read cursor (`view`)
215
+ - low-noise message actions (`react ack|like|dislike|unlike|undislike`, `action working_on|disregard`, `reply`/`comment`, `actions`) plus one monotonic per-agent read cursor (`view`)
207
216
  - agent lifecycle controls (join/ephemeral TTL presence/leave/kill); listener liveness never enters the durable transcript
208
217
  - recap and context briefing for late-joining agents
209
218
  - analytics + lineage artifacts at session closeout
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -111,6 +111,11 @@ import {
111
111
  currentAdmittedAgent,
112
112
  withAgentAdmission,
113
113
  } from "../session/admission-auth.js";
114
+ import {
115
+ SESSION_REACTION_TYPES,
116
+ isSessionReactionType,
117
+ submitSessionReaction,
118
+ } from "../session/reactions.js";
114
119
  import {
115
120
  claimTicket,
116
121
  listTickets,
@@ -564,10 +569,16 @@ const SESSION_MESSAGE_ACTION_TYPES = new Set([
564
569
  "reply",
565
570
  "like",
566
571
  "dislike",
572
+ "unlike",
573
+ "undislike",
567
574
  "disregard",
568
575
  "view",
569
576
  ]);
570
577
 
578
+ // like/dislike/unlike/undislike all go through src/session/reactions.js, the path the
579
+ // local MCP server shares; `ack` keeps the plain message-action path.
580
+ const SESSION_REACT_COMMAND_TYPES = Object.freeze(["ack", ...SESSION_REACTION_TYPES]);
581
+
571
582
  const SESSION_MESSAGE_ACTION_ALIASES = new Map([
572
583
  ["comment", "reply"],
573
584
  ]);
@@ -599,6 +610,16 @@ const SESSION_MESSAGE_ACTION_DESCRIPTIONS = Object.freeze([
599
610
  command: "sl session react <id> dislike --target-sequence <n>",
600
611
  description: "Negative lightweight feedback. Use --target-action-id <uuid> to react to a threaded reply.",
601
612
  },
613
+ {
614
+ type: "unlike",
615
+ command: "sl session react <id> unlike --target-sequence <n>",
616
+ description: "Retract your own active like. Refused when you have none; needs a server with reaction undo.",
617
+ },
618
+ {
619
+ type: "undislike",
620
+ command: "sl session react <id> undislike --target-sequence <n>",
621
+ description: "Retract your own active dislike. Refused when you have none; needs a server with reaction undo.",
622
+ },
602
623
  {
603
624
  type: "disregard",
604
625
  command: "sl session action <id> disregard --target-sequence <n>",
@@ -630,7 +651,7 @@ function shortSha256(value) {
630
651
 
631
652
  function actionEventType(actionType) {
632
653
  if (actionType === "reply") return "session_reply";
633
- if (actionType === "like" || actionType === "dislike") return "session_reaction";
654
+ if (isSessionReactionType(actionType)) return "session_reaction";
634
655
  return "session_action";
635
656
  }
636
657
 
@@ -686,6 +707,7 @@ function buildSessionActionEvent(sessionId, action = {}) {
686
707
  }),
687
708
  );
688
709
  const actorId = actionActorId(action);
710
+ const collapsedIntoActionId = normalizeString(action.collapsedIntoActionId ?? action.collapsed_into_action_id);
689
711
  const event = createAgentEvent({
690
712
  event: actionEventType(actionType),
691
713
  agent: {
@@ -710,6 +732,8 @@ function buildSessionActionEvent(sessionId, action = {}) {
710
732
  metadata: action.metadata && typeof action.metadata === "object" ? action.metadata : undefined,
711
733
  message: actionDisplayMessage(action),
712
734
  source: "session_action",
735
+ // A retained no-op reaction key (sentinelayer-api#914): evidence, not a reaction.
736
+ ...(collapsedIntoActionId ? { collapsedIntoActionId } : {}),
713
737
  },
714
738
  });
715
739
  event.eventId = `session-action-${id}`;
@@ -4282,6 +4306,21 @@ export function registerSessionCommand(program) {
4282
4306
  );
4283
4307
  }
4284
4308
  const agentId = identity.agentId;
4309
+ if (isSessionReactionType(normalizedActionType)) {
4310
+ return runReactionCommand({
4311
+ sessionId: normalizedSessionId,
4312
+ reaction: normalizedActionType,
4313
+ agentId,
4314
+ targetPath,
4315
+ targetSequenceId,
4316
+ targetCursor,
4317
+ targetActionId,
4318
+ note,
4319
+ options,
4320
+ command,
4321
+ commandName,
4322
+ });
4323
+ }
4285
4324
  const idempotencyKey =
4286
4325
  normalizeString(options.idempotencyKey) ||
4287
4326
  defaultActionIdempotencyKey({
@@ -4364,6 +4403,89 @@ export function registerSessionCommand(program) {
4364
4403
  return payload;
4365
4404
  }
4366
4405
 
4406
+ // like/dislike/unlike/undislike: the shared reaction path (src/session/reactions.js)
4407
+ // decides admission, the operation key and the outcome; this only mirrors and prints.
4408
+ // `--idempotency-key` is a RETRY of the intent that minted that key.
4409
+ async function runReactionCommand({
4410
+ sessionId,
4411
+ reaction,
4412
+ agentId,
4413
+ targetPath,
4414
+ targetSequenceId,
4415
+ targetCursor,
4416
+ targetActionId,
4417
+ note,
4418
+ options,
4419
+ command,
4420
+ commandName,
4421
+ }) {
4422
+ const outcome = await submitSessionReaction({
4423
+ surface: "cli",
4424
+ sessionId,
4425
+ agentId,
4426
+ reaction,
4427
+ targetSequenceId,
4428
+ targetCursor,
4429
+ targetActionId,
4430
+ note,
4431
+ idempotencyKey: options.idempotencyKey,
4432
+ targetPath,
4433
+ });
4434
+ let event = null;
4435
+ let localAppend = { appended: false, reason: "not_recorded" };
4436
+ if (outcome.action) {
4437
+ const appended = await appendActionEventIfMissing(
4438
+ sessionId,
4439
+ buildSessionActionEvent(sessionId, outcome.action),
4440
+ { targetPath },
4441
+ );
4442
+ event = appended.event;
4443
+ localAppend = { appended: Boolean(appended.appended), reason: appended.reason || "" };
4444
+ }
4445
+ let evidenceEvent = null;
4446
+ if (outcome.collapsedActionId && outcome.action) {
4447
+ // Mirror the server's retained no-op row: this request's key, collapsed into
4448
+ // the active reaction it repeated.
4449
+ const appended = await appendActionEventIfMissing(
4450
+ sessionId,
4451
+ buildSessionActionEvent(sessionId, {
4452
+ id: outcome.collapsedActionId,
4453
+ actionType: reaction,
4454
+ targetSequenceId: outcome.action.targetSequenceId,
4455
+ targetCursor: outcome.action.targetCursor,
4456
+ targetActionId: outcome.action.targetActionId,
4457
+ actorKind: outcome.action.actorKind,
4458
+ actorId: outcome.action.actorId,
4459
+ actorRole: outcome.action.actorRole,
4460
+ idempotencyKey: outcome.operationKey,
4461
+ collapsedIntoActionId: outcome.action.id,
4462
+ }),
4463
+ { targetPath },
4464
+ );
4465
+ evidenceEvent = appended.event;
4466
+ }
4467
+ const payload = {
4468
+ command: commandName,
4469
+ targetPath,
4470
+ ...outcome,
4471
+ event,
4472
+ evidenceEvent,
4473
+ localAppend,
4474
+ };
4475
+ if (!outcome.ok) process.exitCode = 1;
4476
+ if (shouldEmitJson(options, command)) {
4477
+ console.log(JSON.stringify(payload, null, 2));
4478
+ return payload;
4479
+ }
4480
+ if (!outcome.ok) {
4481
+ console.error(pc.yellow(outcome.message));
4482
+ return payload;
4483
+ }
4484
+ if (event) console.log(formatEventLine(event));
4485
+ if (outcome.outcome !== "applied" || !event) console.log(pc.gray(outcome.message));
4486
+ return payload;
4487
+ }
4488
+
4367
4489
  session
4368
4490
  .command("actions")
4369
4491
  .description("List supported low-noise message actions with examples")
@@ -4390,7 +4512,7 @@ export function registerSessionCommand(program) {
4390
4512
  session
4391
4513
  .command("action <sessionId> <actionType>")
4392
4514
  .description(
4393
- "Create a message action for a target session event (ack, working_on, reply/comment, like, dislike, disregard, view)",
4515
+ "Create a message action for a target session event (ack, working_on, reply/comment, like, dislike, unlike, undislike, disregard, view)",
4394
4516
  )
4395
4517
  .option("--target-sequence <n>", "Target event sequence id")
4396
4518
  .option("--target-cursor <cursor>", "Target event cursor")
@@ -4406,18 +4528,23 @@ export function registerSessionCommand(program) {
4406
4528
 
4407
4529
  session
4408
4530
  .command("react <sessionId> <reaction>")
4409
- .description("React to or acknowledge a target session event with ack, like, or dislike")
4531
+ .description(
4532
+ "React to or acknowledge a target session event with ack, like, or dislike; retract your own like/dislike with unlike/undislike",
4533
+ )
4410
4534
  .option("--target-sequence <n>", "Target event sequence id")
4411
4535
  .option("--target-cursor <cursor>", "Target event cursor")
4412
4536
  .option("--target-action-id <uuid>", "Target a threaded reply/action by action UUID")
4413
4537
  .option("--agent <id>", "Agent id authoring the action (defaults to the joined session agent)")
4414
- .option("--idempotency-key <key>", "Explicit idempotency key")
4538
+ .option(
4539
+ "--idempotency-key <key>",
4540
+ "Retry an earlier reaction as the SAME intent by resending its operationKey (without it, each call is a new intent)",
4541
+ )
4415
4542
  .option("--path <path>", "Workspace path for the session", ".")
4416
4543
  .option("--json", "Emit machine-readable output")
4417
4544
  .action(async (sessionId, reaction, options, command) => {
4418
4545
  const normalizedReaction = normalizeSessionMessageActionType(reaction);
4419
- if (!["ack", "like", "dislike"].includes(normalizedReaction)) {
4420
- throw new Error("reaction must be one of: ack, like, dislike.");
4546
+ if (!SESSION_REACT_COMMAND_TYPES.includes(normalizedReaction)) {
4547
+ throw new Error(`reaction must be one of: ${SESSION_REACT_COMMAND_TYPES.join(", ")}.`);
4421
4548
  }
4422
4549
  await runMessageActionCommand({
4423
4550
  sessionId,
@@ -64,6 +64,9 @@ const SENSITIVE_COMMAND_PREFIXES = [
64
64
  "ai.identity.target.",
65
65
  "ai.identity.site.",
66
66
  "ai.identity.legal-hold.",
67
+ // Room access administration (request/list/status/approve/deny/revoke/mode) belongs to
68
+ // the room owner's own session; operators use the direct CLI or the web dashboard.
69
+ "session.access.",
67
70
  ];
68
71
  const REDACTION_MARKER = "[REDACTED]";
69
72
 
@@ -655,7 +655,7 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
655
655
  name: "session_action",
656
656
  title: "Record Senti Session Action",
657
657
  description:
658
- "Record a low-noise message action such as ack, working_on, disregard, like, dislike, or reply; view advances the monotonic read cursor instead of appending an action.",
658
+ "Record a low-noise message action such as ack, working_on, disregard, like, dislike, unlike, undislike, or reply; view advances the monotonic read cursor instead of appending an action.",
659
659
  input_schema: {
660
660
  type: "object",
661
661
  additionalProperties: false,
@@ -665,7 +665,7 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
665
665
  agentId: { type: "string" },
666
666
  actionType: {
667
667
  type: "string",
668
- enum: ["ack", "working_on", "reply", "like", "dislike", "disregard", "view"],
668
+ enum: ["ack", "working_on", "reply", "like", "dislike", "unlike", "undislike", "disregard", "view"],
669
669
  },
670
670
  targetSequenceId: { type: "integer", minimum: 1 },
671
671
  targetCursor: { type: "string" },
@@ -702,7 +702,7 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
702
702
  name: "session_react",
703
703
  title: "React To Senti Message",
704
704
  description:
705
- "Acknowledge or react to a target session event with ack, like, or dislike.",
705
+ "Acknowledge or react to a target session event with ack, like, or dislike, or retract your own like/dislike with unlike/undislike. Reactions report an outcome and an operation key; resend that key as idempotencyKey only to retry the same intent.",
706
706
  input_schema: {
707
707
  type: "object",
708
708
  additionalProperties: false,
@@ -710,7 +710,7 @@ export function buildSentinelayerSessionRegistryTemplate({ generatedAt = new Dat
710
710
  properties: {
711
711
  sessionId: { type: "string" },
712
712
  agentId: { type: "string" },
713
- reaction: { type: "string", enum: ["ack", "like", "dislike"] },
713
+ reaction: { type: "string", enum: ["ack", "like", "dislike", "unlike", "undislike"] },
714
714
  targetSequenceId: { type: "integer", minimum: 1 },
715
715
  targetCursor: { type: "string" },
716
716
  targetActionId: { type: "string" },
@@ -13,6 +13,12 @@ import { eventMatchesAgent } from "../session/listener.js";
13
13
  import { isSessionControlEvent } from "../session/control-events.js";
14
14
  import { buildObservations } from "../session/recall/observations.js";
15
15
  import { createMemoryService } from "../engram/index.js";
16
+ import { assertCanonicalAgentId, canonicalAgentId, withAgentAdmission } from "../session/admission-auth.js";
17
+ import {
18
+ SESSION_REACTION_TYPES,
19
+ isSessionReactionType,
20
+ submitSessionReaction,
21
+ } from "../session/reactions.js";
16
22
  import {
17
23
  createSessionMessageAction,
18
24
  listSessionMessageActions,
@@ -44,9 +50,12 @@ const SESSION_MESSAGE_ACTION_TYPES = new Set([
44
50
  "reply",
45
51
  "like",
46
52
  "dislike",
53
+ "unlike",
54
+ "undislike",
47
55
  "disregard",
48
56
  "view",
49
57
  ]);
58
+ const SESSION_REACT_TYPES = Object.freeze(["ack", ...SESSION_REACTION_TYPES]);
50
59
  const SESSION_MESSAGE_ACTION_ALIASES = new Map([["comment", "reply"]]);
51
60
 
52
61
  function normalizeString(value) {
@@ -701,6 +710,25 @@ async function runSessionAction({
701
710
  const normalizedActionType = normalizeSessionMessageActionType(actionType || input.actionType || input.action_type);
702
711
  const target = requireSessionActionTarget(input);
703
712
  const normalizedNote = truncateText(note || input.note || input.message || input.text || "").text;
713
+ if (isSessionReactionType(normalizedActionType)) {
714
+ // The same path as the native CLI: canonical fail-closed admission, a fresh
715
+ // operation key per new intent (an explicit idempotencyKey is a retry), and a
716
+ // truthful outcome. The raw agent id is passed so a non-canonical spelling is
717
+ // refused rather than silently aliased.
718
+ return submitSessionReaction({
719
+ surface: "mcp",
720
+ sessionId,
721
+ agentId: normalizeString(input.agentId || input.agent_id || input.agent),
722
+ reaction: normalizedActionType,
723
+ ...target,
724
+ note: normalizedNote,
725
+ idempotencyKey: normalizeString(input.idempotencyKey || input.idempotency_key),
726
+ targetPath,
727
+ timeoutMs: normalizePositiveInteger(input.timeoutMs || input.timeout_ms, 15_000),
728
+ dryRun: Boolean(input.dryRun || input.dry_run),
729
+ createSessionMessageActionFn,
730
+ });
731
+ }
704
732
  const idempotencyKey =
705
733
  normalizeString(input.idempotencyKey || input.idempotency_key) ||
706
734
  defaultActionIdempotencyKey({
@@ -858,6 +886,47 @@ async function persistSessionEvent({
858
886
  };
859
887
  }
860
888
 
889
+ // Local MCP tools that act AS an agent: post, raise attention, react, reply, act on a
890
+ // message, lease files, or advance that agent's read cursor. Each runs inside the
891
+ // agent's admission scope, the same canonical path native `sl session` actor commands
892
+ // use. A live stored admission is the only credential its requests carry; an expired,
893
+ // malformed or mis-bound one is refused before any request; a credential the API
894
+ // refuses (401/403) is final. With no stored admission a tool behaves as before.
895
+ export const SESSION_MCP_ACTOR_TOOLS = Object.freeze([
896
+ "poll_inbox",
897
+ "read_history",
898
+ "send_message",
899
+ "attention_request",
900
+ "session_action",
901
+ "session_react",
902
+ "session_reply",
903
+ "session_lock",
904
+ "session_unlock",
905
+ ]);
906
+
907
+ function actorAgentId(toolName, input = {}) {
908
+ const raw =
909
+ normalizeString(input.agentId || input.agent_id || input.agent) ||
910
+ (toolName === "read_history" ? normalizeString(process.env.SENTINELAYER_AGENT_ID) : "");
911
+ if (!raw) return "";
912
+ // One canonical identity for authorisation and execution (see admission-auth.js).
913
+ assertCanonicalAgentId(raw, "agentId");
914
+ return canonicalAgentId(raw);
915
+ }
916
+
917
+ function routeActorToolsThroughAdmission(handlers) {
918
+ for (const name of SESSION_MCP_ACTOR_TOOLS) {
919
+ const run = handlers[name];
920
+ handlers[name] = async (input = {}) => {
921
+ const agentId = actorAgentId(name, input);
922
+ // No agent named: the tool's own validation reports it (read_history reads as the user).
923
+ if (!agentId) return run(input);
924
+ return withAgentAdmission(requireSessionId(input), agentId, () => run(input));
925
+ };
926
+ }
927
+ return handlers;
928
+ }
929
+
861
930
  export function createSessionMcpToolHandlers({
862
931
  targetPath = process.cwd(),
863
932
  pollSessionEventsFn = pollSessionEvents,
@@ -897,7 +966,7 @@ export function createSessionMcpToolHandlers({
897
966
  verified: false,
898
967
  };
899
968
  };
900
- return {
969
+ const handlers = {
901
970
  async poll_inbox(input = {}) {
902
971
  const sessionId = requireSessionId(input);
903
972
  const agentId = requireAgentId(input);
@@ -1105,8 +1174,8 @@ export function createSessionMcpToolHandlers({
1105
1174
 
1106
1175
  async session_react(input = {}) {
1107
1176
  const reaction = normalizeSessionMessageActionType(input.reaction || input.actionType || input.action_type);
1108
- if (!["ack", "like", "dislike"].includes(reaction)) {
1109
- throw new Error("reaction must be one of: ack, like, dislike.");
1177
+ if (!SESSION_REACT_TYPES.includes(reaction)) {
1178
+ throw new Error(`reaction must be one of: ${SESSION_REACT_TYPES.join(", ")}.`);
1110
1179
  }
1111
1180
  return runSessionAction({
1112
1181
  input,
@@ -1267,6 +1336,7 @@ export function createSessionMcpToolHandlers({
1267
1336
  });
1268
1337
  },
1269
1338
  };
1339
+ return routeActorToolsThroughAdmission(handlers);
1270
1340
  }
1271
1341
 
1272
1342
  export const SESSION_MCP_TOOLS = Object.freeze([
@@ -1419,7 +1489,7 @@ export const SESSION_MCP_TOOLS = Object.freeze([
1419
1489
  name: "session_action",
1420
1490
  title: "Record Senti Session Action",
1421
1491
  description:
1422
- "Record a low-noise message action such as ack, working_on, disregard, like, dislike, or reply; view advances the monotonic read cursor instead of appending an action.",
1492
+ "Record a low-noise message action such as ack, working_on, disregard, like, dislike, unlike, undislike, or reply; view advances the monotonic read cursor instead of appending an action.",
1423
1493
  inputSchema: {
1424
1494
  type: "object",
1425
1495
  additionalProperties: false,
@@ -1442,7 +1512,7 @@ export const SESSION_MCP_TOOLS = Object.freeze([
1442
1512
  name: "session_react",
1443
1513
  title: "React To Senti Message",
1444
1514
  description:
1445
- "Acknowledge or react to a target session event with ack, like, or dislike.",
1515
+ "Acknowledge or react to a target session event with ack, like, or dislike, or retract your own like/dislike with unlike/undislike. Reactions report an outcome and an operation key; resend that key as idempotencyKey only to retry the same intent.",
1446
1516
  inputSchema: {
1447
1517
  type: "object",
1448
1518
  additionalProperties: false,
@@ -1450,7 +1520,7 @@ export const SESSION_MCP_TOOLS = Object.freeze([
1450
1520
  properties: {
1451
1521
  sessionId: { type: "string", minLength: 1 },
1452
1522
  agentId: { type: "string", minLength: 1 },
1453
- reaction: { type: "string", enum: ["ack", "like", "dislike"] },
1523
+ reaction: { type: "string", enum: [...SESSION_REACT_TYPES] },
1454
1524
  targetSequenceId: { type: "integer", minimum: 1 },
1455
1525
  targetCursor: { type: "string" },
1456
1526
  targetActionId: { type: "string" },
@@ -10,12 +10,37 @@ import process from "node:process";
10
10
 
11
11
  import { requestJson, requestJsonMutation } from "../auth/http.js";
12
12
  import { resolveActiveAuthSession } from "../auth/service.js";
13
+ import { hasStoredAdmissionCredentials } from "./admission.js";
14
+ import { currentAdmittedAgent } from "./admission-auth.js";
13
15
  import {
14
16
  SESSION_MUTATION_ORIGIN,
15
17
  createSessionMutationHeaders,
16
18
  createSessionMutationIdempotencyKey,
17
19
  } from "./invitations.js";
18
20
 
21
+ /**
22
+ * Approve, deny, revoke and mode changes are the room owner's decisions, made with the
23
+ * owner's own session. They are unavailable while this process is an agent context:
24
+ * SENTINELAYER_AGENT_ID is set, an agent admission scope is in force, or this machine
25
+ * stores agent admission credentials. Checked before any credential lookup or request.
26
+ */
27
+ export async function assertOwnerAccessContext({ env = process.env, homeDir } = {}) {
28
+ let why = "";
29
+ if (normalizeString(env.SENTINELAYER_AGENT_ID)) {
30
+ why = "SENTINELAYER_AGENT_ID is set";
31
+ } else if (currentAdmittedAgent()) {
32
+ why = "an agent admission is in use";
33
+ } else if (await hasStoredAdmissionCredentials({ homeDir })) {
34
+ why = "agent admission credentials are stored on this machine";
35
+ }
36
+ if (why) {
37
+ throw new Error(
38
+ `Access approve, deny, revoke and mode are owner actions and are unavailable in an agent context (${why}). ` +
39
+ `Use the room's web dashboard, or run the command from an environment without agent credentials.`,
40
+ );
41
+ }
42
+ }
43
+
19
44
  export const ADMISSION_STATUSES = Object.freeze([
20
45
  "pending",
21
46
  "approved",
@@ -171,6 +196,7 @@ export async function decideSessionAdmission(
171
196
  if (ttlSeconds !== undefined) body.ttlSeconds = ttlSeconds;
172
197
  const normalizedNote = normalizeString(note);
173
198
  if (normalizedNote) body.note = normalizedNote;
199
+ await assertOwnerAccessContext();
174
200
  return mutateAdmission(
175
201
  sid,
176
202
  `/${encodeURIComponent(aid)}/decision`,
@@ -202,6 +228,7 @@ export async function revokeSessionAdmission(
202
228
  const sid = required(sessionId, "session id");
203
229
  const aid = required(admissionId, "admission id");
204
230
  const normalizedReason = normalizeString(reason);
231
+ await assertOwnerAccessContext();
205
232
  return mutateAdmission(
206
233
  sid,
207
234
  `/${encodeURIComponent(aid)}/revoke`,
@@ -234,6 +261,7 @@ export async function setSessionAdmissionMode(
234
261
  if (!["legacy", "required"].includes(normalizedMode)) {
235
262
  throw new Error("mode must be legacy or required.");
236
263
  }
264
+ await assertOwnerAccessContext();
237
265
  const key = boundedIdempotencyKey(idempotencyKey, "session.admission_mode");
238
266
  const auth = await authContext(targetPath, resolveAuthSession);
239
267
  const result = await requestMutation(
@@ -63,7 +63,8 @@ const ROUTE = {
63
63
  claim: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/claim",
64
64
  cancel: "POST /api/v1/sessions/{session_id}/admissions/{admission_id}/cancel",
65
65
  };
66
- const TERMINAL = new Set(["denied", "cancelled", "expired", "revoked"]);
66
+ // Final admission statuses: polling stops, nothing is claimed, local request state is cleared.
67
+ const TERMINAL = new Set(["denied", "cancelled", "expired", "revoked", "stopped"]);
67
68
  const TTL_MIN_SECONDS = 300;
68
69
  const TTL_MAX_SECONDS = 86_400;
69
70
 
@@ -242,6 +243,34 @@ export function admissionCredentialPath(sessionId, agentId, { homeDir } = {}) {
242
243
  );
243
244
  }
244
245
 
246
+ /**
247
+ * Whether this machine stores ANY agent admission credential (usable or not, any
248
+ * session): `<home>/.sentinelayer/agents/<agent>/admissions/<session>.json`. Agent keys
249
+ * alone do not count. An unreadable directory counts as present, never as absence.
250
+ */
251
+ export async function hasStoredAdmissionCredentials({ homeDir } = {}) {
252
+ const agentsDir = path.join(sentinelayerHome(homeDir), "agents");
253
+ let agents;
254
+ try {
255
+ agents = await fsp.readdir(agentsDir, { withFileTypes: true });
256
+ } catch (error) {
257
+ if (error && error.code === "ENOENT") return false;
258
+ return true;
259
+ }
260
+ for (const agent of agents) {
261
+ if (!agent.isDirectory()) continue;
262
+ let files;
263
+ try {
264
+ files = await fsp.readdir(path.join(agentsDir, agent.name, "admissions"));
265
+ } catch (error) {
266
+ if (error && error.code === "ENOENT") continue;
267
+ return true;
268
+ }
269
+ if (files.some((name) => name.endsWith(".json"))) return true;
270
+ }
271
+ return false;
272
+ }
273
+
245
274
  /**
246
275
  * What this machine holds for (session, agent):
247
276
  * { state: "none" } -- never admitted here: the legacy path applies
@@ -471,18 +500,24 @@ export async function runAdmissionJoin(
471
500
  headers: { Authorization: `Bearer ${auth.token}` },
472
501
  });
473
502
  state.status = polled.status;
474
- const claimReady =
475
- polled.status === "approved" &&
476
- polled.identityReady === true &&
477
- polled?.claim?.domain === CLAIM_DOMAIN &&
478
- polled?.claim?.preimage;
479
- if (polled.status === "approved" && polled.identityReady === true && !claimReady) {
480
- throw new Error("Identity-ready admission did not include a valid claim challenge.");
481
- }
482
- if (polled.status === "approved" && polled.identityReady !== true && polled?.claim) {
483
- throw new Error("Admission exposed a claim challenge before AIdenID identity evidence was ready.");
503
+ // Identity readiness is a server capability: a server that gates the claim on
504
+ // AIdenID identity evidence reports `identityReady` (true or false) on every
505
+ // approved poll. A server that does not report it issues the claim challenge on
506
+ // approval, so the claim proceeds as soon as the admission is approved.
507
+ const reportsIdentityReadiness =
508
+ polled.status === "approved" && Object.prototype.hasOwnProperty.call(polled, "identityReady");
509
+ const claimable =
510
+ polled.status === "approved" && (!reportsIdentityReadiness || polled.identityReady === true);
511
+ if (reportsIdentityReadiness) {
512
+ const claimReady = claimable && polled?.claim?.domain === CLAIM_DOMAIN && polled?.claim?.preimage;
513
+ if (polled.identityReady === true && !claimReady) {
514
+ throw new Error("Identity-ready admission did not include a valid claim challenge.");
515
+ }
516
+ if (polled.identityReady !== true && polled?.claim) {
517
+ throw new Error("Admission exposed a claim challenge before AIdenID identity evidence was ready.");
518
+ }
484
519
  }
485
- if (claimReady || TERMINAL.has(polled.status) || polled.status === "active") break;
520
+ if (claimable || TERMINAL.has(polled.status) || polled.status === "active") break;
486
521
  const phase = polled.status === "approved" ? "identity" : "approval";
487
522
  onPending({
488
523
  admissionId: state.admissionId,
@@ -14,6 +14,8 @@ const QUIET_SESSION_ACTION_TYPES = new Set([
14
14
  "dislike",
15
15
  "disregard",
16
16
  "like",
17
+ "undislike",
18
+ "unlike",
17
19
  "view",
18
20
  ]);
19
21
 
@@ -0,0 +1,258 @@
1
+ // reactions.js -- the ONE path every like / dislike / unlike / undislike takes, from
2
+ // the native CLI (`sl session react|action`) and the local MCP server
3
+ // (`session_react`, `session_action`) alike.
4
+ //
5
+ // Three guarantees. Separate copies of this logic once broke each of them:
6
+ //
7
+ // 1. Authority. The write runs inside withAgentAdmission for (session, agent): an
8
+ // admitted agent acts on ITS admission credential, a tombstoned (expired,
9
+ // malformed, mis-bound) admission is refused before any request, and nothing
10
+ // falls back to the human's broader token or legacy agent grants.
11
+ // 2. Intent. The API replays a reused idempotency key instead of acting on it
12
+ // (sentinelayer-api#914 reaction contract), so a key derived from
13
+ // (type, target, actor) makes like -> unlike -> like a silent no-op. Each NEW
14
+ // intent gets a fresh operation key. An explicit key is a retry of the intent
15
+ // that minted it, and is sent unchanged.
16
+ // 3. Truth. Every result names its outcome and carries its operation key, so an
17
+ // ambiguous failure (the request may have landed) can be retried as the SAME
18
+ // intent instead of becoming a new one that could retract a newer reaction.
19
+
20
+ import { randomBytes } from "node:crypto";
21
+ import process from "node:process";
22
+
23
+ import { assertCanonicalAgentId, canonicalAgentId, withAgentAdmission } from "./admission-auth.js";
24
+ import { createSessionMessageAction } from "./sync.js";
25
+
26
+ export const SESSION_REACTION_TYPES = Object.freeze(["like", "dislike", "unlike", "undislike"]);
27
+ const REACTION_TYPES = new Set(SESSION_REACTION_TYPES);
28
+ const UNDO_TARGETS = new Map([
29
+ ["unlike", "like"],
30
+ ["undislike", "dislike"],
31
+ ]);
32
+
33
+ // createSessionMessageAction failures that happen before any request leaves this machine.
34
+ const NOT_SENT_REASONS = new Set([
35
+ "invalid_input",
36
+ "remote_sync_disabled_env",
37
+ "circuit_breaker_open",
38
+ "no_session",
39
+ "not_authenticated",
40
+ ]);
41
+
42
+ /**
43
+ * Every outcome a reaction can have. `ok` is true only for the first three.
44
+ * applied -- this request recorded a new reaction row
45
+ * no_op -- an equal reaction was already active; nothing changed. With
46
+ * `collapsedActionId` the server retained this request as evidence.
47
+ * replayed -- an explicit (retry) key had already been recorded; the server
48
+ * returned that original result and did not re-check current state
49
+ * not_active -- an undo with nothing of this actor's active to retract (409)
50
+ * unsupported -- a server without reaction undo rejected the action value (422)
51
+ * refused -- any other definitive refusal; nothing was recorded
52
+ * not_sent -- the request never left this machine; nothing was recorded
53
+ * unknown -- the request may or may not have been recorded (timeout, lost
54
+ * response, 5xx): retry with the SAME operation key
55
+ */
56
+ export const SESSION_REACTION_OUTCOMES = Object.freeze([
57
+ "applied",
58
+ "no_op",
59
+ "replayed",
60
+ "not_active",
61
+ "unsupported",
62
+ "refused",
63
+ "not_sent",
64
+ "unknown",
65
+ ]);
66
+
67
+ function normalizeString(value) {
68
+ return String(value ?? "").trim();
69
+ }
70
+
71
+ export function isSessionReactionType(value) {
72
+ return REACTION_TYPES.has(normalizeString(value).toLowerCase());
73
+ }
74
+
75
+ /** The like/dislike an undo retracts, or "" for a non-undo. */
76
+ export function reactionUndoTarget(value) {
77
+ return UNDO_TARGETS.get(normalizeString(value).toLowerCase()) || "";
78
+ }
79
+
80
+ function targetKey({ targetSequenceId, targetCursor, targetActionId }) {
81
+ if (normalizeString(targetActionId)) return `action:${normalizeString(targetActionId)}`;
82
+ if (targetSequenceId) return `seq:${targetSequenceId}`;
83
+ return `cursor:${normalizeString(targetCursor)}`;
84
+ }
85
+
86
+ function targetLabel({ targetSequenceId, targetCursor, targetActionId }) {
87
+ if (normalizeString(targetActionId)) return `reply ${normalizeString(targetActionId)}`;
88
+ if (targetSequenceId) return `#${targetSequenceId}`;
89
+ return normalizeString(targetCursor) || "target";
90
+ }
91
+
92
+ /** A key for a NEW intent. Never derived from (type, target, actor) alone. */
93
+ export function newReactionOperationKey({ surface, reaction, agentId, ...target } = {}) {
94
+ return `${surface}:${reaction}:${targetKey(target)}:${agentId}:${randomBytes(8).toString("hex")}`;
95
+ }
96
+
97
+ /**
98
+ * The exact 422 a server without reaction undo (sentinelayer-api before #914) returns
99
+ * for this action: FastAPI's validation envelope around the route's action-type
100
+ * validator, a single entry echoing the value sent. Anything looser could relabel a
101
+ * different validation failure as "undo unsupported", so the match is exact.
102
+ */
103
+ export function isUndoUnsupportedValidationError(result = {}, actionType = "") {
104
+ if (result.status !== 422) return false;
105
+ const detail = result.error?.detail;
106
+ if (!Array.isArray(detail) || detail.length !== 1) return false;
107
+ const [entry] = detail;
108
+ return (
109
+ entry?.type === "value_error" &&
110
+ Array.isArray(entry.loc) &&
111
+ entry.loc.length === 2 &&
112
+ entry.loc[0] === "body" &&
113
+ entry.loc[1] === "actionType" &&
114
+ entry.msg === "Value error, unsupported message action type" &&
115
+ entry.input === actionType
116
+ );
117
+ }
118
+
119
+ function classify(reaction, result, retry) {
120
+ const status = Number.isInteger(result?.status) ? result.status : null;
121
+ if (result?.ok && result.action) {
122
+ if (normalizeString(result.collapsedActionId)) return "no_op";
123
+ if (result.duplicate) return retry ? "replayed" : "no_op";
124
+ return "applied";
125
+ }
126
+ if (status === null) {
127
+ return NOT_SENT_REASONS.has(normalizeString(result?.reason)) ? "not_sent" : "unknown";
128
+ }
129
+ if (status >= 500) return "unknown";
130
+ if (isUndoUnsupportedValidationError(result, reaction)) return "unsupported";
131
+ if (status === 409 && reactionUndoTarget(reaction) && result.error?.error?.code === "REACTION_NOT_ACTIVE") {
132
+ return "not_active";
133
+ }
134
+ if (status >= 400) return "refused";
135
+ return "unknown";
136
+ }
137
+
138
+ function describe(outcome, { reaction, agentId, target, operationKey, reason, errorCode, collapsedActionId }) {
139
+ const where = targetLabel(target);
140
+ switch (outcome) {
141
+ case "applied":
142
+ return `${agentId} ${reaction} recorded on ${where}.`;
143
+ case "no_op":
144
+ return (
145
+ `No change: ${agentId} already has an active ${reaction} on ${where}.` +
146
+ (collapsedActionId ? ` This request is retained as evidence ${collapsedActionId}.` : "")
147
+ );
148
+ case "replayed":
149
+ return (
150
+ `Replayed: operation ${operationKey} was already recorded. The server returned its ` +
151
+ `original result and did not re-check the current state.`
152
+ );
153
+ case "not_active":
154
+ return `Nothing to undo: ${agentId} has no active ${reactionUndoTarget(reaction)} on ${where}.`;
155
+ case "unsupported":
156
+ return `This server doesn't support undo yet (no '${reaction}' action). Nothing was changed.`;
157
+ case "refused":
158
+ return `The server refused this ${reaction} (${errorCode || reason}). Nothing was recorded.`;
159
+ case "not_sent":
160
+ return `Not sent (${reason}). Nothing was recorded.`;
161
+ default:
162
+ return (
163
+ `Outcome unknown (${reason || "no response"}): the server may have recorded this ${reaction}. ` +
164
+ `To retry this same intent, resend it with idempotency key ${operationKey}; ` +
165
+ `without that key it is a new intent.`
166
+ );
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Submit one reaction. Throws only for invalid input or a refused admission (before
172
+ * any request); every server answer, network failure and timeout is an outcome.
173
+ */
174
+ export async function submitSessionReaction({
175
+ surface,
176
+ sessionId,
177
+ agentId,
178
+ reaction,
179
+ targetSequenceId = null,
180
+ targetCursor = "",
181
+ targetActionId = "",
182
+ note = "",
183
+ idempotencyKey = "",
184
+ targetPath = process.cwd(),
185
+ timeoutMs = 15_000,
186
+ dryRun = false,
187
+ createSessionMessageActionFn = createSessionMessageAction,
188
+ } = {}) {
189
+ const sid = normalizeString(sessionId);
190
+ const type = normalizeString(reaction).toLowerCase();
191
+ if (!sid) throw new Error("session id is required.");
192
+ if (!REACTION_TYPES.has(type)) {
193
+ throw new Error(`reaction must be one of: ${SESSION_REACTION_TYPES.join(", ")}.`);
194
+ }
195
+ // One canonical identity for authorisation and execution (see admission-auth.js).
196
+ assertCanonicalAgentId(agentId, "agentId");
197
+ const agent = canonicalAgentId(agentId);
198
+ if (!agent) throw new Error("agentId is required.");
199
+ const target = {
200
+ targetSequenceId: targetSequenceId || null,
201
+ targetCursor: normalizeString(targetCursor),
202
+ targetActionId: normalizeString(targetActionId),
203
+ };
204
+ if (!target.targetSequenceId && !target.targetCursor && !target.targetActionId) {
205
+ throw new Error("Provide a target sequence, cursor, or action id.");
206
+ }
207
+ const explicitKey = normalizeString(idempotencyKey);
208
+ const operationKey =
209
+ explicitKey || newReactionOperationKey({ surface, reaction: type, agentId: agent, ...target });
210
+ const base = {
211
+ sessionId: sid,
212
+ agentId: agent,
213
+ actionType: type,
214
+ ...target,
215
+ note: normalizeString(note),
216
+ operationKey,
217
+ idempotencyKey: operationKey,
218
+ retry: Boolean(explicitKey),
219
+ };
220
+ if (dryRun) return { ok: true, dryRun: true, outcome: "dry_run", ...base };
221
+
222
+ const result = await withAgentAdmission(sid, agent, () =>
223
+ createSessionMessageActionFn(sid, {
224
+ actionType: type,
225
+ targetPath,
226
+ ...target,
227
+ note: base.note,
228
+ metadata: { source: surface, agentId: agent },
229
+ idempotencyKey: operationKey,
230
+ timeoutMs,
231
+ }),
232
+ );
233
+ const outcome = classify(type, result, base.retry);
234
+ const collapsedActionId = normalizeString(result?.collapsedActionId) || null;
235
+ const status = Number.isInteger(result?.status) ? result.status : null;
236
+ const reason = normalizeString(result?.reason);
237
+ const errorCode = normalizeString(result?.error?.error?.code) || null;
238
+ return {
239
+ ok: outcome === "applied" || outcome === "no_op" || outcome === "replayed",
240
+ outcome,
241
+ ...base,
242
+ duplicate: Boolean(result?.duplicate),
243
+ collapsedActionId,
244
+ status,
245
+ reason,
246
+ errorCode,
247
+ message: describe(outcome, {
248
+ reaction: type,
249
+ agentId: agent,
250
+ target,
251
+ operationKey,
252
+ reason,
253
+ errorCode,
254
+ collapsedActionId,
255
+ }),
256
+ action: result?.ok && result.action ? result.action : null,
257
+ };
258
+ }
@@ -19,6 +19,8 @@ const DEFAULT_API_BASE_URL = "https://api.sentinelayer.com";
19
19
  const DEFAULT_SYNC_TIMEOUT_MS = 5_000;
20
20
  const MAX_CONSECUTIVE_FAILURES = 3;
21
21
  const CIRCUIT_RESET_MS = 60_000;
22
+ // Message-action statuses whose body the caller needs to explain the refusal.
23
+ const MESSAGE_ACTION_REFUSAL_STATUSES = Object.freeze([409, 422]);
22
24
  const SESSION_INGEST_LIMIT_PER_MINUTE = 500;
23
25
  const HUMAN_MESSAGE_LIMIT_PER_MINUTE = 10;
24
26
  const HUMAN_MESSAGE_MAX_LENGTH = 2_000;
@@ -374,6 +376,18 @@ function recordCircuitFailure(circuit, nowMs) {
374
376
  persistCircuitState();
375
377
  }
376
378
 
379
+ // 401 and 403 refuse one credential (an expired, revoked or narrower agent admission, or
380
+ // an identity the account has not granted). They say nothing about API health, so they
381
+ // never count toward the machine-wide outbound breaker; every other failure does.
382
+ function isCredentialRefusal(response) {
383
+ return Boolean(response) && (response.status === 401 || response.status === 403);
384
+ }
385
+
386
+ function recordOutboundFailure(response, nowMs) {
387
+ if (isCredentialRefusal(response)) return;
388
+ recordCircuitFailure(outboundCircuit, nowMs);
389
+ }
390
+
377
391
  function recordCircuitSuccess(circuit) {
378
392
  if (!circuit) {
379
393
  return;
@@ -1033,18 +1047,23 @@ export async function syncSessionEventToApi(
1033
1047
  if (response && response.status === 403) {
1034
1048
  const body = forbiddenBody;
1035
1049
  if (isIdentityForgeryBody(body)) {
1050
+ if (session.source === "session_admission") {
1051
+ // An admitted agent acts only on its admission: account-level agent grants
1052
+ // are neither created nor used for it.
1053
+ return { synced: false, reason: "api_403" };
1054
+ }
1036
1055
  const agentId = normalizeString(event?.agent?.id);
1037
1056
  if (!agentId || isReservedAgentIdForGrant(agentId)) {
1038
1057
  // Reserved or empty — server enforcement is intentional, no grant
1039
1058
  // attempt is meaningful. Treat as a normal 403 failure.
1040
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
1059
+ recordOutboundFailure(response, normalizedNowMs);
1041
1060
  return { synced: false, reason: "api_403" };
1042
1061
  }
1043
1062
  if (autoGrantAttemptedAgentIds.has(agentId)) {
1044
1063
  // We already tried to grant this identity in a prior event in this
1045
1064
  // process and either it succeeded but the server still says no, or
1046
1065
  // the grant failed. Either way, don't loop — surface the 403.
1047
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
1066
+ recordOutboundFailure(response, normalizedNowMs);
1048
1067
  return { synced: false, reason: "api_403" };
1049
1068
  }
1050
1069
  rememberAutoGrantAttempt(agentId);
@@ -1081,7 +1100,7 @@ export async function syncSessionEventToApi(
1081
1100
  // Treat 409 (already granted) as success — idempotent on the server.
1082
1101
  const grantIdempotent = grantStatus === 409;
1083
1102
  if (!grantOk && !grantIdempotent) {
1084
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
1103
+ recordOutboundFailure(grantResponse, normalizedNowMs);
1085
1104
  return {
1086
1105
  synced: false,
1087
1106
  reason: `grant_failed_${grantStatus || "no_response"}`,
@@ -1109,7 +1128,7 @@ export async function syncSessionEventToApi(
1109
1128
  autoGranted: true,
1110
1129
  };
1111
1130
  }
1112
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
1131
+ recordOutboundFailure(retryResponse, normalizedNowMs);
1113
1132
  return {
1114
1133
  synced: false,
1115
1134
  reason: `api_${retryResponse ? retryResponse.status : "no_response"}`,
@@ -1117,7 +1136,7 @@ export async function syncSessionEventToApi(
1117
1136
  }
1118
1137
  }
1119
1138
 
1120
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
1139
+ recordOutboundFailure(response, normalizedNowMs);
1121
1140
  return {
1122
1141
  synced: false,
1123
1142
  reason: `api_${response ? response.status : "no_response"}`,
@@ -1207,7 +1226,7 @@ async function syncSessionAuxPayload(
1207
1226
  { readSuccessBody: false },
1208
1227
  );
1209
1228
  if (!response || !response.ok) {
1210
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
1229
+ recordOutboundFailure(response, normalizedNowMs);
1211
1230
  return {
1212
1231
  synced: false,
1213
1232
  reason: `api_${response ? response.status : "no_response"}`,
@@ -1843,7 +1862,7 @@ export async function renewSessionPresence(
1843
1862
  retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
1844
1863
  };
1845
1864
  }
1846
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
1865
+ recordOutboundFailure(response, normalizedNowMs);
1847
1866
  return {
1848
1867
  ok: false,
1849
1868
  reason: `api_${response ? response.status : "no_response"}`,
@@ -1958,7 +1977,7 @@ export async function requestSessionListenerStop(
1958
1977
  retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
1959
1978
  };
1960
1979
  }
1961
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
1980
+ recordOutboundFailure(response, normalizedNowMs);
1962
1981
  return {
1963
1982
  ok: false,
1964
1983
  reason: `api_${response ? response.status : "no_response"}`,
@@ -2083,7 +2102,7 @@ export async function updateSessionReadCursor(
2083
2102
  updated: false,
2084
2103
  };
2085
2104
  }
2086
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
2105
+ recordOutboundFailure(response, normalizedNowMs);
2087
2106
  return {
2088
2107
  ok: false,
2089
2108
  reason: `api_${response ? response.status : "no_response"}`,
@@ -2990,12 +3009,20 @@ export async function createSessionMessageAction(
2990
3009
  },
2991
3010
  normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
2992
3011
  fetchImpl,
3012
+ { errorBodyStatuses: MESSAGE_ACTION_REFUSAL_STATUSES },
2993
3013
  );
2994
3014
  if (!response || !response.ok) {
2995
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
3015
+ const status = response ? response.status : null;
3016
+ const refused = MESSAGE_ACTION_REFUSAL_STATUSES.includes(status);
3017
+ // A refusal (409 nothing-to-undo, 422 a server that predates the action) is an
3018
+ // answer about this request, not API health: it must not open the persisted
3019
+ // breaker that guards every other outbound write.
3020
+ if (!refused) recordOutboundFailure(response, normalizedNowMs);
2996
3021
  return {
2997
3022
  ok: false,
2998
- reason: `api_${response ? response.status : "no_response"}`,
3023
+ reason: `api_${status ?? "no_response"}`,
3024
+ status,
3025
+ error: refused && payload && typeof payload === "object" ? payload : null,
2999
3026
  action: null,
3000
3027
  };
3001
3028
  }
@@ -3004,6 +3031,9 @@ export async function createSessionMessageAction(
3004
3031
  ok: Boolean(payload?.ok ?? true),
3005
3032
  reason: "",
3006
3033
  duplicate: Boolean(payload?.duplicate),
3034
+ // A repeat reaction is a no-op whose key the server retains as a collapsed row
3035
+ // (sentinelayer-api#914): the id of that evidence row.
3036
+ collapsedActionId: normalizeString(payload?.collapsedActionId) || null,
3007
3037
  action: payload?.action && typeof payload.action === "object" ? payload.action : null,
3008
3038
  };
3009
3039
  } catch (error) {