@agentcomms/whatsapp 0.22.0 → 0.23.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.
@@ -11,7 +11,7 @@ import { execFile, spawn } from "node:child_process";
11
11
  //#endregion
12
12
  //#region src/version.ts
13
13
  /** The package version, read from package.json at build time. */
14
- const VERSION$2 = "0.22.0";
14
+ const VERSION$2 = "0.23.0";
15
15
  //#endregion
16
16
  //#region src/caller.ts
17
17
  /** This package's name, as its manifest has it. */
@@ -28,7 +28,7 @@ const CALLER = Object.freeze({
28
28
  packageName: PACKAGE_NAME
29
29
  });
30
30
  //#endregion
31
- //#region ../core/dist/update-check-DQ7AAwLg.mjs
31
+ //#region ../core/dist/update-check-BrR1qAfk.mjs
32
32
  /**
33
33
  * Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
34
34
  * optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
@@ -96,6 +96,16 @@ const ERROR_REGISTRY = {
96
96
  retryable: false,
97
97
  summary: "a behaviour rule does not allow this act here"
98
98
  },
99
+ STANDING_REFUSED: {
100
+ exit: EXIT_CODES.APPROVAL,
101
+ retryable: false,
102
+ summary: "this standing approval does not cover this act"
103
+ },
104
+ STANDING_LIMIT: {
105
+ exit: EXIT_CODES.APPROVAL,
106
+ retryable: false,
107
+ summary: "a standing approval's limit is reached"
108
+ },
99
109
  RATE_CAPPED: {
100
110
  exit: EXIT_CODES.APPROVAL,
101
111
  retryable: true,
@@ -1019,6 +1029,9 @@ function nameProblem(name) {
1019
1029
  if (FORBIDDEN_NAME_CHARACTERS.test(name)) return "the name holds a parenthesis or one of <>\"@,;:\\";
1020
1030
  for (const char of name) if (isDangerous(char.codePointAt(0) ?? 0)) return "the name holds a hidden or control character";
1021
1031
  }
1032
+ /** `<name> (<word>)`: a name holds no parenthesis or line break (`nameProblem`), so the first `(` is the word's. */
1033
+ const LABEL_PATTERN = `[^()\\n]{1,160} \\((?:${AGENT_LABEL_WORDS.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\)`;
1034
+ new RegExp(`^\\s*(?:${LABEL_PATTERN}: |Sent by ${LABEL_PATTERN}(?:$|\\s))`, "iu");
1022
1035
  const ruleKey = (rule) => JSON.stringify([
1023
1036
  rule.account,
1024
1037
  rule.accountId ?? null,
@@ -1428,12 +1441,17 @@ function sameDisclosureBinding(left, right) {
1428
1441
  return false;
1429
1442
  }
1430
1443
  }
1431
- /** The lifetime profile, in milliseconds: pending on each route, approved, and a download's question. */
1444
+ /**
1445
+ * The lifetime profile, in milliseconds: pending on each route, approved, a download's question, and a standing
1446
+ * approval's child — usable once, within two minutes of the use that minted it. A standing record's own approved
1447
+ * lifetime is its days (`approvedMs`, checked against its limits), not one of these.
1448
+ */
1432
1449
  const APPROVAL_LIFETIMES = Object.freeze({
1433
1450
  chat: 6e5,
1434
1451
  confirm: 18e5,
1435
1452
  approved: 864e5,
1436
- download: 18e5
1453
+ download: 18e5,
1454
+ standingChild: 12e4
1437
1455
  });
1438
1456
  /**
1439
1457
  * The sending lease (design 2026-10-05 §D1): a `sending` record whose claimant has not renewed it for this long reads
@@ -1452,8 +1470,9 @@ function unknownAtOf(record) {
1452
1470
  const at = Date.parse(renewed);
1453
1471
  return Number.isFinite(at) ? new Date(at + SENDING_LEASE_MS).toISOString() : void 0;
1454
1472
  }
1455
- /** How long a pending send or change on `route` stays open. */
1473
+ /** How long a pending send or change on `route` stays open; a standing child is never pending, and lasts its own. */
1456
1474
  function pendingMsOf(route) {
1475
+ if (route === "standing") return APPROVAL_LIFETIMES.standingChild;
1457
1476
  return route === "confirm" ? APPROVAL_LIFETIMES.confirm : APPROVAL_LIFETIMES.chat;
1458
1477
  }
1459
1478
  /** Whether `channel` is one this release knows: a manifest channel of the generated snapshot. */
@@ -1464,9 +1483,20 @@ function isKnownChannel(channel) {
1464
1483
  function requireKnownChannel(channel) {
1465
1484
  if (!isKnownChannel(channel)) throw new CommsError("UNEXPECTED", `an approval cannot be made for the channel "${channel}": this release does not know it`, { hint: "This is a bug — please report it." });
1466
1485
  }
1486
+ /** Why `use` is not a child's use of its standing approval, or null: three strings, its content a SHA-256. */
1487
+ function standingUseProblem(use) {
1488
+ if (typeof use !== "object" || use === null || Array.isArray(use)) return "it names no use";
1489
+ const fields = use;
1490
+ if (Object.keys(fields).some((key) => key !== "channel" && key !== "thread" && key !== "content")) return "it names more than its use";
1491
+ for (const field of ["channel", "thread"]) if (typeof fields[field] !== "string" || fields[field] === "") return `its use has no ${field}`;
1492
+ if (typeof fields.content !== "string" || !/^[0-9a-f]{64}$/.test(fields.content)) return "its use names no content";
1493
+ return null;
1494
+ }
1467
1495
  /**
1468
1496
  * The identity a record is bound to, exactly as stored. An absent `inboxSub` is left out rather than kept as
1469
- * `undefined`; a send always carries its epoch.
1497
+ * `undefined`; a send and a standing record always carry their send epoch. A standing record and its children carry
1498
+ * the standing epoch, and a child its standing id — each only when present, so every other record's identity, and its
1499
+ * digest, is exactly what it was before standing approvals existed.
1470
1500
  */
1471
1501
  function identityOf(record) {
1472
1502
  return {
@@ -1483,7 +1513,14 @@ function identityOf(record) {
1483
1513
  bcc: [...record.expect.bcc],
1484
1514
  subject: record.expect.subject
1485
1515
  },
1486
- ...record.kind === "send" ? { sendEpoch: record.sendEpoch } : {}
1516
+ ...record.kind === "send" || record.kind === "standing" ? { sendEpoch: record.sendEpoch } : {},
1517
+ ...record.standingEpoch === void 0 ? {} : { standingEpoch: record.standingEpoch },
1518
+ ...record.standingId === void 0 ? {} : { standingId: record.standingId },
1519
+ ...record.standingUse === void 0 ? {} : { standingUse: {
1520
+ channel: record.standingUse.channel,
1521
+ thread: record.standingUse.thread,
1522
+ content: record.standingUse.content
1523
+ } }
1487
1524
  };
1488
1525
  }
1489
1526
  /** One listed file, as stored: its name and size, and its rename reason and flags only when it has them. */
@@ -2986,12 +3023,12 @@ function collectFromJson(parsed, client, path, topScope = "user") {
2986
3023
  function collectFromToml(text, client, path) {
2987
3024
  const servers = parseToml(text).mcp_servers;
2988
3025
  if (servers === void 0) return [];
2989
- if (!isRecord$1(servers)) throw new TomlError("mcp_servers is not a table");
3026
+ if (!isRecord$2(servers)) throw new TomlError("mcp_servers is not a table");
2990
3027
  const found = [];
2991
3028
  for (const [name, entry] of Object.entries(servers)) {
2992
- if (!isRecord$1(entry)) throw new TomlError(`mcp_servers.${name} is not a table`);
3029
+ if (!isRecord$2(entry)) throw new TomlError(`mcp_servers.${name} is not a table`);
2993
3030
  if (entry.args !== void 0 && !Array.isArray(entry.args)) throw new TomlError(`mcp_servers.${name}.args is not an array`);
2994
- if (entry.env !== void 0 && !isRecord$1(entry.env)) throw new TomlError(`mcp_servers.${name}.env is not a table`);
3031
+ if (entry.env !== void 0 && !isRecord$2(entry.env)) throw new TomlError(`mcp_servers.${name}.env is not a table`);
2995
3032
  found.push({
2996
3033
  client,
2997
3034
  path,
@@ -2999,12 +3036,12 @@ function collectFromToml(text, client, path) {
2999
3036
  command: typeof entry.command === "string" ? entry.command : "",
3000
3037
  args: Array.isArray(entry.args) ? entry.args.map(String) : [],
3001
3038
  ...typeof entry.url === "string" ? { url: entry.url } : {},
3002
- ...isRecord$1(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
3039
+ ...isRecord$2(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
3003
3040
  });
3004
3041
  }
3005
3042
  return found;
3006
3043
  }
3007
- function isRecord$1(value) {
3044
+ function isRecord$2(value) {
3008
3045
  return typeof value === "object" && value !== null && !Array.isArray(value);
3009
3046
  }
3010
3047
  /**
@@ -3017,8 +3054,8 @@ function isRecord$1(value) {
3017
3054
  */
3018
3055
  function codexServerFromGet(name, stdout, path) {
3019
3056
  const answer = JSON.parse(stdout);
3020
- if (!isRecord$1(answer)) throw new SyntaxError("not an object");
3021
- const entry = isRecord$1(answer.transport) ? answer.transport : answer;
3057
+ if (!isRecord$2(answer)) throw new SyntaxError("not an object");
3058
+ const entry = isRecord$2(answer.transport) ? answer.transport : answer;
3022
3059
  const command = typeof entry.command === "string" ? entry.command : "";
3023
3060
  const url = typeof entry.url === "string" ? entry.url : void 0;
3024
3061
  if (!command && !url) throw new SyntaxError("neither a command nor a URL");
@@ -3030,7 +3067,7 @@ function codexServerFromGet(name, stdout, path) {
3030
3067
  command,
3031
3068
  args: Array.isArray(entry.args) ? entry.args.map(String) : [],
3032
3069
  ...url ? { url } : {},
3033
- ...isRecord$1(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
3070
+ ...isRecord$2(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
3034
3071
  };
3035
3072
  return {
3036
3073
  ...server,
@@ -3136,7 +3173,7 @@ async function scanRegisteredServers(env = process.env, platform = process.platf
3136
3173
  continue;
3137
3174
  }
3138
3175
  add(collectFromJson(parsed, file.client, file.path));
3139
- if (file.client !== "claude-code" || !isRecord$1(parsed) || !isRecord$1(parsed.projects)) continue;
3176
+ if (file.client !== "claude-code" || !isRecord$2(parsed) || !isRecord$2(parsed.projects)) continue;
3140
3177
  for (const root of Object.keys(parsed.projects)) {
3141
3178
  if (!isAbsolute(root)) continue;
3142
3179
  const path = join(root, ".mcp.json");
@@ -6141,7 +6178,7 @@ const BEHAVIOUR_ACTS = Object.freeze([
6141
6178
  "file",
6142
6179
  "send"
6143
6180
  ]);
6144
- function isRecord$2(value) {
6181
+ function isRecord$1(value) {
6145
6182
  return typeof value === "object" && value !== null && !Array.isArray(value);
6146
6183
  }
6147
6184
  /**
@@ -6188,7 +6225,7 @@ function accountNamed(written, names) {
6188
6225
  /** One stored rule, or why it cannot be read. Reasons are fixed words: nothing from the file is repeated in them. */
6189
6226
  function readRule(raw, index, names) {
6190
6227
  const at = `rule ${index + 1}`;
6191
- if (!isRecord$2(raw)) return { unreadable: {
6228
+ if (!isRecord$1(raw)) return { unreadable: {
6192
6229
  account: null,
6193
6230
  reason: `${at} is not an object`
6194
6231
  } };
@@ -6244,7 +6281,7 @@ function readBehaviourSettings(config) {
6244
6281
  rules: [],
6245
6282
  unreadable: []
6246
6283
  };
6247
- if (!isRecord$2(stored)) return unreadableWhole("the behaviour setting is not an object");
6284
+ if (!isRecord$1(stored)) return unreadableWhole("the behaviour setting is not an object");
6248
6285
  const rules = stored.rules;
6249
6286
  if (rules === void 0) return {
6250
6287
  rules: [],
@@ -6551,7 +6588,7 @@ Object.freeze({
6551
6588
  */
6552
6589
  function behaviourRenamed(config, id, from, to) {
6553
6590
  const stored = config.behaviour;
6554
- if (!isRecord$2(stored)) return stored;
6591
+ if (!isRecord$1(stored)) return stored;
6555
6592
  const rules = stored.rules;
6556
6593
  if (!Array.isArray(rules)) return stored;
6557
6594
  const held = new Set([...Object.values(config.inboxes ?? {}), ...Object.values(config.accounts ?? {})].map((entry) => entry.id));
@@ -6562,7 +6599,7 @@ function behaviourRenamed(config, id, from, to) {
6562
6599
  };
6563
6600
  return {
6564
6601
  ...stored,
6565
- rules: rules.map((rule) => isRecord$2(rule) && holds(rule) ? {
6602
+ rules: rules.map((rule) => isRecord$1(rule) && holds(rule) ? {
6566
6603
  ...rule,
6567
6604
  account: to
6568
6605
  } : rule)
@@ -6949,6 +6986,7 @@ const configV2Schema = looseObject({
6949
6986
  /** What version 3 adds, loose as the rest of the file is: a later release's keys inside them are kept. */
6950
6987
  const v3Shape = {
6951
6988
  sendEpochs: record(string().min(1), number().int().min(0)).optional(),
6989
+ standingEpoch: number().int().min(0).optional(),
6952
6990
  legacyDrain: looseObject({
6953
6991
  since: absoluteTimeSchema,
6954
6992
  tracked: record(string().min(1), _enum(["open", ...[
@@ -7192,7 +7230,7 @@ var ConfigStore = class {
7192
7230
  });
7193
7231
  }
7194
7232
  }
7195
- const written = withSendEpochs(current, parsed.data);
7233
+ const written = withStandingEpoch(current, withSendEpochs(current, parsed.data));
7196
7234
  const serialized = `${JSON.stringify(written, null, 2)}\n`;
7197
7235
  await writeFileAtomic(this.path, serialized, 384, options.signal);
7198
7236
  committed = written;
@@ -7375,6 +7413,46 @@ function withSendEpochs(before, after) {
7375
7413
  sendEpochs: epochs
7376
7414
  };
7377
7415
  }
7416
+ /**
7417
+ * The standing epoch in `config` (design 2026-10-19-standing-approvals, S8): its stored value, or 0 when it has none —
7418
+ * or when it is not a whole number of 0 or more, which no write of this release leaves.
7419
+ */
7420
+ function standingEpochOf(config) {
7421
+ const epoch = config.standingEpoch;
7422
+ return typeof epoch === "number" && Number.isInteger(epoch) && epoch >= 0 ? epoch : 0;
7423
+ }
7424
+ /** The change policy of every inbox and account by id, each its own or the default. */
7425
+ function changePoliciesById(config) {
7426
+ const policies = /* @__PURE__ */ new Map();
7427
+ for (const inbox of Object.values(config.inboxes)) policies.set(inbox.id, inbox.changePolicy ?? defaultChangePolicy(config));
7428
+ for (const account of Object.values(config.accounts)) policies.set(account.id, account.changePolicy ?? defaultChangePolicy(config));
7429
+ return policies;
7430
+ }
7431
+ /**
7432
+ * Whether this write turns a change policy from `chat` to `confirm`: the default's, or the effective one of an inbox or
7433
+ * account there before and after, by id.
7434
+ */
7435
+ function tightensAChangePolicy(before, after) {
7436
+ if (defaultChangePolicy(before) === "chat" && defaultChangePolicy(after) === "confirm") return true;
7437
+ const was = changePoliciesById(before);
7438
+ for (const [id, policy] of changePoliciesById(after)) if (policy === "confirm" && was.get(id) === "chat") return true;
7439
+ return false;
7440
+ }
7441
+ /**
7442
+ * `after`, with the standing epoch this write owes (design 2026-10-19-standing-approvals, S8): on version 3, the higher
7443
+ * of the two it holds — never lowered, whatever the write's own copy says, nor dropped — and one more when it tightens a
7444
+ * change policy (`tightensAChangePolicy`). Before version 3 there is no epoch, and no standing approval: every one is
7445
+ * made on version 3.
7446
+ */
7447
+ function withStandingEpoch(before, after) {
7448
+ if (before.version !== 3 || after.version !== 3) return after;
7449
+ const held = Math.max(standingEpochOf(before), standingEpochOf(after));
7450
+ const kept = tightensAChangePolicy(before, after) ? held + 1 : held;
7451
+ return kept === 0 && after.standingEpoch === void 0 ? after : {
7452
+ ...after,
7453
+ standingEpoch: kept
7454
+ };
7455
+ }
7378
7456
  async function readFileIfExists(path) {
7379
7457
  try {
7380
7458
  return await readFile(path, "utf8");
@@ -7859,23 +7937,53 @@ function ownerEntry(config, ownerId) {
7859
7937
  */
7860
7938
  function liveGateOf(config, record) {
7861
7939
  const governing = (fallback) => record.kind === "change" && record.change !== void 0 ? governingChangePolicy(config, record.change) : fallback;
7940
+ const standingEpoch = standingEpochOf(config);
7862
7941
  if (record.ownerScope !== "owner") return {
7863
7942
  owner: "none",
7864
- changePolicy: governing(defaultChangePolicy(config))
7943
+ changePolicy: governing(defaultChangePolicy(config)),
7944
+ standingEpoch
7865
7945
  };
7866
7946
  const entry = ownerEntry(config, record.inboxId);
7867
- if (entry === void 0) return { owner: "removed" };
7947
+ if (entry === void 0) return {
7948
+ owner: "removed",
7949
+ standingEpoch
7950
+ };
7868
7951
  return {
7869
7952
  owner: "present",
7870
7953
  sendPolicy: entry.sendPolicy ?? config.defaults.sendPolicy,
7871
7954
  changePolicy: governing(entry.changePolicy ?? defaultChangePolicy(config)),
7872
- sendEpoch: sendEpochOf(config, record.inboxId)
7955
+ sendEpoch: sendEpochOf(config, record.inboxId),
7956
+ standingEpoch
7873
7957
  };
7874
7958
  }
7875
7959
  /** The reason a record whose owner was removed is revoked with. */
7876
7960
  const OWNER_REMOVED_REASON = "its mailbox or account was removed";
7877
7961
  /** The reason a send prepared before a `never` is revoked with: its stored epoch is behind the live one. */
7878
7962
  const SEND_EPOCH_REASON = "sending was turned off since this was prepared (policy: never)";
7963
+ /**
7964
+ * The reason a standing approval, or one of its unused children, is revoked with once standing approvals were ended
7965
+ * since it was made: its standing epoch is behind the live one (design 2026-10-19-standing-approvals, S8).
7966
+ */
7967
+ const STANDING_EPOCH_REASON = "standing approvals were ended since this was approved";
7968
+ /**
7969
+ * The reason a standing approval is revoked with once its account's change policy is stricter than the one it was
7970
+ * approved under (`approvedChangePolicy`) — `changePolicy-tightened`, read from the configuration itself, whatever the
7971
+ * standing epoch says, so a release that knows no epoch tightening the policy still ends it (design
7972
+ * 2026-10-19-standing-approvals, S8, the lead's call after review).
7973
+ */
7974
+ const CHANGE_POLICY_TIGHTENED_REASON = "the change policy was tightened since this was approved";
7975
+ /** Whether `live` is stricter than `approved`: `confirm` over `chat`. */
7976
+ function changePolicyTightened(approved, live) {
7977
+ return live === "confirm" && approved !== "confirm";
7978
+ }
7979
+ /**
7980
+ * Whether even a look writes this outcome's revocation down: a standing approval ended by a tightened change policy.
7981
+ * Every other revocation a look derives is from something that never goes back — an epoch, an owner's id — so the next
7982
+ * action can write it; a policy can be loosened again, and what it ended must stay ended (S8).
7983
+ */
7984
+ function writtenAtALook(outcome) {
7985
+ return outcome.revokes && outcome.record?.kind === "standing" && outcome.reason === "the change policy was tightened since this was approved";
7986
+ }
7879
7987
  /** What an outcome says of a pending or approved send under a live `never`, before anything revokes it. */
7880
7988
  const NEVER_NOTICE = "sending is turned off (policy: never); any use revokes it";
7881
7989
  /** The refusal of a claim, an approval or an answer by a store opened without a configuration. */
@@ -7884,7 +7992,11 @@ const NO_CONFIGURATION = "this approval store was opened without a configuration
7884
7992
  const sentAtWords = (usedAt) => `sent at ${usedAt}`;
7885
7993
  /** The refusal prefix and detail key of each kind: a send sends nothing, a change changes nothing, a question saves nothing. */
7886
7994
  function refusalOf(kind) {
7887
- const [prefix, idKey, hint] = kind === "disclosure" ? [
7995
+ const [prefix, idKey, hint] = kind === "standing" ? [
7996
+ "nothing was sent",
7997
+ "approvalId",
7998
+ "This standing approval cannot be used. Ask the person instead; only they can set up another, at their own terminal."
7999
+ ] : kind === "disclosure" ? [
7888
8000
  "nothing was disclosed",
7889
8001
  "approvalId",
7890
8002
  "Prepare a new standing disclosure authorisation and show its exact activation preview to the person."
@@ -7915,7 +8027,7 @@ function refusalOf(kind) {
7915
8027
  * or one a pinned surface may not see — byte for byte the same, with `approval: null`, and nothing of any record in it.
7916
8028
  */
7917
8029
  function approvalNotFound(approvalId, kind) {
7918
- return new CommsError("NOT_FOUND", `${kind === "disclosure" ? "nothing was disclosed" : kind === "change" ? "nothing was changed" : kind === "download" ? "nothing was saved" : "nothing was sent"}: no approval ${approvalId}`, {
8030
+ return new CommsError("NOT_FOUND", `${kind === "disclosure" ? "nothing was disclosed" : kind === "standing" ? "nothing was sent" : kind === "change" ? "nothing was changed" : kind === "download" ? "nothing was saved" : "nothing was sent"}: no approval ${approvalId}`, {
7919
8031
  hint: "Check the id: an approval is only found by the surface, the kind and the owner it was prepared for.",
7920
8032
  details: { approval: null }
7921
8033
  });
@@ -8083,12 +8195,20 @@ function classifyV2(read, context) {
8083
8195
  const ownerRemoved = read.ownerScope === "owner" && live?.owner === "removed";
8084
8196
  const active = read.state === "pending" || read.state === "approved";
8085
8197
  const acting = action === "approve" || action === "claim";
8086
- const never = read.kind === "send" && live?.owner === "present" && stricterPolicy(live.sendPolicy ?? "never", read.requiredPolicy) === "never";
8198
+ const sends = read.kind === "send" || read.kind === "standing";
8199
+ const standingBound = read.kind === "standing" || read.kind === "send" && read.route === "standing";
8200
+ const never = sends && live?.owner === "present" && stricterPolicy(live.sendPolicy ?? "never", read.requiredPolicy) === "never";
8087
8201
  let derived = null;
8202
+ let revokedByNever = false;
8088
8203
  if (active && ownerRemoved) derived = OWNER_REMOVED_REASON;
8089
8204
  else if (active && never) {
8090
- if (acting) derived = live?.sendPolicy === "never" ? `sending is turned off for this ${accountNoun(read.channel)} (policy: never)` : "sending is turned off for this approval (policy: never)";
8091
- } else if (active && read.kind === "send" && live?.owner === "present" && (read.sendEpoch ?? 0) !== live.sendEpoch) derived = SEND_EPOCH_REASON;
8205
+ if (acting) {
8206
+ derived = live?.sendPolicy === "never" ? `sending is turned off for this ${accountNoun(read.channel)} (policy: never)` : "sending is turned off for this approval (policy: never)";
8207
+ revokedByNever = true;
8208
+ }
8209
+ } else if (active && sends && live?.owner === "present" && (read.sendEpoch ?? 0) !== live.sendEpoch) derived = SEND_EPOCH_REASON;
8210
+ else if (active && standingBound && live !== null && live.standingEpoch !== void 0 && read.standingEpoch !== live.standingEpoch) derived = STANDING_EPOCH_REASON;
8211
+ else if (read.kind === "standing" && read.state === "approved" && live?.owner === "present" && changePolicyTightened(read.approvedChangePolicy, live.changePolicy)) derived = CHANGE_POLICY_TIGHTENED_REASON;
8092
8212
  const record = derived === null ? read : {
8093
8213
  ...read,
8094
8214
  state: "revoked",
@@ -8096,7 +8216,6 @@ function classifyV2(read, context) {
8096
8216
  revokedAt: at,
8097
8217
  challengeHash: void 0
8098
8218
  };
8099
- const revokedByNever = derived !== null && derived !== "its mailbox or account was removed" && derived !== "sending was turned off since this was prepared (policy: never)";
8100
8219
  let claimable = false;
8101
8220
  let reason = record.reason;
8102
8221
  if (record.state === "pending" || record.state === "approved") {
@@ -8162,6 +8281,7 @@ function isClaimable(record, live) {
8162
8281
  case "change": return record.state === "approved" || record.route === "chat" && live.changePolicy === "chat";
8163
8282
  case "download": return downloadClaimable(record, live.changePolicy);
8164
8283
  case "disclosure": return record.state === "approved";
8284
+ case "standing": return false;
8165
8285
  }
8166
8286
  }
8167
8287
  /**
@@ -8210,7 +8330,7 @@ function errorOf(record, approval, context) {
8210
8330
  if (download) return refuse("APPROVAL_EXPIRED", downloadExpiredWords(record.approvedVia !== void 0), record, approval);
8211
8331
  const nothing = `nothing was ${record.kind === "change" ? "changed" : "sent"} with it`;
8212
8332
  return new CommsError("APPROVAL_EXPIRED", record.reason === "clock-anomaly" ? `the clock moved backwards; this approval was expired safely at ${record.expiredAt}; ${nothing}` : record.approvedAt !== void 0 ? `this approval expired; ${nothing}: approved at ${record.approvedAt}, expired unused at ${record.expiredAt}` : `this approval expired; ${nothing}: prepared at ${record.createdAt}, expired at ${record.expiredAt}`, {
8213
- hint: record.kind === "change" ? "Prepare the change again and show the new preview to the user." : "Prepare the send again and show the new preview to the user.",
8333
+ hint: record.kind === "change" ? "Prepare the change again and show the new preview to the user." : record.kind === "standing" ? "This standing approval has ended. Ask the person instead; only they can set up another, at their own terminal." : "Prepare the send again and show the new preview to the user.",
8214
8334
  details: {
8215
8335
  approvalId: record.approvalId,
8216
8336
  state: record.state,
@@ -8294,6 +8414,578 @@ var AuditLog = class {
8294
8414
  }
8295
8415
  };
8296
8416
  /**
8417
+ * The emoji a standing approval may react with (design 2026-10-19-standing-approvals, S3 and §8.4): a curated list of
8418
+ * standard Unicode names, each with its code point, shipped in the code.
8419
+ *
8420
+ * Only standard names, because a custom workspace emoji's image can be replaced under the same name: the person would
8421
+ * have approved a picture that is no longer the one sent. And a short list rather than every Unicode name, because each
8422
+ * one here is a word the agent may say on the person's behalf without asking.
8423
+ */
8424
+ const STANDARD_EMOJI = Object.freeze({
8425
+ eyes: "U+1F440",
8426
+ white_check_mark: "U+2705",
8427
+ heavy_check_mark: "U+2714",
8428
+ ballot_box_with_check: "U+2611",
8429
+ "+1": "U+1F44D",
8430
+ thumbsup: "U+1F44D",
8431
+ ok: "U+1F197",
8432
+ ok_hand: "U+1F44C",
8433
+ "100": "U+1F4AF",
8434
+ heavy_plus_sign: "U+2795",
8435
+ handshake: "U+1F91D",
8436
+ raised_hands: "U+1F64C",
8437
+ pray: "U+1F64F",
8438
+ clap: "U+1F44F",
8439
+ wave: "U+1F44B",
8440
+ memo: "U+1F4DD",
8441
+ pencil2: "U+270F",
8442
+ hourglass_flowing_sand: "U+23F3",
8443
+ stopwatch: "U+23F1",
8444
+ inbox_tray: "U+1F4E5",
8445
+ pushpin: "U+1F4CC",
8446
+ mag: "U+1F50D",
8447
+ bulb: "U+1F4A1",
8448
+ speech_balloon: "U+1F4AC",
8449
+ thinking_face: "U+1F914",
8450
+ slightly_smiling_face: "U+1F642",
8451
+ heart: "U+2764",
8452
+ tada: "U+1F389",
8453
+ rocket: "U+1F680",
8454
+ sparkles: "U+2728"
8455
+ });
8456
+ /**
8457
+ * The emoji among them that people read as a yes: ✅ on "can I deploy?" is taken for approval, and a Slack workflow
8458
+ * can start on a reaction (S3). Allowed, with a warning in the preview.
8459
+ */
8460
+ const READS_AS_APPROVAL = Object.freeze([
8461
+ "white_check_mark",
8462
+ "heavy_check_mark",
8463
+ "ballot_box_with_check",
8464
+ "+1",
8465
+ "thumbsup",
8466
+ "ok",
8467
+ "ok_hand",
8468
+ "100",
8469
+ "heavy_plus_sign",
8470
+ "handshake"
8471
+ ]);
8472
+ /** Whether `name` is one of the standard emoji a standing approval may hold, exactly as listed. */
8473
+ function isStandardEmoji(name) {
8474
+ return Object.hasOwn(STANDARD_EMOJI, name);
8475
+ }
8476
+ Object.freeze(["reaction", "reply"]);
8477
+ /** S3: what a standing approval holds when no content is given. */
8478
+ const STANDING_DEFAULT_REACTION = "eyes";
8479
+ /** One day, the unit a standing approval's expiry is counted in. */
8480
+ const STANDING_DAY_MS = 864e5;
8481
+ /** S6's table: each limit's default and the range a person may set it to. */
8482
+ const STANDING_LIMITS = Object.freeze({
8483
+ perThread: Object.freeze({
8484
+ default: 1,
8485
+ min: 1,
8486
+ max: 3
8487
+ }),
8488
+ perHour: Object.freeze({
8489
+ default: 10,
8490
+ min: 1,
8491
+ max: 30
8492
+ }),
8493
+ perDay: Object.freeze({
8494
+ default: 30,
8495
+ min: 1,
8496
+ max: 100
8497
+ }),
8498
+ perSender: Object.freeze({
8499
+ default: 3,
8500
+ min: 1,
8501
+ max: 10
8502
+ }),
8503
+ expiresDays: Object.freeze({
8504
+ default: 7,
8505
+ min: 1,
8506
+ max: 30
8507
+ })
8508
+ });
8509
+ const LIMIT_NAMES$1 = [
8510
+ "perThread",
8511
+ "perHour",
8512
+ "perDay",
8513
+ "perSender",
8514
+ "expiresDays"
8515
+ ];
8516
+ const CONTENT_WORDS = Object.freeze({
8517
+ "too-many-reactions": `a standing approval holds at most 5 reactions`,
8518
+ "too-many-replies": `a standing approval holds at most 5 replies`,
8519
+ "custom-emoji": "only a standard emoji from the list this release ships may be a standing reaction",
8520
+ empty: "a standing reply has no words",
8521
+ "too-long": `a standing reply is at most 200 characters`,
8522
+ "multi-line": "a standing reply is one line",
8523
+ "control-character": "a standing reply holds a control or invisible character",
8524
+ mention: "a standing reply mentions nobody: no @, and no <@…> or <!…>",
8525
+ link: "a standing reply holds no link, address or domain",
8526
+ placeholder: "a standing reply is sent exactly as written: it holds no placeholder",
8527
+ markup: "a standing reply is plain text: no formatting characters",
8528
+ "emoji-code": "a standing reply holds no :emoji: code; a reaction is its own content",
8529
+ pictograph: "a standing reply holds no emoji or pictograph, such as ✅, ✔ or 👍; a reaction is its own content",
8530
+ duplicate: "a standing approval holds each content once"
8531
+ });
8532
+ /** The refusal of a content, as BAD_DATA, with its reason and which content it was. */
8533
+ function contentRefused(reason, where) {
8534
+ return new CommsError("BAD_DATA", `nothing was prepared: ${CONTENT_WORDS[reason]}`, {
8535
+ hint: reason === "custom-emoji" ? `Choose from the standard emoji: ${Object.keys(STANDARD_EMOJI).join(", ")}.` : "Write each reply as plain words on one line, at most 200 characters: no links, mentions, markup, placeholders, emoji or emoji codes. A reply is sent exactly as written.",
8536
+ details: {
8537
+ reason,
8538
+ ...where === void 0 ? {} : {
8539
+ kind: where.kind,
8540
+ position: where.position
8541
+ }
8542
+ }
8543
+ });
8544
+ }
8545
+ /** The digest of one content: its kind and exact text. */
8546
+ function standingContentDigest(kind, text) {
8547
+ return sha256Hex(canonicalJson$1({
8548
+ kind,
8549
+ text
8550
+ }));
8551
+ }
8552
+ /** A reaction's name, exactly as listed — `:name:` read as `name` — or the refusal of a custom one. */
8553
+ function reactionName(written, position) {
8554
+ const trimmed = written.trim();
8555
+ const name = /^:[^:]+:$/.test(trimmed) ? trimmed.slice(1, -1) : trimmed;
8556
+ if (!isStandardEmoji(name)) throw contentRefused("custom-emoji", {
8557
+ kind: "reaction",
8558
+ position
8559
+ });
8560
+ return name;
8561
+ }
8562
+ /** Each reason a reply's text is refused for, in the order checked: the first that applies is the one given. */
8563
+ const REPLY_RULES = Object.freeze([
8564
+ ["multi-line", (text) => /[\r\n\u2028\u2029]/.test(text)],
8565
+ ["pictograph", (text) => /[\p{Extended_Pictographic}\u2713]/u.test(text)],
8566
+ ["control-character", (text) => [...text].some((char) => char === " " || isDangerous(char.codePointAt(0) ?? 0))],
8567
+ ["mention", (text) => /@|<[@!#]/.test(text)],
8568
+ ["link", (text) => /:\/\/|www\.|mailto:|[A-Za-z0-9-]+\.[A-Za-z]{2,}/i.test(text)],
8569
+ ["placeholder", (text) => /[{}[\]]/.test(text)],
8570
+ ["markup", (text) => /[<>*_~`|]/.test(text)],
8571
+ ["emoji-code", (text) => /:[A-Za-z0-9_+-]+:/.test(text)]
8572
+ ]);
8573
+ /**
8574
+ * Words that read as a yes in a reply (the lead's call, after review): "OK" or "Done" on "can I deploy?" is taken for
8575
+ * approval as surely as ✅ is. Matched whole, in any case. Allowed, with a warning in the preview.
8576
+ */
8577
+ const APPROVAL_WORDS = Object.freeze([
8578
+ ["yes", /\byes\b/i],
8579
+ ["approved", /\bapproved\b/i],
8580
+ ["approve", /\bapprove\b/i],
8581
+ ["go ahead", /\bgo\s+ahead\b/i],
8582
+ ["lgtm", /\blgtm\b/i],
8583
+ ["ok", /\bok\b/i],
8584
+ ["done", /\bdone\b/i],
8585
+ ["confirmed", /\bconfirmed\b/i]
8586
+ ]);
8587
+ /** What a reply's reads-as-approval warning begins with: the preview shows it under that reply. */
8588
+ function replyWarningPrefix(text) {
8589
+ return `The reply "${text}" can read as approval:`;
8590
+ }
8591
+ /** S3's warning for a reply holding words that read as a yes, or null. */
8592
+ function replyReadsAsApproval(text) {
8593
+ const said = APPROVAL_WORDS.filter(([, pattern]) => pattern.test(text)).map(([word]) => `"${word}"`);
8594
+ if (said.length === 0) return null;
8595
+ return `${replyWarningPrefix(text)} it says ${said.join(", ")}. People read "OK" or "Done" on "can I deploy?" as a yes.`;
8596
+ }
8597
+ /** A reply's text, trimmed and checked (S3), or the refusal of the first rule it breaks. */
8598
+ function replyText(written, position) {
8599
+ const where = {
8600
+ kind: "reply",
8601
+ position
8602
+ };
8603
+ const text = written.trim();
8604
+ if (text === "") throw contentRefused("empty", where);
8605
+ for (const [reason, breaks] of REPLY_RULES) if (breaks(text)) throw contentRefused(reason, where);
8606
+ if ([...text].length > 200) throw contentRefused("too-long", where);
8607
+ return text;
8608
+ }
8609
+ /** S3's warning for an emoji that reads as approval, in the spec's words. */
8610
+ function readsAsApprovalWarning(name) {
8611
+ return `:${name}: can read as approval: people read ✅ on "can I deploy?" as a yes, and a Slack workflow can start on a reaction.`;
8612
+ }
8613
+ /**
8614
+ * The contents a person wrote, checked (S3): reactions first, then replies, each as written, each with its digest; and
8615
+ * the warnings the preview shows. With no content given at all, `eyes` alone. Refuses BAD_DATA, with `details.reason`,
8616
+ * for anything S3 does not allow.
8617
+ */
8618
+ function checkedContents(input) {
8619
+ const reactions = input.reactions ?? [];
8620
+ const replies = input.replies ?? [];
8621
+ if (reactions.length > 5) throw contentRefused("too-many-reactions");
8622
+ if (replies.length > 5) throw contentRefused("too-many-replies");
8623
+ const names = reactions.length === 0 && replies.length === 0 ? [STANDING_DEFAULT_REACTION] : reactions.map((written, index) => reactionName(written, index + 1));
8624
+ const texts = replies.map((written, index) => replyText(written, index + 1));
8625
+ const contents = [...names.map((text) => ({
8626
+ kind: "reaction",
8627
+ text,
8628
+ digest: standingContentDigest("reaction", text)
8629
+ })), ...texts.map((text) => ({
8630
+ kind: "reply",
8631
+ text,
8632
+ digest: standingContentDigest("reply", text)
8633
+ }))];
8634
+ if (new Set(contents.map((content) => content.digest)).size !== contents.length) throw contentRefused("duplicate");
8635
+ return {
8636
+ contents,
8637
+ warnings: [...names.filter((name) => READS_AS_APPROVAL.includes(name)).map(readsAsApprovalWarning), ...texts.map(replyReadsAsApproval).filter((warning) => warning !== null)]
8638
+ };
8639
+ }
8640
+ /** S6's limits, each from the table: its default when left out, and BAD_DATA outside its range. */
8641
+ function checkedLimits(input) {
8642
+ const limits = {};
8643
+ for (const name of LIMIT_NAMES$1) {
8644
+ const { default: fallback, min, max } = STANDING_LIMITS[name];
8645
+ const given = input[name];
8646
+ const value = given ?? fallback;
8647
+ if (!Number.isSafeInteger(value) || value < min || value > max) throw new CommsError("BAD_DATA", `nothing was prepared: ${name} is a whole number from ${min} to ${max}`, {
8648
+ hint: `Leave it out for ${fallback}, or give a whole number from ${min} to ${max}.`,
8649
+ details: {
8650
+ reason: "limit-out-of-range",
8651
+ limit: name,
8652
+ min,
8653
+ max,
8654
+ given: given ?? null
8655
+ }
8656
+ });
8657
+ limits[name] = value;
8658
+ }
8659
+ return limits;
8660
+ }
8661
+ /** The canonical form of a place, as the digest binds it. */
8662
+ function canonicalPlaces(places) {
8663
+ if (places.kind === "threads-i-am-in") return { kind: "threads-i-am-in" };
8664
+ return {
8665
+ kind: "exact",
8666
+ places: places.places.map((place) => ({
8667
+ id: place.id,
8668
+ name: place.name,
8669
+ members: place.members,
8670
+ shared: {
8671
+ extShared: place.shared.extShared,
8672
+ pendingExtShared: place.shared.pendingExtShared,
8673
+ externalMembers: place.shared.externalMembers
8674
+ }
8675
+ }))
8676
+ };
8677
+ }
8678
+ /**
8679
+ * The digest a standing record is bound to: everything its binding holds but the summary — the account, every content
8680
+ * with its digest in order, every place as recorded, the limits, who proposed it, what it replaces, and the warnings
8681
+ * the person was shown.
8682
+ */
8683
+ function standingDigest(binding) {
8684
+ return sha256Hex(canonicalJson$1({
8685
+ kind: "standing",
8686
+ target: {
8687
+ kind: binding.target.kind,
8688
+ name: binding.target.name,
8689
+ id: binding.target.id
8690
+ },
8691
+ contents: binding.contents.map((content) => ({
8692
+ kind: content.kind,
8693
+ text: content.text,
8694
+ digest: content.digest
8695
+ })),
8696
+ places: canonicalPlaces(binding.places),
8697
+ limits: {
8698
+ perThread: binding.limits.perThread,
8699
+ perHour: binding.limits.perHour,
8700
+ perDay: binding.limits.perDay,
8701
+ perSender: binding.limits.perSender,
8702
+ expiresDays: binding.limits.expiresDays
8703
+ },
8704
+ proposedBy: binding.proposedBy,
8705
+ replaces: binding.replaces ?? null,
8706
+ warnings: [...binding.warnings]
8707
+ }));
8708
+ }
8709
+ /** A narrowing that is not one: USAGE, saying what to do instead. */
8710
+ function notANarrowing(message) {
8711
+ return new CommsError("USAGE", `nothing was narrowed: ${message}`, { hint: "A narrowing keeps some of what the standing approval holds. To end it altogether, revoke it." });
8712
+ }
8713
+ /**
8714
+ * The standing approval as it applies after `narrowed`: only the contents whose digests it keeps, only the exact
8715
+ * places it keeps, and the lower of each limit — so a narrowing never raises one, whatever it says. USAGE for a digest
8716
+ * or a place the approval does not hold, for keeping none, and for places kept from `threads-i-am-in`, which names none.
8717
+ */
8718
+ function effectiveStanding(binding, narrowed) {
8719
+ if (narrowed === void 0) return binding;
8720
+ let contents = binding.contents;
8721
+ if (narrowed.keep !== void 0) {
8722
+ const keep = new Set(narrowed.keep);
8723
+ for (const digest of keep) if (!binding.contents.some((content) => content.digest === digest)) throw notANarrowing("it keeps a content this standing approval does not hold");
8724
+ contents = binding.contents.filter((content) => keep.has(content.digest));
8725
+ if (contents.length === 0) throw notANarrowing("it would keep no content at all");
8726
+ }
8727
+ let places = binding.places;
8728
+ if (narrowed.places !== void 0) {
8729
+ if (binding.places.kind !== "exact") throw notANarrowing("threads-i-am-in names no places to keep; narrow its contents or limits, or revoke it");
8730
+ const keep = new Set(narrowed.places);
8731
+ const held = binding.places.places;
8732
+ for (const id of keep) if (!held.some((place) => place.id === id)) throw notANarrowing("it keeps a place this standing approval does not cover");
8733
+ const kept = held.filter((place) => keep.has(place.id));
8734
+ if (kept.length === 0) throw notANarrowing("it would keep no place at all");
8735
+ places = {
8736
+ kind: "exact",
8737
+ places: kept
8738
+ };
8739
+ }
8740
+ const limits = { ...binding.limits };
8741
+ for (const name of LIMIT_NAMES$1) {
8742
+ const lower = narrowed.limits?.[name];
8743
+ if (lower !== void 0 && Number.isFinite(lower)) limits[name] = Math.min(limits[name], lower);
8744
+ }
8745
+ return {
8746
+ ...binding,
8747
+ contents,
8748
+ places,
8749
+ limits
8750
+ };
8751
+ }
8752
+ /**
8753
+ * The narrowing `narrowing` makes of what is in force — `binding` as `narrowed` already leaves it — as the `narrowed` to
8754
+ * store beside `binding`: the contents and exact places kept, and every limit, as asked. Applies at once, so it may
8755
+ * only narrow (S8): USAGE for anything that would widen what is in force — a higher limit, a later expiry, a content
8756
+ * or a place not held — and BAD_DATA for a limit outside the table.
8757
+ */
8758
+ function narrowedFurther(binding, narrowed, narrowing) {
8759
+ const before = effectiveStanding(binding, narrowed);
8760
+ const kept = effectiveStanding(before, {
8761
+ ...narrowing.keep === void 0 ? {} : { keep: narrowing.keep },
8762
+ ...narrowing.places === void 0 ? {} : { places: narrowing.places }
8763
+ });
8764
+ const asked = { ...kept.limits };
8765
+ for (const name of LIMIT_NAMES$1) {
8766
+ const value = narrowing.limits?.[name];
8767
+ if (value !== void 0) asked[name] = value;
8768
+ }
8769
+ const after = {
8770
+ ...kept,
8771
+ limits: checkedLimits(asked)
8772
+ };
8773
+ if (classifyStandingChange(before, after) === "widens") throw new CommsError("USAGE", "nothing was narrowed: this would widen the standing approval, not narrow it", {
8774
+ hint: "A narrowing only lowers a limit, ends it sooner, or keeps fewer contents or places. Anything more is a new standing approval, which the person approves at their terminal with its code.",
8775
+ details: { reason: "widens" }
8776
+ });
8777
+ return {
8778
+ keep: after.contents.map((content) => content.digest),
8779
+ ...after.places.kind === "exact" ? { places: after.places.places.map((place) => place.id) } : {},
8780
+ limits: { ...after.limits }
8781
+ };
8782
+ }
8783
+ /** How many days a standing approval lasts once approved, as narrowed: the lower of its own and the narrowing's. */
8784
+ function standingDays(binding, narrowed) {
8785
+ const lower = narrowed?.limits?.expiresDays;
8786
+ return typeof lower === "number" && Number.isFinite(lower) ? Math.min(binding.limits.expiresDays, lower) : binding.limits.expiresDays;
8787
+ }
8788
+ /** A Slack conversation a standing approval may name exactly: a channel, a private channel or group DM, or a DM. */
8789
+ const STANDING_PLACE_PATTERN = /^[CGD][A-Z0-9]{2,}$/;
8790
+ const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8791
+ const isStrings$1 = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
8792
+ /** Why `shared` is not a recorded sharing state, or null. */
8793
+ function sharedProblem(shared) {
8794
+ if (!isRecord$3(shared)) return "a place has no sharing state";
8795
+ if (typeof shared.extShared !== "boolean" || typeof shared.pendingExtShared !== "boolean") return "a place has no sharing state";
8796
+ if (shared.externalMembers !== null && typeof shared.externalMembers !== "boolean") return "a place has no sharing state";
8797
+ return null;
8798
+ }
8799
+ /** Why `places` is not a standing approval's places, or null. */
8800
+ function placesProblem(places) {
8801
+ if (!isRecord$3(places)) return "it has no places";
8802
+ if (places.kind === "threads-i-am-in") return Object.keys(places).length === 1 ? null : "its places are not one kind";
8803
+ if (places.kind !== "exact" || !Array.isArray(places.places)) return "it has no places";
8804
+ if (places.places.length === 0) return "it names no place";
8805
+ const seen = /* @__PURE__ */ new Set();
8806
+ for (const place of places.places) {
8807
+ if (!isRecord$3(place) || typeof place.id !== "string" || !STANDING_PLACE_PATTERN.test(place.id)) return "a place is not a Slack channel or DM id";
8808
+ if (seen.has(place.id)) return "a place is named twice";
8809
+ seen.add(place.id);
8810
+ if (place.name !== null && typeof place.name !== "string") return "a place has no name or null";
8811
+ if (!Number.isSafeInteger(place.members) || place.members < 0) return "a place has no member count";
8812
+ const shared = sharedProblem(place.shared);
8813
+ if (shared !== null) return shared;
8814
+ }
8815
+ return null;
8816
+ }
8817
+ /**
8818
+ * Why `binding` is not one a standing record may hold, or null: every content one `checkedContents` gives for its
8819
+ * texts, in order, with its digest; exactly the warnings those contents give (S3); every limit inside the table; places
8820
+ * of one kind, each a Slack id once, with its member count and sharing; an account by its id; and who proposed it. The store refuses one before writing it, and
8821
+ * the validator a record that holds one, so nothing a caller assembles by hand — a custom emoji, a 31-day expiry, a
8822
+ * reply with a link — can become a standing approval.
8823
+ */
8824
+ function standingBindingProblem(binding) {
8825
+ if (!isRecord$3(binding)) return "it is not a standing approval";
8826
+ if (typeof binding.summary !== "string") return "it has no summary";
8827
+ const target = binding.target;
8828
+ if (!isRecord$3(target) || target.kind !== "account" || typeof target.name !== "string") return "it names no account";
8829
+ if (typeof target.id !== "string" || !ACCOUNT_ID_PATTERN.test(target.id)) return "it names no account by its id";
8830
+ if (!Array.isArray(binding.contents)) return "it has no contents";
8831
+ const written = binding.contents;
8832
+ if (!written.every((content) => isRecord$3(content) && typeof content.text === "string")) return "a content is unreadable";
8833
+ const contents = written;
8834
+ let checked;
8835
+ let warnings;
8836
+ try {
8837
+ ({contents: checked, warnings} = checkedContents({
8838
+ reactions: contents.filter((content) => content.kind === "reaction").map((content) => content.text),
8839
+ replies: contents.filter((content) => content.kind === "reply").map((content) => content.text)
8840
+ }));
8841
+ } catch {
8842
+ return "a content is not one a standing approval may hold";
8843
+ }
8844
+ if (contents.length === 0) return "it holds no content";
8845
+ const canonical = (list) => canonicalJson$1(list.map((content) => ({
8846
+ kind: content.kind,
8847
+ text: content.text,
8848
+ digest: content.digest
8849
+ })));
8850
+ if (canonical(checked) !== canonical(contents)) return "its contents are not as checked, in order, with their digests";
8851
+ const places = placesProblem(binding.places);
8852
+ if (places !== null) return places;
8853
+ if (!isRecord$3(binding.limits)) return "it has no limits";
8854
+ try {
8855
+ if (canonicalJson$1(checkedLimits(binding.limits)) !== canonicalJson$1(binding.limits)) return "its limits are not whole and complete";
8856
+ } catch {
8857
+ return "a limit is outside the table";
8858
+ }
8859
+ if (binding.proposedBy !== "agent" && binding.proposedBy !== "person") return "it does not say who proposed it";
8860
+ if (binding.replaces !== void 0 && (typeof binding.replaces !== "string" || !APPROVAL_ID_PATTERN.test(binding.replaces))) return "what it replaces is not an approval id";
8861
+ if (!isStrings$1(binding.warnings)) return "its warnings are unreadable";
8862
+ if (canonicalJson$1(binding.warnings) !== canonicalJson$1(warnings)) return "its warnings are not the ones its contents give";
8863
+ return null;
8864
+ }
8865
+ /** A copy of `binding` holding exactly its own fields, or BAD_DATA naming why it is not one (`standingBindingProblem`). */
8866
+ function canonicalStandingBinding(binding) {
8867
+ const problem = standingBindingProblem(binding);
8868
+ if (problem !== null) throw new CommsError("BAD_DATA", `nothing was prepared: ${problem}`, {
8869
+ hint: "This is a bug — please report it.",
8870
+ details: { reason: "binding-invalid" }
8871
+ });
8872
+ return {
8873
+ summary: binding.summary,
8874
+ target: {
8875
+ kind: "account",
8876
+ name: binding.target.name,
8877
+ id: binding.target.id
8878
+ },
8879
+ contents: binding.contents.map((content) => ({
8880
+ kind: content.kind,
8881
+ text: content.text,
8882
+ digest: content.digest
8883
+ })),
8884
+ places: binding.places.kind === "threads-i-am-in" ? { kind: "threads-i-am-in" } : {
8885
+ kind: "exact",
8886
+ places: binding.places.places.map((place) => ({
8887
+ id: place.id,
8888
+ name: place.name,
8889
+ members: place.members,
8890
+ shared: {
8891
+ extShared: place.shared.extShared,
8892
+ pendingExtShared: place.shared.pendingExtShared,
8893
+ externalMembers: place.shared.externalMembers
8894
+ }
8895
+ }))
8896
+ },
8897
+ limits: { ...binding.limits },
8898
+ proposedBy: binding.proposedBy,
8899
+ ...binding.replaces === void 0 ? {} : { replaces: binding.replaces },
8900
+ warnings: [...binding.warnings]
8901
+ };
8902
+ }
8903
+ /**
8904
+ * Why `narrowed`, stored beside `binding`, is not a narrowing of it, or null: each key what a narrowing holds, each
8905
+ * content and place one the binding has, and no limit above the binding's or outside the table. Not bound into the
8906
+ * record's digest, since it changes after approval; only ever able to narrow, so a hand that edits it can only take away.
8907
+ */
8908
+ function standingNarrowingProblem(binding, narrowed) {
8909
+ if (narrowed === void 0) return null;
8910
+ if (!isRecord$3(narrowed)) return "it is not a narrowing";
8911
+ for (const key of Object.keys(narrowed)) if (![
8912
+ "keep",
8913
+ "places",
8914
+ "limits"
8915
+ ].includes(key)) return "it is not a narrowing";
8916
+ if (narrowed.keep !== void 0 && !isStrings$1(narrowed.keep)) return "it keeps something that is not a digest";
8917
+ if (narrowed.places !== void 0 && !isStrings$1(narrowed.places)) return "it keeps something that is not a place";
8918
+ if (narrowed.limits !== void 0) {
8919
+ if (!isRecord$3(narrowed.limits)) return "its limits are unreadable";
8920
+ for (const [name, value] of Object.entries(narrowed.limits)) {
8921
+ if (!LIMIT_NAMES$1.includes(name)) return "it narrows a limit there is none of";
8922
+ const limit = name;
8923
+ if (!Number.isSafeInteger(value) || value < STANDING_LIMITS[limit].min) return "a limit is outside the table";
8924
+ if (value > binding.limits[limit]) return "it raises a limit";
8925
+ }
8926
+ }
8927
+ try {
8928
+ effectiveStanding(binding, narrowed);
8929
+ } catch {
8930
+ return "it keeps what the standing approval does not hold";
8931
+ }
8932
+ return null;
8933
+ }
8934
+ /** Whether a place recorded as `after` is one `before` approved: the same id, shared the same, and no bigger a room. */
8935
+ function placeCovered(before, after) {
8936
+ const held = before.find((place) => place.id === after.id);
8937
+ if (held === void 0) return false;
8938
+ if (canonicalJson$1(held.shared) !== canonicalJson$1(after.shared)) return false;
8939
+ return after.members < 50 || held.members >= 50;
8940
+ }
8941
+ /**
8942
+ * What `after` does to `before` (S8): `widens` with any content digest or exact place `before` does not hold — a place
8943
+ * shared differently, or grown to 50 or more members, is not the place it approved — a swap between exact places and
8944
+ * `threads-i-am-in` either way, another account, any higher limit, or a later expiry; `same` when it permits exactly
8945
+ * what `before` does; anything else that differs `narrows`. Unclear is a widening: it costs a person a code, never a
8946
+ * yes they did not give.
8947
+ */
8948
+ function classifyStandingChange(before, after) {
8949
+ if (before.target.id !== after.target.id) return "widens";
8950
+ const held = new Set(before.contents.map((content) => content.digest));
8951
+ if (after.contents.some((content) => !held.has(content.digest))) return "widens";
8952
+ if (before.places.kind !== after.places.kind) return "widens";
8953
+ if (before.places.kind === "exact" && after.places.kind === "exact") {
8954
+ const approved = before.places.places;
8955
+ if (after.places.places.some((place) => !placeCovered(approved, place))) return "widens";
8956
+ }
8957
+ for (const name of LIMIT_NAMES$1) if (after.limits[name] > before.limits[name]) return "widens";
8958
+ const kept = new Set(after.contents.map((content) => content.digest));
8959
+ const fewerContents = before.contents.some((content) => !kept.has(content.digest));
8960
+ const afterPlaces = after.places.kind === "exact" ? after.places.places : [];
8961
+ const fewerPlaces = before.places.kind === "exact" && before.places.places.some((place) => !afterPlaces.some((now) => now.id === place.id));
8962
+ const lower = LIMIT_NAMES$1.some((name) => after.limits[name] < before.limits[name]);
8963
+ return fewerContents || fewerPlaces || lower ? "narrows" : "same";
8964
+ }
8965
+ Object.freeze({
8966
+ "not-fresh": "the message is older than 15 minutes, or than this standing approval",
8967
+ "not-newest": "the message is not the newest from someone else in its thread or channel",
8968
+ "already-acknowledged": "this message or thread was already acknowledged under a standing approval",
8969
+ "not-human": "the message, or its thread's first message, is not from a person",
8970
+ interactive: "the message has buttons or menus, or came from a workflow",
8971
+ "place-not-covered": "this standing approval does not cover this place",
8972
+ "big-room": "this place has 50 or more members, and was not approved with that count",
8973
+ "count-unreadable": "how many people are in this place could not be read",
8974
+ external: "someone from another organisation is in this place",
8975
+ "shared-state-changed": "who shares this place changed since it was approved",
8976
+ "reaction-as-person": "a standing reaction goes only as the agent app, and here it would go as you",
8977
+ "app-not-member": "the agent app is not a member of this place, or whether it is could not be read",
8978
+ "content-not-held": "this standing approval holds no such content",
8979
+ "clock-behind": "the clock is earlier than this account's last standing act",
8980
+ unreadable: "the message or its thread could not be read"
8981
+ });
8982
+ Object.freeze({
8983
+ perThread: "per thread",
8984
+ perHour: "an hour",
8985
+ perDay: "a day",
8986
+ perSender: "a day on messages from one person"
8987
+ });
8988
+ /**
8297
8989
  * The digest a download's question is bound to: the account, the download, the request, the files it listed and the
8298
8990
  * names it showed them under.
8299
8991
  *
@@ -8440,6 +9132,19 @@ function refuseDisclosure(code, reason, record, hint) {
8440
9132
  } : {}
8441
9133
  });
8442
9134
  }
9135
+ /**
9136
+ * The same refusal for a standing approval, which sends only through its children: nothing was sent under it. Its hint
9137
+ * never names a command that makes or widens one — only the person does that, at their terminal.
9138
+ */
9139
+ function refuseStanding(code, reason, record, hint) {
9140
+ return new CommsError(code, `nothing was sent: ${reason}`, {
9141
+ hint: hint ?? "This standing approval cannot be used. Ask the person instead; only they can set up another, at their own terminal.",
9142
+ details: record ? {
9143
+ approvalId: record.approvalId,
9144
+ state: record.state
9145
+ } : {}
9146
+ });
9147
+ }
8443
9148
  /** The refusal in the words of the record's own kind. */
8444
9149
  function refusalFor(record) {
8445
9150
  switch (approvalKind(record)) {
@@ -8447,6 +9152,7 @@ function refusalFor(record) {
8447
9152
  case "change": return refuseChange;
8448
9153
  case "download": return refuseDownload;
8449
9154
  case "disclosure": return refuseDisclosure;
9155
+ case "standing": return refuseStanding;
8450
9156
  }
8451
9157
  }
8452
9158
  /**
@@ -8497,6 +9203,14 @@ function cancelledClaim(record) {
8497
9203
  reason: "cancelled"
8498
9204
  }
8499
9205
  });
9206
+ case "standing": return new CommsError("USAGE", "cancelled: nothing was sent", {
9207
+ hint: "The standing approval was not used: it is as it was.",
9208
+ details: {
9209
+ approvalId: record.approvalId,
9210
+ state: record.state,
9211
+ reason: "cancelled"
9212
+ }
9213
+ });
8500
9214
  }
8501
9215
  }
8502
9216
  /** The refusal of a renewal or an outcome from a call that does not hold the send's claim. It names no token. */
@@ -8616,7 +9330,11 @@ var ApprovalStore = class {
8616
9330
  reason: CLOCK_ANOMALY,
8617
9331
  expiredAt: observed.toISOString()
8618
9332
  };
8619
- const deadline = record.state === "approved" && record.kind !== "download" && record.usableUntil !== void 0 ? record.usableUntil : record.expiresAt;
9333
+ let deadline = record.state === "approved" && record.kind !== "download" && record.usableUntil !== void 0 ? record.usableUntil : record.expiresAt;
9334
+ if (record.kind === "standing" && record.state === "approved" && record.approvedAt !== void 0 && record.standing) {
9335
+ const narrowed = Date.parse(record.approvedAt) + standingDays(record.standing, record.narrowed) * STANDING_DAY_MS;
9336
+ if (narrowed < Date.parse(deadline)) deadline = new Date(narrowed).toISOString();
9337
+ }
8620
9338
  if (now >= Date.parse(deadline)) return {
8621
9339
  ...record,
8622
9340
  state: "expired",
@@ -8784,6 +9502,7 @@ var ApprovalStore = class {
8784
9502
  const objectOf = (record) => approvalObjectOf(record, live, this.#now());
8785
9503
  let next;
8786
9504
  try {
9505
+ await options.before?.(current, outcome);
8787
9506
  next = decide(current, outcome, live);
8788
9507
  } catch (error) {
8789
9508
  throw withApproval(error, objectOf(current));
@@ -8824,7 +9543,7 @@ var ApprovalStore = class {
8824
9543
  usedSaid: options.usedSaid
8825
9544
  });
8826
9545
  if (found.form === "v2" && derived.form === "v2") {
8827
- const current = outcome.revokes && action !== "inspect" && action !== "wait" && outcome.record !== null && outcome.record !== null ? outcome.record : derived.record;
9546
+ const current = outcome.revokes && outcome.record !== null && (action !== "inspect" && action !== "wait" || writtenAtALook(outcome)) && outcome.record !== null ? outcome.record : derived.record;
8828
9547
  if (current.state !== found.record.state) await this.#write({
8829
9548
  ...current,
8830
9549
  updatedAt: this.#now().toISOString()
@@ -8908,6 +9627,10 @@ var ApprovalStore = class {
8908
9627
  hint: "Nothing more is needed from the person: complete the same activation with the same approval.",
8909
9628
  details: detail
8910
9629
  });
9630
+ case "standing": return new CommsError("USAGE", "nothing was sent: it is approved already, and a person approves it once", {
9631
+ hint: "Nothing more is needed from the person: the standing approval is in force until it ends.",
9632
+ details: detail
9633
+ });
8911
9634
  }
8912
9635
  }
8913
9636
  /** Issues a new challenge to show a human; only its hash is kept. */
@@ -9068,7 +9791,7 @@ var ApprovalStore = class {
9068
9791
  async approve(approvalId, via, live, answer, kind = "send", _platform = process.platform, options = {}) {
9069
9792
  if (kind === "disclosure") throw new CommsError("APPROVAL_REQUIRED", "nothing was disclosed: a standing disclosure approval is only accepted on the trusted terminal or app route");
9070
9793
  let failure = null;
9071
- const done = await this.#transition(approvalId, { kind }, "approve", (current, outcome) => {
9794
+ const done = await this.#transition(approvalId, { kind }, "approve", (current, outcome, gate) => {
9072
9795
  if (outcome.error) throw outcome.error;
9073
9796
  if (current.state !== "pending") throw this.#alreadyApproved(current, outcome);
9074
9797
  if (!current.challengeHash) throw refusalFor(current)("APPROVAL_REQUIRED", "no challenge was issued for this approval", current);
@@ -9085,10 +9808,22 @@ var ApprovalStore = class {
9085
9808
  revokedAt: at.toISOString()
9086
9809
  };
9087
9810
  }
9811
+ if (kind === "standing" && via !== "terminal") {
9812
+ failure = {
9813
+ code: "APPROVAL_VOID",
9814
+ reason: "a standing approval is approved only with its code at a terminal, and this was not approved at a terminal"
9815
+ };
9816
+ return {
9817
+ ...current,
9818
+ state: "revoked",
9819
+ reason: failure.reason,
9820
+ revokedAt: at.toISOString()
9821
+ };
9822
+ }
9088
9823
  if (live.draftMessageId !== current.draftMessageId || live.contentDigest !== current.contentDigest) {
9089
9824
  failure = {
9090
9825
  code: "APPROVAL_VOID",
9091
- reason: kind === "change" ? "the change shown is not the one the approval was prepared for" : "the draft changed after the preview was prepared"
9826
+ reason: kind === "change" ? "the change shown is not the one the approval was prepared for" : kind === "standing" ? "the standing approval shown is not the one that was prepared" : "the draft changed after the preview was prepared"
9092
9827
  };
9093
9828
  return {
9094
9829
  ...current,
@@ -9128,7 +9863,8 @@ var ApprovalStore = class {
9128
9863
  approvedAt: at.toISOString(),
9129
9864
  usableUntil: new Date(at.getTime() + (current.approvedMs ?? APPROVAL_LIFETIMES.approved)).toISOString(),
9130
9865
  approvedBindingDigest: current.bindingDigest,
9131
- challengeHash: void 0
9866
+ challengeHash: void 0,
9867
+ ...kind === "standing" ? { approvedChangePolicy: gate?.changePolicy ?? "chat" } : {}
9132
9868
  };
9133
9869
  }, { usedSaid: options.usedSaid });
9134
9870
  const failed = failure;
@@ -9147,6 +9883,7 @@ var ApprovalStore = class {
9147
9883
  */
9148
9884
  async claimForSend(approvalId, live, options = {}) {
9149
9885
  let failure = null;
9886
+ let parentProblem = null;
9150
9887
  const done = await this.#transition(approvalId, {
9151
9888
  kind: "send",
9152
9889
  owner: live.inboxId
@@ -9167,6 +9904,7 @@ var ApprovalStore = class {
9167
9904
  challengeHash: void 0
9168
9905
  };
9169
9906
  };
9907
+ if (parentProblem !== null) return voidWith("APPROVAL_VOID", parentProblem);
9170
9908
  if (current.inboxSub && current.inboxSub !== live.inboxSub) return voidWith("APPROVAL_VOID", live.inboxSub ? "the inbox is now connected to a different account" : "the account this was prepared for could not be confirmed");
9171
9909
  if (live.draftMessageId !== current.draftMessageId) return voidWith("APPROVAL_VOID", "the draft was edited after the preview");
9172
9910
  if (live.contentDigest !== current.contentDigest) return voidWith("APPROVAL_VOID", "the draft content changed after the preview");
@@ -9178,7 +9916,12 @@ var ApprovalStore = class {
9178
9916
  state: "sending",
9179
9917
  sendingAt: at
9180
9918
  };
9181
- }, { usedSaid: options.usedSaid });
9919
+ }, {
9920
+ usedSaid: options.usedSaid,
9921
+ before: async (current, outcome) => {
9922
+ if (outcome.error === void 0 && current.route === "standing" && current.state === "approved") parentProblem = await this.#standingParentProblem(current);
9923
+ }
9924
+ });
9182
9925
  const failed = failure;
9183
9926
  if (failed) throw refusedAfter(refuse(failed.code, failed.reason, done.record), done);
9184
9927
  const claimToken = randomBytes(16).toString("hex");
@@ -9191,6 +9934,31 @@ var ApprovalStore = class {
9191
9934
  };
9192
9935
  }
9193
9936
  /**
9937
+ * Why a standing child may not be claimed now, or null (design 2026-10-19-standing-approvals, S9, the lead's call after
9938
+ * review): its standing approval read again, under that one's own lock — ended by any of the store's rules, an epoch
9939
+ * or a tightened change policy among them, or narrowed so that it no longer holds the child's content or covers its
9940
+ * place. Called with the child's lock held: no lock is ever taken the other way round.
9941
+ */
9942
+ async #standingParentProblem(child) {
9943
+ const use = child.standingUse;
9944
+ if (child.standingId === void 0 || use === void 0) return "it names no standing approval it is a use of";
9945
+ let seen;
9946
+ try {
9947
+ seen = await this.inspect(child.standingId, {
9948
+ kind: "standing",
9949
+ owner: child.inboxId
9950
+ }, { action: "claim" });
9951
+ } catch {
9952
+ return "its standing approval could not be read";
9953
+ }
9954
+ const parent = seen.outcome.record;
9955
+ if (seen.outcome.error !== void 0 || parent === null || parent.state !== "approved" || parent.standing === void 0) return `its standing approval has ended${seen.outcome.reason === void 0 ? "" : ` (${seen.outcome.reason})`}`;
9956
+ const inForce = effectiveStanding(parent.standing, parent.narrowed);
9957
+ if (!inForce.contents.some((content) => content.digest === use.content)) return "its standing approval no longer holds what it sends";
9958
+ if (inForce.places.kind === "exact" && !inForce.places.places.some((place) => place.id === use.channel)) return "its standing approval no longer covers where it goes";
9959
+ return null;
9960
+ }
9961
+ /**
9194
9962
  * The file system's O_EXCL is the single-use guarantee, independent of the lock. A send's marker also holds its
9195
9963
  * claim token, the one thing that lets the claimant — and nobody else — renew its lease and record its outcome.
9196
9964
  */
@@ -9352,6 +10120,208 @@ var ApprovalStore = class {
9352
10120
  return done.record;
9353
10121
  }
9354
10122
  /**
10123
+ * A standing approval (design 2026-10-19-standing-approvals, S8), pending, bound to `standingDigest` of its binding —
10124
+ * computed here, never taken from the caller — and to the send epoch of its account and the standing epoch, both as
10125
+ * read when it is made.
10126
+ *
10127
+ * Its route is `confirm` and its `requiredPolicy` `confirm` whatever the change policy is, because what it permits
10128
+ * happens with nobody asked: it is approved only with the code typed at a terminal (`approve`, `via: 'terminal'`),
10129
+ * never by a yes in the chat, a client's form, or an answer carried in a call. It lasts its days once approved
10130
+ * (`approvedMs`). Nothing ever claims it: each use mints a child (`createStandingChild`).
10131
+ *
10132
+ * The binding is checked before anything is written (`canonicalStandingBinding`): a custom emoji, a reply with a
10133
+ * link, a limit outside the table or a place that is no Slack id is refused, however the caller assembled it.
10134
+ */
10135
+ async createStanding(input) {
10136
+ const standing = canonicalStandingBinding(input.binding);
10137
+ if (typeof input.inboxSub !== "string" || input.inboxSub === "") throw new CommsError("BAD_DATA", "nothing was prepared: a standing approval is made for a known account user", { hint: "This is a bug — please report it." });
10138
+ requireEpochs(input.sendEpoch, input.standingEpoch);
10139
+ requireKnownChannel("slack");
10140
+ await this.ensurePruned();
10141
+ const now = this.#now();
10142
+ const digest = standingDigest(standing);
10143
+ const pendingMs = APPROVAL_LIFETIMES.confirm;
10144
+ const record = this.#bound({
10145
+ approvalId: newApprovalId(),
10146
+ kind: "standing",
10147
+ digestVersion: 2,
10148
+ channel: "slack",
10149
+ ownerScope: "owner",
10150
+ route: "confirm",
10151
+ pendingMs,
10152
+ approvedMs: standing.limits.expiresDays * STANDING_DAY_MS,
10153
+ inboxId: standing.target.id,
10154
+ inboxSub: input.inboxSub,
10155
+ draftId: "standing",
10156
+ draftMessageId: digest,
10157
+ contentDigest: digest,
10158
+ sendEpoch: input.sendEpoch,
10159
+ standingEpoch: input.standingEpoch,
10160
+ policy: "confirm",
10161
+ requiredPolicy: "confirm",
10162
+ riskFlags: [],
10163
+ expect: {
10164
+ to: [],
10165
+ cc: [],
10166
+ bcc: [],
10167
+ subject: standing.summary
10168
+ },
10169
+ challengeAttempts: 0,
10170
+ state: "pending",
10171
+ createdAt: now.toISOString(),
10172
+ expiresAt: new Date(now.getTime() + pendingMs).toISOString(),
10173
+ updatedAt: now.toISOString(),
10174
+ standing
10175
+ });
10176
+ await this.#write(record);
10177
+ return record;
10178
+ }
10179
+ /**
10180
+ * Runs `use` under the standing record's own lock, with the record as it reads there and its classification for a
10181
+ * use (`claim`): its expiry, a revocation the configuration derives — its account removed, `never`, a send epoch or
10182
+ * a standing epoch behind the live one — written before `use` runs, and `outcome.error` set for every state that
10183
+ * refuses a use. `owner` is the account the caller acts for: any other's record is the one `NOT_FOUND`.
10184
+ *
10185
+ * What `use` decides is decided while no revocation, narrowing or approval of this record can land. `use` must not
10186
+ * take this record's lock again: it is held.
10187
+ */
10188
+ async withStanding(standingId, owner, use) {
10189
+ const path = this.#path(standingId);
10190
+ const expect = {
10191
+ kind: "standing",
10192
+ owner
10193
+ };
10194
+ return withFileLock(`${path}.lock`, async () => {
10195
+ const config = await this.#config();
10196
+ const found = await this.#read(standingId, config);
10197
+ if (found === null || !matchesExpectation(found, expect)) throw approvalNotFound(standingId, "standing");
10198
+ const derived = this.#derived(found);
10199
+ const outcome = approvalOutcome(derived, {
10200
+ action: "claim",
10201
+ live: config === null || derived.form !== "v2" ? null : liveGateOf(config, derived.record),
10202
+ now: this.#now()
10203
+ });
10204
+ if (found.form !== "v2" || derived.form !== "v2" || outcome.record === null) throw outcome.error ?? integrityRefusal(found);
10205
+ const current = outcome.revokes ? outcome.record : derived.record;
10206
+ if (current.state !== found.record.state) await this.#write({
10207
+ ...current,
10208
+ updatedAt: this.#now().toISOString()
10209
+ });
10210
+ return use(current, outcome);
10211
+ });
10212
+ }
10213
+ /**
10214
+ * One use of a standing approval (S9): a send record, approved the moment it is made — `route: 'standing'`,
10215
+ * `approvedVia: 'standing'`, `approvedAt` its creation — bound to the standing id, the standing epoch and the send
10216
+ * epoch, and usable once, within `APPROVAL_LIFETIMES.standingChild`. It then takes the one send path every send
10217
+ * takes: `claimForSend`, its O_EXCL marker, the fence and the outcome.
10218
+ *
10219
+ * Minted only by the use (`mintStandingUse`), under the account's acknowledgement ledger lock and then the standing
10220
+ * record's, after every check there. Checked again here, read without that lock — which the caller holds: the
10221
+ * standing record must be this account's, approved at a terminal, under the same epochs and the same account user,
10222
+ * and not ended, or nothing is written. A child never names anything but what its use was checked for.
10223
+ */
10224
+ async createStandingChild(input) {
10225
+ if (!/^[0-9a-f]{64}$/.test(input.contentDigest)) throw new CommsError("UNEXPECTED", "an approval is bound to a SHA-256 of what it permits, and this is not one", { hint: "This is a bug — please report it." });
10226
+ if (standingUseProblem(input.use) !== null) throw new CommsError("UNEXPECTED", "a standing use’s child names where it acts and what it sends, or nothing", { hint: "This is a bug — please report it." });
10227
+ requireEpochs(input.sendEpoch, input.standingEpoch);
10228
+ requireKnownChannel("slack");
10229
+ const config = await this.#config();
10230
+ if (input.approvalId !== void 0) {
10231
+ if (!APPROVAL_ID_PATTERN.test(input.approvalId) || await this.#read(input.approvalId, config) !== null) throw new CommsError("UNEXPECTED", "a standing use’s child is made under a fresh id, and this is not one", { hint: "This is a bug — please report it." });
10232
+ }
10233
+ const found = await this.#read(input.standingId, config);
10234
+ if (found === null || !matchesExpectation(found, {
10235
+ kind: "standing",
10236
+ owner: input.inboxId
10237
+ })) throw approvalNotFound(input.standingId, "standing");
10238
+ if (found.form !== "v2") throw integrityRefusal(found);
10239
+ const parent = this.#derive(found.record);
10240
+ const live = config === null ? null : liveGateOf(config, parent);
10241
+ const outcome = approvalOutcome({
10242
+ form: "v2",
10243
+ record: parent
10244
+ }, {
10245
+ action: "claim",
10246
+ live,
10247
+ now: this.#now()
10248
+ });
10249
+ if (outcome.error) throw outcome.error;
10250
+ const standing = outcome.record ?? parent;
10251
+ if (standing.state === "pending") throw refuseStanding("APPROVAL_PENDING", "the standing approval waits for the person to approve it at a terminal", standing);
10252
+ const unbound = standing.state !== "approved" ? "the standing approval is not in force" : standing.approvedVia !== "terminal" || standing.approvedBindingDigest !== standing.bindingDigest ? "the standing approval was not approved at a terminal" : standing.standingEpoch !== input.standingEpoch || standing.sendEpoch !== input.sendEpoch ? "the use is not bound to the epochs its standing approval was made under" : standing.inboxSub !== input.inboxSub ? "the use is not for the account user its standing approval was made for" : null;
10253
+ if (unbound !== null) throw refuseStanding("APPROVAL_VOID", unbound, standing);
10254
+ const now = this.#now();
10255
+ const at = now.toISOString();
10256
+ const lifetime = APPROVAL_LIFETIMES.standingChild;
10257
+ const until = new Date(now.getTime() + lifetime).toISOString();
10258
+ const sealed = this.#bound({
10259
+ approvalId: input.approvalId ?? newApprovalId(),
10260
+ kind: "send",
10261
+ digestVersion: 2,
10262
+ channel: "slack",
10263
+ ownerScope: "owner",
10264
+ route: "standing",
10265
+ pendingMs: lifetime,
10266
+ approvedMs: lifetime,
10267
+ inboxId: input.inboxId,
10268
+ inboxSub: input.inboxSub,
10269
+ draftId: input.draftId,
10270
+ draftMessageId: input.draftMessageId,
10271
+ contentDigest: input.contentDigest,
10272
+ sendEpoch: input.sendEpoch,
10273
+ standingEpoch: input.standingEpoch,
10274
+ standingId: input.standingId,
10275
+ standingUse: {
10276
+ channel: input.use.channel,
10277
+ thread: input.use.thread,
10278
+ content: input.use.content
10279
+ },
10280
+ policy: live?.sendPolicy === "chat" ? "chat" : "confirm",
10281
+ requiredPolicy: "confirm",
10282
+ riskFlags: [...input.riskFlags],
10283
+ expect: {
10284
+ to: [...input.expect.to],
10285
+ cc: [...input.expect.cc],
10286
+ bcc: [...input.expect.bcc],
10287
+ subject: input.expect.subject
10288
+ },
10289
+ challengeAttempts: 0,
10290
+ state: "approved",
10291
+ createdAt: at,
10292
+ expiresAt: until,
10293
+ updatedAt: at,
10294
+ approvedVia: "standing",
10295
+ approvedAt: at,
10296
+ usableUntil: until
10297
+ });
10298
+ const record = {
10299
+ ...sealed,
10300
+ approvedBindingDigest: sealed.bindingDigest
10301
+ };
10302
+ await this.#write(record);
10303
+ return record;
10304
+ }
10305
+ /**
10306
+ * Narrows a standing approval at once (S8): fewer contents or places, a lower limit, an earlier end. Nothing is asked
10307
+ * for, because nothing more is permitted: anything `classifyStandingChange` calls a widening of what is in force is
10308
+ * refused with USAGE, and the record is left as it was. The binding the person approved stays the record's own, and
10309
+ * what is narrowed is kept beside it, where it can only take away.
10310
+ */
10311
+ async narrowStanding(standingId, owner, narrowing) {
10312
+ return (await this.#transition(standingId, {
10313
+ kind: "standing",
10314
+ owner
10315
+ }, "revoke", (current, outcome) => {
10316
+ if (outcome.error) throw outcome.error;
10317
+ if (current.state !== "pending" && current.state !== "approved" || current.standing === void 0) throw refuseStanding("APPROVAL_VOID", "the standing approval has ended, so there is nothing to narrow", current);
10318
+ return {
10319
+ ...current,
10320
+ narrowed: narrowedFurther(current.standing, current.narrowed, narrowing)
10321
+ };
10322
+ })).record;
10323
+ }
10324
+ /**
9355
10325
  * A download's question, pending, bound to `downloadDigest(input.download)` — computed here, never taken from the
9356
10326
  * caller, as a change's digest is. It stands in for the draft revision too.
9357
10327
  *
@@ -9636,13 +10606,28 @@ var ApprovalStore = class {
9636
10606
  ...derived.record,
9637
10607
  updatedAt: this.#now().toISOString()
9638
10608
  });
10609
+ const outcome = approvalOutcome(derived, {
10610
+ action: "inspect",
10611
+ live: config === null || derived.form !== "v2" ? null : liveGateOf(config, derived.record),
10612
+ now: this.#now()
10613
+ });
10614
+ if (writtenAtALook(outcome) && outcome.record !== null) {
10615
+ const ended = {
10616
+ ...outcome.record,
10617
+ updatedAt: this.#now().toISOString()
10618
+ };
10619
+ await this.#write(ended);
10620
+ return {
10621
+ stored: {
10622
+ form: "v2",
10623
+ record: ended
10624
+ },
10625
+ outcome
10626
+ };
10627
+ }
9639
10628
  return {
9640
10629
  stored: derived,
9641
- outcome: approvalOutcome(derived, {
9642
- action: "inspect",
9643
- live: config === null || derived.form !== "v2" ? null : liveGateOf(config, derived.record),
9644
- now: this.#now()
9645
- })
10630
+ outcome
9646
10631
  };
9647
10632
  });
9648
10633
  if (seen === null) continue;
@@ -9681,6 +10666,10 @@ var ApprovalStore = class {
9681
10666
  return found.sort(byCreation);
9682
10667
  }
9683
10668
  };
10669
+ /** Both epochs a standing record or child binds: whole numbers of 0 or more, or a programming error. */
10670
+ function requireEpochs(sendEpoch, standingEpoch) {
10671
+ for (const epoch of [sendEpoch, standingEpoch]) if (!Number.isSafeInteger(epoch) || epoch < 0) throw new CommsError("UNEXPECTED", "a standing approval is bound to whole epochs, and this is not one", { hint: "This is a bug — please report it." });
10672
+ }
9684
10673
  /** Oldest first; a record that cannot be read has no time to be ordered by, and follows, by id. */
9685
10674
  function byCreation(a, b) {
9686
10675
  const createdOf = (stored) => stored.form === "v2" ? stored.record.createdAt : stored.form === "legacy" ? stored.view.createdAt : null;
@@ -9718,7 +10707,7 @@ function sameExpectation(a, b) {
9718
10707
  const CLOCK_ANOMALY = "clock-anomaly";
9719
10708
  const HEX64 = /^[0-9a-f]{64}$/;
9720
10709
  /** Exactly what `Date.prototype.toISOString` writes, which is all the store ever writes. */
9721
- const ISO$1 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
10710
+ const ISO$2 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
9722
10711
  const STATES$1 = [
9723
10712
  "pending",
9724
10713
  "approved",
@@ -9729,7 +10718,10 @@ const STATES$1 = [
9729
10718
  "expired",
9730
10719
  "revoked"
9731
10720
  ];
9732
- /** The states each kind can reach. A change or a download is claimed straight to `used`: it is never sent. */
10721
+ /**
10722
+ * The states each kind can reach. A change or a download is claimed straight to `used`: it is never sent. A standing
10723
+ * approval is never claimed at all — its children are — so it ends expired or revoked.
10724
+ */
9733
10725
  const STATES_OF = {
9734
10726
  send: STATES$1,
9735
10727
  change: [
@@ -9745,8 +10737,24 @@ const STATES_OF = {
9745
10737
  "used",
9746
10738
  "expired",
9747
10739
  "revoked"
10740
+ ],
10741
+ standing: [
10742
+ "pending",
10743
+ "approved",
10744
+ "expired",
10745
+ "revoked"
9748
10746
  ]
9749
10747
  };
10748
+ /** A standing approval's child, approved when it is made: never pending. */
10749
+ const CHILD_STATES = [
10750
+ "approved",
10751
+ "sending",
10752
+ "used",
10753
+ "failed",
10754
+ "unknown",
10755
+ "expired",
10756
+ "revoked"
10757
+ ];
9750
10758
  /** The send states that went through a claim, and so carry `sendingAt`. */
9751
10759
  const CLAIMED_SEND = [
9752
10760
  "sending",
@@ -9754,10 +10762,14 @@ const CLAIMED_SEND = [
9754
10762
  "failed",
9755
10763
  "unknown"
9756
10764
  ];
10765
+ /** Whether a record is one use of a standing approval: a send on the `standing` route. */
10766
+ function isChild(raw) {
10767
+ return raw.kind === "send" && raw.route === "standing";
10768
+ }
9757
10769
  /** A record's timestamp field: absent, invalid, or the time it names. */
9758
10770
  function timeOf(value) {
9759
10771
  if (value === void 0) return null;
9760
- if (typeof value !== "string" || !ISO$1.test(value)) return "invalid";
10772
+ if (typeof value !== "string" || !ISO$2.test(value)) return "invalid";
9761
10773
  const at = Date.parse(value);
9762
10774
  return Number.isFinite(at) && new Date(at).toISOString() === value ? at : "invalid";
9763
10775
  }
@@ -9804,7 +10816,7 @@ function checkVerified(record, raw) {
9804
10816
  if (raw.digestVersion !== 2) return fail("digest-version");
9805
10817
  const kind = raw.kind;
9806
10818
  if (kind === "disclosure") return checkDisclosure(record, raw);
9807
- if (kind !== "send" && kind !== "change" && kind !== "download") return fail("wrong-shape");
10819
+ if (kind !== "send" && kind !== "change" && kind !== "download" && kind !== "standing") return fail("wrong-shape");
9808
10820
  if (!STATES$1.includes(raw.state)) return fail("state-impossible");
9809
10821
  const state = raw.state;
9810
10822
  if (typeof raw.contentDigest !== "string" || !HEX64.test(raw.contentDigest)) return fail("content-digest-malformed");
@@ -9814,10 +10826,16 @@ function checkVerified(record, raw) {
9814
10826
  if (kind === "download") {
9815
10827
  if (record.download === void 0 || downloadDigest(record.download) !== record.contentDigest) return fail("content-digest-mismatch");
9816
10828
  }
10829
+ if (kind === "standing") {
10830
+ if (record.standing === void 0 || standingBindingProblem(record.standing) !== null) return fail("wrong-shape");
10831
+ if (standingDigest(record.standing) !== record.contentDigest) return fail("content-digest-mismatch");
10832
+ if (standingNarrowingProblem(record.standing, raw.narrowed) !== null) return fail("narrowing-invalid");
10833
+ } else if (raw.standing !== void 0 || raw.narrowed !== void 0) return fail("wrong-shape");
9817
10834
  const identity = checkIdentity(record, raw, kind);
9818
10835
  if (identity !== null) return identity;
9819
10836
  if (!STATES_OF[kind].includes(state)) return fail("state-impossible");
9820
- const lifetime = checkLifetime(raw, kind);
10837
+ if (isChild(raw) && !CHILD_STATES.includes(state)) return fail("state-impossible");
10838
+ const lifetime = checkLifetime(record, raw, kind);
9821
10839
  if (lifetime !== null) return lifetime;
9822
10840
  const times = {};
9823
10841
  for (const field of TIMESTAMPS) {
@@ -9892,9 +10910,19 @@ function checkIdentity(record, raw, kind) {
9892
10910
  if (scope !== expected) return fail("identity-contradiction");
9893
10911
  if (raw.inboxId !== (expected === "owner" ? target?.id : "")) return fail("identity-contradiction");
9894
10912
  }
9895
- if (kind === "send") {
9896
- if (typeof raw.sendEpoch !== "number" || !Number.isInteger(raw.sendEpoch) || raw.sendEpoch < 0) return fail("identity-contradiction");
10913
+ const wholeEpoch = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
10914
+ if (kind === "send" || kind === "standing") {
10915
+ if (!wholeEpoch(raw.sendEpoch)) return fail("identity-contradiction");
9897
10916
  } else if (raw.sendEpoch !== void 0) return fail("identity-contradiction");
10917
+ if (kind === "standing" || isChild(raw)) {
10918
+ if (raw.channel !== "slack" || !wholeEpoch(raw.standingEpoch)) return fail("identity-contradiction");
10919
+ } else if (raw.standingEpoch !== void 0) return fail("identity-contradiction");
10920
+ if (isChild(raw)) {
10921
+ if (typeof raw.standingId !== "string" || !APPROVAL_ID_PATTERN.test(raw.standingId)) return fail("identity-contradiction");
10922
+ if (raw.standingId === raw.approvalId) return fail("identity-contradiction");
10923
+ if (standingUseProblem(raw.standingUse) !== null) return fail("identity-contradiction");
10924
+ } else if (raw.standingId !== void 0 || raw.standingUse !== void 0) return fail("identity-contradiction");
10925
+ if (kind === "standing" && record.standing.target.id !== raw.inboxId) return fail("identity-contradiction");
9898
10926
  if (kind === "download") {
9899
10927
  const download = record.download;
9900
10928
  if (download === void 0 || download.target.id !== raw.inboxId) return fail("identity-contradiction");
@@ -9904,13 +10932,31 @@ function checkIdentity(record, raw, kind) {
9904
10932
  }
9905
10933
  return null;
9906
10934
  }
9907
- /** The lifetime profile: what the route says, and nothing the record could have chosen for itself. */
9908
- function checkLifetime(raw, kind) {
10935
+ /**
10936
+ * The lifetime profile: what the route says, and nothing the record could have chosen for itself. A standing approval
10937
+ * waits as a `confirm` change does and lasts the whole days its limits name, from 1 to 30; its child lives
10938
+ * `standingChild`, pending and approved alike.
10939
+ */
10940
+ function checkLifetime(record, raw, kind) {
9909
10941
  if (kind === "download") {
9910
10942
  if (raw.pendingMs !== APPROVAL_LIFETIMES.download) return fail("lifetime-mismatch");
9911
10943
  if (raw.route !== void 0 || raw.approvedMs !== void 0) return fail("lifetime-mismatch");
9912
10944
  return null;
9913
10945
  }
10946
+ if (kind === "standing") {
10947
+ const days = record.standing.limits.expiresDays;
10948
+ const { min, max } = STANDING_LIMITS.expiresDays;
10949
+ if (raw.route !== "confirm" || raw.pendingMs !== APPROVAL_LIFETIMES.confirm) return fail("lifetime-mismatch");
10950
+ if (!Number.isSafeInteger(days) || days < min || days > max) return fail("lifetime-mismatch");
10951
+ if (raw.approvedMs !== days * 864e5) return fail("lifetime-mismatch");
10952
+ if (raw.policy !== "confirm" || raw.requiredPolicy !== "confirm") return fail("lifetime-mismatch");
10953
+ return null;
10954
+ }
10955
+ if (isChild(raw)) {
10956
+ const lifetime = APPROVAL_LIFETIMES.standingChild;
10957
+ if (raw.pendingMs !== lifetime || raw.approvedMs !== lifetime) return fail("lifetime-mismatch");
10958
+ return null;
10959
+ }
9914
10960
  if (raw.route !== "chat" && raw.route !== "confirm") return fail("lifetime-mismatch");
9915
10961
  if (raw.pendingMs !== pendingMsOf(raw.route)) return fail("lifetime-mismatch");
9916
10962
  if (raw.approvedMs !== APPROVAL_LIFETIMES.approved) return fail("lifetime-mismatch");
@@ -9960,6 +11006,10 @@ function checkTimestamps(raw, kind, state, times) {
9960
11006
  if (!(createdAt <= approvedAt && approvedAt < expiresAt)) return fail("timestamp-misordered");
9961
11007
  if (usableUntil - approvedAt !== raw.approvedMs) return fail("lifetime-mismatch");
9962
11008
  }
11009
+ if (isChild(raw)) {
11010
+ if (approvedAt === void 0) return fail("timestamp-missing");
11011
+ if (approvedAt !== createdAt) return fail("timestamp-misordered");
11012
+ }
9963
11013
  const deadline = usableUntil ?? expiresAt;
9964
11014
  const since = approvedAt ?? createdAt;
9965
11015
  const { sendingAt, sendingHeartbeatAt, usedAt, sentAt, failedAt, revokedAt, expiredAt } = times;
@@ -9987,7 +11037,9 @@ function checkTimestamps(raw, kind, state, times) {
9987
11037
  */
9988
11038
  function checkEvidence(record, raw, kind, state) {
9989
11039
  const via = raw.approvedVia;
9990
- if (via !== void 0 && via !== "terminal" && via !== "elicitation") return fail("evidence-invalid");
11040
+ if (via !== void 0 && via !== "terminal" && via !== "elicitation" && via !== "standing") return fail("evidence-invalid");
11041
+ if (via === "standing" !== (isChild(raw) && via !== void 0)) return fail("evidence-invalid");
11042
+ if (raw.approvedChangePolicy !== void 0 && (kind !== "standing" || raw.approvedAt === void 0)) return fail("evidence-contradictory");
9991
11043
  if (kind === "download") {
9992
11044
  const answer = record.download?.answer;
9993
11045
  const answered = via !== void 0 || raw.approvedDigest !== void 0 || answer !== void 0;
@@ -10005,7 +11057,9 @@ function checkEvidence(record, raw, kind, state) {
10005
11057
  if (raw.approvedDigest !== void 0) return fail("evidence-contradictory");
10006
11058
  if (!(raw.approvedAt !== void 0)) return via !== void 0 || raw.approvedBindingDigest !== void 0 ? fail("evidence-contradictory") : null;
10007
11059
  if (via === void 0 || raw.approvedBindingDigest === void 0) return fail("evidence-missing");
10008
- if (kind === "change" && via !== "terminal") return fail("evidence-invalid");
11060
+ if (kind === "standing" && raw.approvedChangePolicy !== "chat" && raw.approvedChangePolicy !== "confirm") return fail("evidence-missing");
11061
+ if ((kind === "change" || kind === "standing") && via !== "terminal") return fail("evidence-invalid");
11062
+ if (isChild(raw) && via !== "standing") return fail("evidence-invalid");
10009
11063
  if (raw.approvedBindingDigest !== record.bindingDigest) return fail("evidence-contradictory");
10010
11064
  return null;
10011
11065
  }
@@ -10025,9 +11079,11 @@ const KINDS$1 = [
10025
11079
  "download",
10026
11080
  "disclosure"
10027
11081
  ];
11082
+ /** Version 2 alone has standing approvals (design 2026-10-19-standing-approvals): no earlier release wrote one. */
11083
+ const V2_KINDS = [...KINDS$1, "standing"];
10028
11084
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10029
- const isString = (value) => typeof value === "string";
10030
- const isStrings = (value) => Array.isArray(value) && value.every(isString);
11085
+ const isString$1 = (value) => typeof value === "string";
11086
+ const isStrings = (value) => Array.isArray(value) && value.every(isString$1);
10031
11087
  const optional = (value, check) => value === void 0 || check(value);
10032
11088
  function unreadable(approvalId, reason) {
10033
11089
  return {
@@ -10040,22 +11096,22 @@ function unreadable(approvalId, reason) {
10040
11096
  };
10041
11097
  }
10042
11098
  function expectationShaped(value) {
10043
- return isObject(value) && isStrings(value.to) && isStrings(value.cc) && isStrings(value.bcc) && isString(value.subject);
11099
+ return isObject(value) && isStrings(value.to) && isStrings(value.cc) && isStrings(value.bcc) && isString$1(value.subject);
10044
11100
  }
10045
11101
  /**
10046
11102
  * The types every record shares, version 1 or 2: anything of the wrong type cannot reach the classifier. Values —
10047
11103
  * a timestamp that is a string but not a time, a digest that is a string but not hex — are the validator's.
10048
11104
  */
10049
11105
  function commonShape(raw) {
10050
- if (!isString(raw.inboxId)) return "missing-ownership";
10051
- if (!isString(raw.state) || !STATES.includes(raw.state)) return "wrong-shape";
11106
+ if (!isString$1(raw.inboxId)) return "missing-ownership";
11107
+ if (!isString$1(raw.state) || !STATES.includes(raw.state)) return "wrong-shape";
10052
11108
  for (const field of [
10053
11109
  "createdAt",
10054
11110
  "expiresAt",
10055
11111
  "updatedAt",
10056
11112
  "draftId",
10057
11113
  "draftMessageId"
10058
- ]) if (!isString(raw[field])) return "wrong-shape";
11114
+ ]) if (!isString$1(raw[field])) return "wrong-shape";
10059
11115
  if (!expectationShaped(raw.expect)) return "wrong-shape";
10060
11116
  for (const field of [
10061
11117
  "inboxSub",
@@ -10066,17 +11122,23 @@ function commonShape(raw) {
10066
11122
  "reason",
10067
11123
  "policy",
10068
11124
  "requiredPolicy"
10069
- ]) if (!optional(raw[field], isString)) return "wrong-shape";
11125
+ ]) if (!optional(raw[field], isString$1)) return "wrong-shape";
10070
11126
  if (!optional(raw.riskFlags, isStrings)) return "wrong-shape";
10071
11127
  if (!optional(raw.challengeAttempts, (value) => typeof value === "number")) return "wrong-shape";
10072
11128
  const kind = raw.kind ?? "send";
10073
11129
  if (kind === "change") {
10074
11130
  const change = raw.change;
10075
- if (!isObject(change) || !isString(change.summary) || !(change.target === null || isObject(change.target)) || !Array.isArray(change.loosened) || !isStrings(change.effects)) return "wrong-shape";
11131
+ if (!isObject(change) || !isString$1(change.summary) || !(change.target === null || isObject(change.target)) || !Array.isArray(change.loosened) || !isStrings(change.effects)) return "wrong-shape";
11132
+ }
11133
+ if (kind === "standing") {
11134
+ const standing = raw.standing;
11135
+ if (!isObject(standing) || !isString$1(standing.summary) || !isObject(standing.target) || !isString$1(standing.target.id) || !Array.isArray(standing.contents) || !standing.contents.every((content) => isObject(content) && isString$1(content.kind) && isString$1(content.text) && isString$1(content.digest)) || !isObject(standing.places) || !isString$1(standing.places.kind) || !isObject(standing.limits) || !isString$1(standing.proposedBy) || !optional(standing.replaces, isString$1) || !isStrings(standing.warnings)) return "wrong-shape";
11136
+ const narrowed = raw.narrowed;
11137
+ if (!optional(narrowed, (value) => isObject(value) && optional(value.keep, isStrings) && optional(value.places, isStrings) && optional(value.limits, isObject))) return "wrong-shape";
10076
11138
  }
10077
11139
  if (kind === "download") {
10078
11140
  const download = raw.download;
10079
- if (!isObject(download) || !isString(download.summary) || !isObject(download.target) || !isString(download.target.id) || !isStrings(download.files) || !isStrings(download.names) || !isObject(download.folders) || !isString(download.folders.downloads) || !isString(download.folders.current) || !optional(download.listing, (listing) => Array.isArray(listing) && listing.every((file) => isObject(file)))) return "wrong-shape";
11141
+ if (!isObject(download) || !isString$1(download.summary) || !isObject(download.target) || !isString$1(download.target.id) || !isStrings(download.files) || !isStrings(download.names) || !isObject(download.folders) || !isString$1(download.folders.downloads) || !isString$1(download.folders.current) || !optional(download.listing, (listing) => Array.isArray(listing) && listing.every((file) => isObject(file)))) return "wrong-shape";
10080
11142
  }
10081
11143
  return null;
10082
11144
  }
@@ -10089,7 +11151,7 @@ function disclosureShape(raw) {
10089
11151
  "expiresAt",
10090
11152
  "updatedAt",
10091
11153
  "bindingDigest"
10092
- ]) if (!isString(raw[field])) return "wrong-shape";
11154
+ ]) if (!isString$1(raw[field])) return "wrong-shape";
10093
11155
  for (const field of [
10094
11156
  "pendingMs",
10095
11157
  "approvedMs",
@@ -10105,9 +11167,9 @@ function disclosureShape(raw) {
10105
11167
  "revokedAt",
10106
11168
  "expiredAt",
10107
11169
  "reason"
10108
- ]) if (!optional(raw[field], isString)) return "wrong-shape";
11170
+ ]) if (!optional(raw[field], isString$1)) return "wrong-shape";
10109
11171
  const disclosure = raw.disclosure;
10110
- if (!isObject(disclosure) || !isString(disclosure.digest) || !isString(disclosure.activationIntentId) || !isString(disclosure.activationKind) || !Array.isArray(disclosure.versions) || !disclosure.versions.every((entry) => isObject(entry) && isString(entry.kind) && isString(entry.id) && typeof entry.version === "number")) return "wrong-shape";
11172
+ if (!isObject(disclosure) || !isString$1(disclosure.digest) || !isString$1(disclosure.activationIntentId) || !isString$1(disclosure.activationKind) || !Array.isArray(disclosure.versions) || !disclosure.versions.every((entry) => isObject(entry) && isString$1(entry.kind) && isString$1(entry.id) && typeof entry.version === "number")) return "wrong-shape";
10111
11173
  for (const field of [
10112
11174
  "channel",
10113
11175
  "ownerScope",
@@ -10122,6 +11184,8 @@ function disclosureShape(raw) {
10122
11184
  "requiredPolicy",
10123
11185
  "riskFlags",
10124
11186
  "approvedBindingDigest",
11187
+ "approvedChangePolicy",
11188
+ "standingUse",
10125
11189
  "sendingAt",
10126
11190
  "sendingHeartbeatAt",
10127
11191
  "sentAt",
@@ -10139,16 +11203,20 @@ function v2Shape(raw) {
10139
11203
  "channel",
10140
11204
  "ownerScope",
10141
11205
  "contentDigest"
10142
- ]) if (!optional(raw[field], isString)) return "wrong-shape";
11206
+ ]) if (!optional(raw[field], isString$1)) return "wrong-shape";
10143
11207
  for (const field of [
10144
11208
  "pendingMs",
10145
11209
  "approvedMs",
10146
- "sendEpoch"
11210
+ "sendEpoch",
11211
+ "standingEpoch"
10147
11212
  ]) if (!optional(raw[field], (value) => typeof value === "number")) return "wrong-shape";
11213
+ if (!optional(raw.standingId, isString$1)) return "wrong-shape";
11214
+ if (!optional(raw.standingUse, isObject)) return "wrong-shape";
10148
11215
  for (const field of [
10149
11216
  "route",
10150
11217
  "bindingDigest",
10151
11218
  "approvedBindingDigest",
11219
+ "approvedChangePolicy",
10152
11220
  "approvedAt",
10153
11221
  "usableUntil",
10154
11222
  "sendingAt",
@@ -10158,7 +11226,7 @@ function v2Shape(raw) {
10158
11226
  "failedAt",
10159
11227
  "revokedAt",
10160
11228
  "expiredAt"
10161
- ]) if (!optional(raw[field], isString)) return "wrong-shape";
11229
+ ]) if (!optional(raw[field], isString$1)) return "wrong-shape";
10162
11230
  return null;
10163
11231
  }
10164
11232
  /**
@@ -10174,7 +11242,7 @@ function decodeStored(fileId, text, config, now) {
10174
11242
  return unreadable(fileId, trimmed === "" || trimmed.startsWith("{") && !trimmed.endsWith("}") ? "truncated" : "invalid-json");
10175
11243
  }
10176
11244
  if (!isObject(raw)) return unreadable(fileId, "wrong-shape");
10177
- if (!isString(raw.approvalId)) return unreadable(fileId, "wrong-shape");
11245
+ if (!isString$1(raw.approvalId)) return unreadable(fileId, "wrong-shape");
10178
11246
  if (raw.kind === "disclosure") {
10179
11247
  if (raw.digestVersion === 1) return unreadable(fileId, "wrong-shape");
10180
11248
  const shape = disclosureShape(raw);
@@ -10196,8 +11264,8 @@ function decodeStored(fileId, text, config, now) {
10196
11264
  const shape = commonShape(raw);
10197
11265
  if (shape !== null) return unreadable(fileId, shape);
10198
11266
  if (raw.digestVersion === 1) {
10199
- if (!optional(raw.kind, (kind) => isString(kind) && KINDS$1.includes(kind))) return unreadable(fileId, "wrong-shape");
10200
- if (!isString(raw.digest)) return unreadable(fileId, "wrong-shape");
11267
+ if (!optional(raw.kind, (kind) => isString$1(kind) && KINDS$1.includes(kind))) return unreadable(fileId, "wrong-shape");
11268
+ if (!isString$1(raw.digest)) return unreadable(fileId, "wrong-shape");
10201
11269
  if (raw.approvalId !== fileId) return unreadable(fileId, "file-name-mismatch");
10202
11270
  const record = raw;
10203
11271
  return {
@@ -10206,7 +11274,7 @@ function decodeStored(fileId, text, config, now) {
10206
11274
  record
10207
11275
  };
10208
11276
  }
10209
- if (!isString(raw.kind) || !KINDS$1.includes(raw.kind)) return unreadable(fileId, "missing-kind");
11277
+ if (!isString$1(raw.kind) || !V2_KINDS.includes(raw.kind)) return unreadable(fileId, "missing-kind");
10210
11278
  const v2 = v2Shape(raw);
10211
11279
  if (v2 !== null) return unreadable(fileId, v2);
10212
11280
  const record = raw;
@@ -10330,9 +11398,9 @@ const NEVER = Object.freeze({
10330
11398
  cursor: null
10331
11399
  });
10332
11400
  /** Exactly what `Date.prototype.toISOString` writes. */
10333
- const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
11401
+ const ISO$1 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
10334
11402
  function isoTime(value) {
10335
- if (typeof value !== "string" || !ISO.test(value)) return null;
11403
+ if (typeof value !== "string" || !ISO$1.test(value)) return null;
10336
11404
  const at = Date.parse(value);
10337
11405
  return Number.isFinite(at) && new Date(at).toISOString() === value ? at : null;
10338
11406
  }
@@ -10932,7 +12000,7 @@ function folding(platform) {
10932
12000
  return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
10933
12001
  }
10934
12002
  /** The package version, read from package.json at build time. */
10935
- const VERSION = "0.22.0";
12003
+ const VERSION = "0.23.0";
10936
12004
  /**
10937
12005
  * The command a person is told to run for one of this suite's own CLIs (design 2026-10-04, D1 and D5; CUE-403).
10938
12006
  *
@@ -11107,8 +12175,8 @@ function locateCliCommand(request, runtime = {
11107
12175
  const theirs = typeof manifest.version === "string" ? manifest.version : "of no version";
11108
12176
  return fail("version", `${caller.packageName} ${version} finds ${target.packageName} ${theirs}, at ${installed}`);
11109
12177
  }
11110
- const callerWorkspace = workspaceOf(callerRoot);
11111
- const targetWorkspace = workspaceOf(installed);
12178
+ const callerWorkspace = workspaceOf$1(callerRoot);
12179
+ const targetWorkspace = workspaceOf$1(installed);
11112
12180
  const sameCheckout = callerWorkspace !== null && callerWorkspace === targetWorkspace;
11113
12181
  const found = {
11114
12182
  root: installed,
@@ -11518,7 +12586,7 @@ function installedPackage(start, name) {
11518
12586
  * `pnpm-workspace.yaml` above it, and its `packages` globs. A package under the checkout but not one of them — an
11519
12587
  * installed copy in its `node_modules` — is not a workspace package of it.
11520
12588
  */
11521
- function workspaceOf(root) {
12589
+ function workspaceOf$1(root) {
11522
12590
  let dir = dirname(root);
11523
12591
  for (;;) {
11524
12592
  const globs = workspaceGlobs(dir);
@@ -12396,7 +13464,7 @@ async function gatedChangeAtTerminal(core, change, options) {
12396
13464
  if ((await approveChangeAtTerminal(core, prepared.approvalId, options.env, options.output, streams)).state !== "approved") throw cancelled();
12397
13465
  } else if (options.answered !== true) {
12398
13466
  streams.stdout.write(`${prepared.preview}\n\n`);
12399
- if ((await askLine(streams, `Type ${paint(options.output.color, "bold", "yes")} to apply this change: `)).trim().toLowerCase() !== "yes") {
13467
+ if ((await askLine$1(streams, `Type ${paint(options.output.color, "bold", "yes")} to apply this change: `)).trim().toLowerCase() !== "yes") {
12400
13468
  await revokeChange(core, prepared.approvalId, "cancelled at the terminal", {
12401
13469
  surface: "cli",
12402
13470
  disposition: "person"
@@ -12461,7 +13529,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
12461
13529
  }
12462
13530
  const prompt = await beginChangeApproval(core, approvalId, { surface: "cli" });
12463
13531
  streams.stdout.write(`${prompt.preview}\n\n`);
12464
- const answer = await askLine(streams, `Type ${paint(output.color, "bold", prompt.challenge)} to approve this change, or press Enter to cancel: `);
13532
+ const answer = await askLine$1(streams, `Type ${paint(output.color, "bold", prompt.challenge)} to approve this change, or press Enter to cancel: `);
12465
13533
  if (!answer.trim()) {
12466
13534
  await revokeChange(core, approvalId, "cancelled at the terminal", {
12467
13535
  surface: "cli",
@@ -12481,7 +13549,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
12481
13549
  function cancelled() {
12482
13550
  return new CommsError("USAGE", "cancelled: nothing was changed");
12483
13551
  }
12484
- async function askLine(streams, question) {
13552
+ async function askLine$1(streams, question) {
12485
13553
  const { createInterface } = await import("node:readline/promises");
12486
13554
  const rl = createInterface({
12487
13555
  input: streams.stdin,
@@ -12816,6 +13884,186 @@ async function openSecretStore(kind, options) {
12816
13884
  if (!keyring) throw new CommsError("SECRET_STORE_UNAVAILABLE", "secrets are kept in the system keychain, but the keychain module is missing", { hint: "Reinstall with optional dependencies, or re-add the inbox with --store file." });
12817
13885
  return new KeychainSecretStore(keyring, options.namespace, KEYCHAIN_TIMEOUT_MS, told);
12818
13886
  }
13887
+ /** Exactly what `Date.prototype.toISOString` writes. */
13888
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
13889
+ const OUTCOMES = [
13890
+ "sent",
13891
+ "failed",
13892
+ "unknown"
13893
+ ];
13894
+ const isString = (value) => typeof value === "string";
13895
+ const isTime$2 = (value) => isString(value) && ISO.test(value) && !Number.isNaN(Date.parse(value));
13896
+ /** One line as an entry, or null when it is not one this release wrote. */
13897
+ function entryOf$2(line) {
13898
+ let raw;
13899
+ try {
13900
+ raw = JSON.parse(line);
13901
+ } catch {
13902
+ return null;
13903
+ }
13904
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
13905
+ const entry = raw;
13906
+ if (!isTime$2(entry.at)) return null;
13907
+ if (entry.kind === "use") {
13908
+ if (![
13909
+ "standingId",
13910
+ "childId",
13911
+ "contentDigest",
13912
+ "channel",
13913
+ "ts",
13914
+ "thread",
13915
+ "sender",
13916
+ "targetDigest"
13917
+ ].every((field) => isString(entry[field]) && entry[field] !== "")) return null;
13918
+ if (entry.act !== "reaction" && entry.act !== "reply") return null;
13919
+ if (typeof entry.edited !== "boolean") return null;
13920
+ return {
13921
+ kind: "use",
13922
+ at: entry.at,
13923
+ standingId: entry.standingId,
13924
+ childId: entry.childId,
13925
+ act: entry.act,
13926
+ contentDigest: entry.contentDigest,
13927
+ channel: entry.channel,
13928
+ ts: entry.ts,
13929
+ thread: entry.thread,
13930
+ sender: entry.sender,
13931
+ targetDigest: entry.targetDigest,
13932
+ edited: entry.edited
13933
+ };
13934
+ }
13935
+ if (entry.kind === "outcome") {
13936
+ if (!isString(entry.childId) || !isString(entry.outcome) || !OUTCOMES.includes(entry.outcome)) return null;
13937
+ if (!isString(entry.notice) || entry.link !== void 0 && !isString(entry.link)) return null;
13938
+ if (entry.actAt !== void 0 && !isTime$2(entry.actAt)) return null;
13939
+ return {
13940
+ kind: "outcome",
13941
+ at: entry.at,
13942
+ ...entry.actAt === void 0 ? {} : { actAt: entry.actAt },
13943
+ childId: entry.childId,
13944
+ outcome: entry.outcome,
13945
+ ...entry.link === void 0 ? {} : { link: entry.link },
13946
+ notice: entry.notice
13947
+ };
13948
+ }
13949
+ return null;
13950
+ }
13951
+ var AckLedger = class {
13952
+ directory;
13953
+ #now;
13954
+ constructor(stateDir, now = () => /* @__PURE__ */ new Date()) {
13955
+ this.directory = join(stateDir, "standing");
13956
+ this.#now = now;
13957
+ }
13958
+ /** The clock this ledger was opened with: what a use is timed by, and its counts' windows measured from. */
13959
+ now() {
13960
+ return this.#now();
13961
+ }
13962
+ /** The account's ledger file. Refuses anything but an account id, so no name can reach another path. */
13963
+ #path(accountId) {
13964
+ if (typeof accountId !== "string" || !ACCOUNT_ID_PATTERN.test(accountId)) throw new CommsError("USAGE", `"${String(accountId)}" is not an account id`, { hint: "A standing approval’s ledger is kept per account, by the account’s id." });
13965
+ return join(this.directory, `${accountId}.jsonl`);
13966
+ }
13967
+ /** Every line, read: the entries, and the number — from 1 — of each line this release cannot read. */
13968
+ async #read(path) {
13969
+ let text = "";
13970
+ try {
13971
+ text = await readFile(path, "utf8");
13972
+ } catch (error) {
13973
+ if (error.code !== "ENOENT") throw error;
13974
+ }
13975
+ const entries = [];
13976
+ const unreadable = [];
13977
+ for (const [index, line] of text.split("\n").entries()) {
13978
+ if (line.trim() === "") continue;
13979
+ const entry = entryOf$2(line);
13980
+ if (entry === null) unreadable.push(index + 1);
13981
+ else entries.push(entry);
13982
+ }
13983
+ return {
13984
+ entries,
13985
+ unreadable
13986
+ };
13987
+ }
13988
+ /** Where an account's ledger is kept: `<state>/standing/<accountId>.jsonl`, for a person told to move it aside. */
13989
+ pathOf(accountId) {
13990
+ return this.#path(accountId);
13991
+ }
13992
+ /**
13993
+ * Every account's ledger on this machine that holds a line this release cannot read, with the number of each such
13994
+ * line, for the doctor (design 2026-10-19-standing-approvals, the lead's call after review): while one does, no
13995
+ * standing approval of that account can be used — what it counts cannot be known — and nothing else says so. Reads
13996
+ * without the lock: a look, and every line it reads was written whole.
13997
+ */
13998
+ async unreadable() {
13999
+ let names;
14000
+ try {
14001
+ names = await readdir(this.directory);
14002
+ } catch (error) {
14003
+ if (error.code === "ENOENT") return [];
14004
+ throw error;
14005
+ }
14006
+ const found = [];
14007
+ for (const name of names.sort()) {
14008
+ if (!name.endsWith(".jsonl")) continue;
14009
+ const accountId = name.slice(0, -6);
14010
+ if (!ACCOUNT_ID_PATTERN.test(accountId)) continue;
14011
+ const path = this.#path(accountId);
14012
+ const { unreadable } = await this.#read(path);
14013
+ if (unreadable.length > 0) found.push({
14014
+ accountId,
14015
+ path,
14016
+ lines: unreadable
14017
+ });
14018
+ }
14019
+ return found;
14020
+ }
14021
+ /**
14022
+ * Runs `fn` under the account's ledger lock, with every entry as it reads there and `append`, which writes a `use`
14023
+ * line under the lock already held. `fn` must never take this lock again.
14024
+ *
14025
+ * A line this release cannot read refuses the whole call, BAD_DATA, before `fn` runs: a count read without it could
14026
+ * be lower than the truth, and a count is never guessed downwards.
14027
+ */
14028
+ withLock(accountId, fn) {
14029
+ let path;
14030
+ try {
14031
+ path = this.#path(accountId);
14032
+ } catch (error) {
14033
+ return Promise.reject(error);
14034
+ }
14035
+ return withFileLock(`${path}.lock`, async () => {
14036
+ const { entries, unreadable } = await this.#read(path);
14037
+ if (unreadable.length > 0) throw new CommsError("BAD_DATA", "nothing was sent: this account’s standing acknowledgement ledger has a line that cannot be read", {
14038
+ hint: "No standing approval of this account can be used until the ledger reads whole: what it counts cannot be known. Ask the person instead; the doctor says which line, and how to recover.",
14039
+ details: {
14040
+ unreadable: unreadable.length,
14041
+ lines: unreadable
14042
+ }
14043
+ });
14044
+ const append = async (entry) => {
14045
+ if (entry.kind !== "use" || entryOf$2(JSON.stringify(entry)) === null) throw new CommsError("UNEXPECTED", "a standing use is written whole, or not at all", { hint: "This is a bug — please report it." });
14046
+ await appendPrivateLine(path, JSON.stringify(entry), { durable: true });
14047
+ };
14048
+ return fn(entries, append);
14049
+ });
14050
+ }
14051
+ /** Records what became of a use's act, taking the lock itself. It never frees a count. */
14052
+ async recordOutcome(accountId, entry) {
14053
+ const path = this.#path(accountId);
14054
+ const written = {
14055
+ ...entry,
14056
+ kind: "outcome",
14057
+ at: entry.at ?? this.#now().toISOString()
14058
+ };
14059
+ if (entryOf$2(JSON.stringify(written)) === null) throw new CommsError("UNEXPECTED", "a standing outcome is written whole, or not at all", { hint: "This is a bug — please report it." });
14060
+ await withFileLock(`${path}.lock`, () => appendPrivateLine(path, JSON.stringify(written), { durable: true }));
14061
+ }
14062
+ /** Every entry this release can read, oldest first, for a report: a line it cannot read is left out of it. */
14063
+ async entries(accountId) {
14064
+ return (await this.#read(this.#path(accountId))).entries;
14065
+ }
14066
+ };
12819
14067
  var InboxStateStore = class {
12820
14068
  directory;
12821
14069
  constructor(stateDir) {
@@ -13629,6 +14877,7 @@ function openCore(options = {}) {
13629
14877
  answered: (approvalId) => securityAnswered(paths.stateDir, approvalId)
13630
14878
  }),
13631
14879
  ledger: new SendLedger(paths.stateDir, now),
14880
+ acks: new AckLedger(paths.stateDir, now),
13632
14881
  plans: new PlanStore(paths.stateDir, now),
13633
14882
  taint: new TaintStore(paths.stateDir, now),
13634
14883
  audit,
@@ -14414,7 +15663,7 @@ function serverPruneChange(core, env, request, own) {
14414
15663
  };
14415
15664
  const { packageName, version } = ownProduct(request.channel, own) ?? {
14416
15665
  packageName: serverFacts(request.channel).packageName,
14417
- version: "0.22.0"
15666
+ version: "0.23.0"
14418
15667
  };
14419
15668
  const product = {
14420
15669
  packageName,
@@ -14562,6 +15811,12 @@ function everyServerPruneChange(core, env, request) {
14562
15811
  }
14563
15812
  };
14564
15813
  }
15814
+ Object.freeze([
15815
+ "narrow",
15816
+ "revoke",
15817
+ "off"
15818
+ ]);
15819
+ Object.keys(STANDING_LIMITS);
14565
15820
  /**
14566
15821
  * Bringing a machine to the latest release, from a terminal (`agentcomms update`) or a chat (`comms_update`).
14567
15822
  *
@@ -14946,7 +16201,7 @@ function commandDecisionLine(core, decision, channel, platform) {
14946
16201
  }
14947
16202
  channelServer("core").packageName;
14948
16203
  //#endregion
14949
- //#region ../core/dist/tool-arguments-H5H3FITT.mjs
16204
+ //#region ../core/dist/tool-arguments-C2vEASTA.mjs
14950
16205
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
14951
16206
  const TARGET = "draft-2020-12";
14952
16207
  /**
@@ -16130,6 +17385,27 @@ strictObject({
16130
17385
  if (manifest.skills.contract !== `skills/_shared/contract-${family}.md`) issue(["skills", "contract"], `skills/_shared/contract-${family}.md: a skill's contract is chosen by its prefix`);
16131
17386
  }
16132
17387
  });
17388
+ /** The environment values the Windows script reads the title and the body from. */
17389
+ const TITLE_VARIABLE = "AGENTCOMMS_NOTICE_TITLE";
17390
+ const BODY_VARIABLE = "AGENTCOMMS_NOTICE_BODY";
17391
+ Object.freeze([
17392
+ "-e",
17393
+ "on run argv",
17394
+ "-e",
17395
+ "display notification (item 1 of argv) with title (item 2 of argv)",
17396
+ "-e",
17397
+ "end run"
17398
+ ]);
17399
+ [
17400
+ "$ErrorActionPreference = 'Stop'",
17401
+ "$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]",
17402
+ "$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)",
17403
+ "$text = $xml.GetElementsByTagName('text')",
17404
+ `$null = $text.Item(0).AppendChild($xml.CreateTextNode([string]$env:${TITLE_VARIABLE}))`,
17405
+ `$null = $text.Item(1).AppendChild($xml.CreateTextNode([string]$env:${BODY_VARIABLE}))`,
17406
+ "$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)",
17407
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show($toast)"
17408
+ ].join("; ");
16133
17409
  const URL_SHORTENERS = /* @__PURE__ */ new Set([
16134
17410
  "bit.ly",
16135
17411
  "tinyurl.com",
@@ -16260,6 +17536,10 @@ function analyseLink(text, href) {
16260
17536
  flags
16261
17537
  };
16262
17538
  }
17539
+ Object.freeze({
17540
+ min: 5e3,
17541
+ max: 3e4
17542
+ });
16263
17543
  Object.freeze({
16264
17544
  complete: "not sent with any approval in the last 90 days",
16265
17545
  window: "not sent with any of the 500 most recently changed approval records",