@agentcomms/resend 0.22.0 → 0.24.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.
@@ -12,7 +12,7 @@ import { execFile, spawn } from "node:child_process";
12
12
  //#endregion
13
13
  //#region src/version.ts
14
14
  /** The package version, read from package.json at build time. */
15
- const VERSION$2 = "0.22.0";
15
+ const VERSION$2 = "0.24.0";
16
16
  //#endregion
17
17
  //#region src/api/routes.ts
18
18
  /**
@@ -201,7 +201,7 @@ const RESEND_CALLER = Object.freeze({
201
201
  packageName: PACKAGE_NAME
202
202
  });
203
203
  //#endregion
204
- //#region ../core/dist/update-check-DQ7AAwLg.mjs
204
+ //#region ../core/dist/update-check-BY5xNUZa.mjs
205
205
  /**
206
206
  * Every failure a user or an agent can act on carries a specific, stable code from this registry, a message, an
207
207
  * optional one-line hint and structured `details`. The code decides the process exit status; agents and skills branch
@@ -269,6 +269,16 @@ const ERROR_REGISTRY = {
269
269
  retryable: false,
270
270
  summary: "a behaviour rule does not allow this act here"
271
271
  },
272
+ STANDING_REFUSED: {
273
+ exit: EXIT_CODES.APPROVAL,
274
+ retryable: false,
275
+ summary: "this standing approval does not cover this act"
276
+ },
277
+ STANDING_LIMIT: {
278
+ exit: EXIT_CODES.APPROVAL,
279
+ retryable: false,
280
+ summary: "a standing approval's limit is reached"
281
+ },
272
282
  RATE_CAPPED: {
273
283
  exit: EXIT_CODES.APPROVAL,
274
284
  retryable: true,
@@ -1276,6 +1286,9 @@ function labelPhrase(name, word, limit) {
1276
1286
  if ([...phrase].length > limit) throw new CommsError("USAGE", `the label "${phrase}" is longer than this platform's ${limit} characters`, { hint: "Set a shorter name with `label name`." });
1277
1287
  return phrase;
1278
1288
  }
1289
+ /** `<name> (<word>)`: a name holds no parenthesis or line break (`nameProblem`), so the first `(` is the word's. */
1290
+ const LABEL_PATTERN = `[^()\\n]{1,160} \\((?:${AGENT_LABEL_WORDS.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\)`;
1291
+ new RegExp(`^\\s*(?:${LABEL_PATTERN}: |Sent by ${LABEL_PATTERN}(?:$|\\s))`, "iu");
1279
1292
  /** L3's sentence for an unlabelled message, the first line of its preview. */
1280
1293
  const UNLABELLED_PREVIEW_LINE = "Sent as you, with no label: it will look as if you wrote it yourself";
1281
1294
  function addressMatches(pattern, address) {
@@ -1728,12 +1741,17 @@ function sameDisclosureBinding(left, right) {
1728
1741
  return false;
1729
1742
  }
1730
1743
  }
1731
- /** The lifetime profile, in milliseconds: pending on each route, approved, and a download's question. */
1744
+ /**
1745
+ * The lifetime profile, in milliseconds: pending on each route, approved, a download's question, and a standing
1746
+ * approval's child — usable once, within two minutes of the use that minted it. A standing record's own approved
1747
+ * lifetime is its days (`approvedMs`, checked against its limits), not one of these.
1748
+ */
1732
1749
  const APPROVAL_LIFETIMES = Object.freeze({
1733
1750
  chat: 6e5,
1734
1751
  confirm: 18e5,
1735
1752
  approved: 864e5,
1736
- download: 18e5
1753
+ download: 18e5,
1754
+ standingChild: 12e4
1737
1755
  });
1738
1756
  /**
1739
1757
  * The sending lease (design 2026-10-05 §D1): a `sending` record whose claimant has not renewed it for this long reads
@@ -1754,8 +1772,9 @@ function unknownAtOf(record) {
1754
1772
  const at = Date.parse(renewed);
1755
1773
  return Number.isFinite(at) ? new Date(at + SENDING_LEASE_MS).toISOString() : void 0;
1756
1774
  }
1757
- /** How long a pending send or change on `route` stays open. */
1775
+ /** How long a pending send or change on `route` stays open; a standing child is never pending, and lasts its own. */
1758
1776
  function pendingMsOf(route) {
1777
+ if (route === "standing") return APPROVAL_LIFETIMES.standingChild;
1759
1778
  return route === "confirm" ? APPROVAL_LIFETIMES.confirm : APPROVAL_LIFETIMES.chat;
1760
1779
  }
1761
1780
  /** Whether `channel` is one this release knows: a manifest channel of the generated snapshot. */
@@ -1766,9 +1785,20 @@ function isKnownChannel(channel) {
1766
1785
  function requireKnownChannel(channel) {
1767
1786
  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." });
1768
1787
  }
1788
+ /** Why `use` is not a child's use of its standing approval, or null: three strings, its content a SHA-256. */
1789
+ function standingUseProblem(use) {
1790
+ if (typeof use !== "object" || use === null || Array.isArray(use)) return "it names no use";
1791
+ const fields = use;
1792
+ if (Object.keys(fields).some((key) => key !== "channel" && key !== "thread" && key !== "content")) return "it names more than its use";
1793
+ for (const field of ["channel", "thread"]) if (typeof fields[field] !== "string" || fields[field] === "") return `its use has no ${field}`;
1794
+ if (typeof fields.content !== "string" || !/^[0-9a-f]{64}$/.test(fields.content)) return "its use names no content";
1795
+ return null;
1796
+ }
1769
1797
  /**
1770
1798
  * The identity a record is bound to, exactly as stored. An absent `inboxSub` is left out rather than kept as
1771
- * `undefined`; a send always carries its epoch.
1799
+ * `undefined`; a send and a standing record always carry their send epoch. A standing record and its children carry
1800
+ * the standing epoch, and a child its standing id — each only when present, so every other record's identity, and its
1801
+ * digest, is exactly what it was before standing approvals existed.
1772
1802
  */
1773
1803
  function identityOf(record) {
1774
1804
  return {
@@ -1785,7 +1815,14 @@ function identityOf(record) {
1785
1815
  bcc: [...record.expect.bcc],
1786
1816
  subject: record.expect.subject
1787
1817
  },
1788
- ...record.kind === "send" ? { sendEpoch: record.sendEpoch } : {}
1818
+ ...record.kind === "send" || record.kind === "standing" ? { sendEpoch: record.sendEpoch } : {},
1819
+ ...record.standingEpoch === void 0 ? {} : { standingEpoch: record.standingEpoch },
1820
+ ...record.standingId === void 0 ? {} : { standingId: record.standingId },
1821
+ ...record.standingUse === void 0 ? {} : { standingUse: {
1822
+ channel: record.standingUse.channel,
1823
+ thread: record.standingUse.thread,
1824
+ content: record.standingUse.content
1825
+ } }
1789
1826
  };
1790
1827
  }
1791
1828
  /** One listed file, as stored: its name and size, and its rename reason and flags only when it has them. */
@@ -1960,8 +1997,12 @@ function programNeedsQuotes(command) {
1960
1997
  const [program] = command.words;
1961
1998
  return command.platform === "win32" && program !== void 0 && windowsShellWord(program) !== program;
1962
1999
  }
2000
+ /**
2001
+ * One word for a POSIX shell: as it is when nothing in it is special, otherwise in single quotes. A word that starts
2002
+ * with `=` or `~` is quoted too: zsh reads `=ls` as the path of `ls`, and `~` as a home.
2003
+ */
1963
2004
  function posixShellWord(word) {
1964
- return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
2005
+ return /^(?![=~])[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`;
1965
2006
  }
1966
2007
  /**
1967
2008
  * The word as cmd.exe, Windows PowerShell and PowerShell 7 all hand it to a program, or null when no printing of it
@@ -3295,12 +3336,12 @@ function collectFromJson(parsed, client, path, topScope = "user") {
3295
3336
  function collectFromToml(text, client, path) {
3296
3337
  const servers = parseToml(text).mcp_servers;
3297
3338
  if (servers === void 0) return [];
3298
- if (!isRecord$1(servers)) throw new TomlError("mcp_servers is not a table");
3339
+ if (!isRecord$2(servers)) throw new TomlError("mcp_servers is not a table");
3299
3340
  const found = [];
3300
3341
  for (const [name, entry] of Object.entries(servers)) {
3301
- if (!isRecord$1(entry)) throw new TomlError(`mcp_servers.${name} is not a table`);
3342
+ if (!isRecord$2(entry)) throw new TomlError(`mcp_servers.${name} is not a table`);
3302
3343
  if (entry.args !== void 0 && !Array.isArray(entry.args)) throw new TomlError(`mcp_servers.${name}.args is not an array`);
3303
- if (entry.env !== void 0 && !isRecord$1(entry.env)) throw new TomlError(`mcp_servers.${name}.env is not a table`);
3344
+ if (entry.env !== void 0 && !isRecord$2(entry.env)) throw new TomlError(`mcp_servers.${name}.env is not a table`);
3304
3345
  found.push({
3305
3346
  client,
3306
3347
  path,
@@ -3308,12 +3349,12 @@ function collectFromToml(text, client, path) {
3308
3349
  command: typeof entry.command === "string" ? entry.command : "",
3309
3350
  args: Array.isArray(entry.args) ? entry.args.map(String) : [],
3310
3351
  ...typeof entry.url === "string" ? { url: entry.url } : {},
3311
- ...isRecord$1(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
3352
+ ...isRecord$2(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
3312
3353
  });
3313
3354
  }
3314
3355
  return found;
3315
3356
  }
3316
- function isRecord$1(value) {
3357
+ function isRecord$2(value) {
3317
3358
  return typeof value === "object" && value !== null && !Array.isArray(value);
3318
3359
  }
3319
3360
  /**
@@ -3326,8 +3367,8 @@ function isRecord$1(value) {
3326
3367
  */
3327
3368
  function codexServerFromGet(name, stdout, path) {
3328
3369
  const answer = JSON.parse(stdout);
3329
- if (!isRecord$1(answer)) throw new SyntaxError("not an object");
3330
- const entry = isRecord$1(answer.transport) ? answer.transport : answer;
3370
+ if (!isRecord$2(answer)) throw new SyntaxError("not an object");
3371
+ const entry = isRecord$2(answer.transport) ? answer.transport : answer;
3331
3372
  const command = typeof entry.command === "string" ? entry.command : "";
3332
3373
  const url = typeof entry.url === "string" ? entry.url : void 0;
3333
3374
  if (!command && !url) throw new SyntaxError("neither a command nor a URL");
@@ -3339,7 +3380,7 @@ function codexServerFromGet(name, stdout, path) {
3339
3380
  command,
3340
3381
  args: Array.isArray(entry.args) ? entry.args.map(String) : [],
3341
3382
  ...url ? { url } : {},
3342
- ...isRecord$1(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
3383
+ ...isRecord$2(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
3343
3384
  };
3344
3385
  return {
3345
3386
  ...server,
@@ -3445,7 +3486,7 @@ async function scanRegisteredServers(env = process.env, platform = process.platf
3445
3486
  continue;
3446
3487
  }
3447
3488
  add(collectFromJson(parsed, file.client, file.path));
3448
- if (file.client !== "claude-code" || !isRecord$1(parsed) || !isRecord$1(parsed.projects)) continue;
3489
+ if (file.client !== "claude-code" || !isRecord$2(parsed) || !isRecord$2(parsed.projects)) continue;
3449
3490
  for (const root of Object.keys(parsed.projects)) {
3450
3491
  if (!isAbsolute(root)) continue;
3451
3492
  const path = join(root, ".mcp.json");
@@ -6429,7 +6470,7 @@ const BEHAVIOUR_ACTS = Object.freeze([
6429
6470
  function isBehaviourAct(value) {
6430
6471
  return typeof value === "string" && BEHAVIOUR_ACTS.includes(value);
6431
6472
  }
6432
- function isRecord$2(value) {
6473
+ function isRecord$1(value) {
6433
6474
  return typeof value === "object" && value !== null && !Array.isArray(value);
6434
6475
  }
6435
6476
  /**
@@ -6476,7 +6517,7 @@ function accountNamed(written, names) {
6476
6517
  /** One stored rule, or why it cannot be read. Reasons are fixed words: nothing from the file is repeated in them. */
6477
6518
  function readRule(raw, index, names) {
6478
6519
  const at = `rule ${index + 1}`;
6479
- if (!isRecord$2(raw)) return { unreadable: {
6520
+ if (!isRecord$1(raw)) return { unreadable: {
6480
6521
  account: null,
6481
6522
  reason: `${at} is not an object`
6482
6523
  } };
@@ -6532,7 +6573,7 @@ function readBehaviourSettings(config) {
6532
6573
  rules: [],
6533
6574
  unreadable: []
6534
6575
  };
6535
- if (!isRecord$2(stored)) return unreadableWhole("the behaviour setting is not an object");
6576
+ if (!isRecord$1(stored)) return unreadableWhole("the behaviour setting is not an object");
6536
6577
  const rules = stored.rules;
6537
6578
  if (rules === void 0) return {
6538
6579
  rules: [],
@@ -6909,7 +6950,7 @@ const SHOWN_WIDTH = 120;
6909
6950
  * space, and cut to `SHOWN_WIDTH`. As `shownText` shows a profile's text, which this module cannot import: its module
6910
6951
  * reads the configuration, and the configuration imports this one.
6911
6952
  */
6912
- function shownName(value) {
6953
+ function shownName$1(value) {
6913
6954
  const flat = neutralise(value).text.replace(/[\n\t]+/g, " ");
6914
6955
  const chars = [...flat];
6915
6956
  return chars.length <= SHOWN_WIDTH ? flat : `${chars.slice(0, 119).join("")}…`;
@@ -6922,20 +6963,20 @@ function shownName(value) {
6922
6963
  * act, the place and the rule as the decision holds them, made safe the same way.
6923
6964
  */
6924
6965
  function actNotAllowed(decision, shown, hint) {
6925
- const account = shownName(shown.account);
6966
+ const account = shownName$1(shown.account);
6926
6967
  const named = shown.place ?? decision.place;
6927
- const where = named === null ? `in ${account}` : `in ${shownName(named)} (${account})`;
6968
+ const where = named === null ? `in ${account}` : `in ${shownName$1(named)} (${account})`;
6928
6969
  const message = decision.allowedActs.length === 0 ? `this agent may do nothing ${where}` : `this agent may only ${inWords(decision.allowedActs)} ${where}`;
6929
- const rule = decision.rule.conversation === void 0 ? { account: shownName(decision.rule.account) } : {
6930
- account: shownName(decision.rule.account),
6931
- conversation: shownName(decision.rule.conversation)
6970
+ const rule = decision.rule.conversation === void 0 ? { account: shownName$1(decision.rule.account) } : {
6971
+ account: shownName$1(decision.rule.account),
6972
+ conversation: shownName$1(decision.rule.conversation)
6932
6973
  };
6933
6974
  return new CommsError("ACT_NOT_ALLOWED", message, {
6934
6975
  hint,
6935
6976
  details: {
6936
6977
  act: decision.act,
6937
6978
  account,
6938
- place: decision.place === null ? null : shownName(decision.place),
6979
+ place: decision.place === null ? null : shownName$1(decision.place),
6939
6980
  rule,
6940
6981
  allowedActs: [...decision.allowedActs]
6941
6982
  }
@@ -6963,8 +7004,8 @@ function allowedHereLine(decision, shown) {
6963
7004
  const acts = decision.allowedActs;
6964
7005
  const allowed = acts === "all" ? "everything" : acts.length === 0 ? "nothing" : acts.join(", ");
6965
7006
  const conversation = decision.rule.conversation;
6966
- const place = conversation === void 0 ? "" : `, ${shownName(shown.place ?? conversation)}`;
6967
- return `Allowed here: ${allowed} (rule: ${shownName(shown.account)}${place})`;
7007
+ const place = conversation === void 0 ? "" : `, ${shownName$1(shown.place ?? conversation)}`;
7008
+ return `Allowed here: ${allowed} (rule: ${shownName$1(shown.account)}${place})`;
6968
7009
  }
6969
7010
  /**
6970
7011
  * `config.behaviour` with every rule that holds the account `id` renamed from `from` to `to` (design
@@ -6979,7 +7020,7 @@ function allowedHereLine(decision, shown) {
6979
7020
  */
6980
7021
  function behaviourRenamed(config, id, from, to) {
6981
7022
  const stored = config.behaviour;
6982
- if (!isRecord$2(stored)) return stored;
7023
+ if (!isRecord$1(stored)) return stored;
6983
7024
  const rules = stored.rules;
6984
7025
  if (!Array.isArray(rules)) return stored;
6985
7026
  const held = new Set([...Object.values(config.inboxes ?? {}), ...Object.values(config.accounts ?? {})].map((entry) => entry.id));
@@ -6990,7 +7031,7 @@ function behaviourRenamed(config, id, from, to) {
6990
7031
  };
6991
7032
  return {
6992
7033
  ...stored,
6993
- rules: rules.map((rule) => isRecord$2(rule) && holds(rule) ? {
7034
+ rules: rules.map((rule) => isRecord$1(rule) && holds(rule) ? {
6994
7035
  ...rule,
6995
7036
  account: to
6996
7037
  } : rule)
@@ -7567,6 +7608,7 @@ const configV2Schema = looseObject({
7567
7608
  /** What version 3 adds, loose as the rest of the file is: a later release's keys inside them are kept. */
7568
7609
  const v3Shape = {
7569
7610
  sendEpochs: record(string().min(1), number().int().min(0)).optional(),
7611
+ standingEpoch: number().int().min(0).optional(),
7570
7612
  legacyDrain: looseObject({
7571
7613
  since: absoluteTimeSchema,
7572
7614
  tracked: record(string().min(1), _enum(["open", ...[
@@ -7850,7 +7892,7 @@ var ConfigStore = class {
7850
7892
  });
7851
7893
  }
7852
7894
  }
7853
- const written = withSendEpochs(current, parsed.data);
7895
+ const written = withStandingEpoch(current, withSendEpochs(current, parsed.data));
7854
7896
  const serialized = `${JSON.stringify(written, null, 2)}\n`;
7855
7897
  await writeFileAtomic(this.path, serialized, 384, options.signal);
7856
7898
  committed = written;
@@ -8033,6 +8075,46 @@ function withSendEpochs(before, after) {
8033
8075
  sendEpochs: epochs
8034
8076
  };
8035
8077
  }
8078
+ /**
8079
+ * The standing epoch in `config` (design 2026-10-19-standing-approvals, S8): its stored value, or 0 when it has none —
8080
+ * or when it is not a whole number of 0 or more, which no write of this release leaves.
8081
+ */
8082
+ function standingEpochOf(config) {
8083
+ const epoch = config.standingEpoch;
8084
+ return typeof epoch === "number" && Number.isInteger(epoch) && epoch >= 0 ? epoch : 0;
8085
+ }
8086
+ /** The change policy of every inbox and account by id, each its own or the default. */
8087
+ function changePoliciesById(config) {
8088
+ const policies = /* @__PURE__ */ new Map();
8089
+ for (const inbox of Object.values(config.inboxes)) policies.set(inbox.id, inbox.changePolicy ?? defaultChangePolicy(config));
8090
+ for (const account of Object.values(config.accounts)) policies.set(account.id, account.changePolicy ?? defaultChangePolicy(config));
8091
+ return policies;
8092
+ }
8093
+ /**
8094
+ * Whether this write turns a change policy from `chat` to `confirm`: the default's, or the effective one of an inbox or
8095
+ * account there before and after, by id.
8096
+ */
8097
+ function tightensAChangePolicy(before, after) {
8098
+ if (defaultChangePolicy(before) === "chat" && defaultChangePolicy(after) === "confirm") return true;
8099
+ const was = changePoliciesById(before);
8100
+ for (const [id, policy] of changePoliciesById(after)) if (policy === "confirm" && was.get(id) === "chat") return true;
8101
+ return false;
8102
+ }
8103
+ /**
8104
+ * `after`, with the standing epoch this write owes (design 2026-10-19-standing-approvals, S8): on version 3, the higher
8105
+ * of the two it holds — never lowered, whatever the write's own copy says, nor dropped — and one more when it tightens a
8106
+ * change policy (`tightensAChangePolicy`). Before version 3 there is no epoch, and no standing approval: every one is
8107
+ * made on version 3.
8108
+ */
8109
+ function withStandingEpoch(before, after) {
8110
+ if (before.version !== 3 || after.version !== 3) return after;
8111
+ const held = Math.max(standingEpochOf(before), standingEpochOf(after));
8112
+ const kept = tightensAChangePolicy(before, after) ? held + 1 : held;
8113
+ return kept === 0 && after.standingEpoch === void 0 ? after : {
8114
+ ...after,
8115
+ standingEpoch: kept
8116
+ };
8117
+ }
8036
8118
  async function readFileIfExists(path) {
8037
8119
  try {
8038
8120
  return await readFile(path, "utf8");
@@ -8717,23 +8799,53 @@ function ownerEntry(config, ownerId) {
8717
8799
  */
8718
8800
  function liveGateOf(config, record) {
8719
8801
  const governing = (fallback) => record.kind === "change" && record.change !== void 0 ? governingChangePolicy(config, record.change) : fallback;
8802
+ const standingEpoch = standingEpochOf(config);
8720
8803
  if (record.ownerScope !== "owner") return {
8721
8804
  owner: "none",
8722
- changePolicy: governing(defaultChangePolicy(config))
8805
+ changePolicy: governing(defaultChangePolicy(config)),
8806
+ standingEpoch
8723
8807
  };
8724
8808
  const entry = ownerEntry(config, record.inboxId);
8725
- if (entry === void 0) return { owner: "removed" };
8809
+ if (entry === void 0) return {
8810
+ owner: "removed",
8811
+ standingEpoch
8812
+ };
8726
8813
  return {
8727
8814
  owner: "present",
8728
8815
  sendPolicy: entry.sendPolicy ?? config.defaults.sendPolicy,
8729
8816
  changePolicy: governing(entry.changePolicy ?? defaultChangePolicy(config)),
8730
- sendEpoch: sendEpochOf(config, record.inboxId)
8817
+ sendEpoch: sendEpochOf(config, record.inboxId),
8818
+ standingEpoch
8731
8819
  };
8732
8820
  }
8733
8821
  /** The reason a record whose owner was removed is revoked with. */
8734
8822
  const OWNER_REMOVED_REASON = "its mailbox or account was removed";
8735
8823
  /** The reason a send prepared before a `never` is revoked with: its stored epoch is behind the live one. */
8736
8824
  const SEND_EPOCH_REASON = "sending was turned off since this was prepared (policy: never)";
8825
+ /**
8826
+ * The reason a standing approval, or one of its unused children, is revoked with once standing approvals were ended
8827
+ * since it was made: its standing epoch is behind the live one (design 2026-10-19-standing-approvals, S8).
8828
+ */
8829
+ const STANDING_EPOCH_REASON = "standing approvals were ended since this was approved";
8830
+ /**
8831
+ * The reason a standing approval is revoked with once its account's change policy is stricter than the one it was
8832
+ * approved under (`approvedChangePolicy`) — `changePolicy-tightened`, read from the configuration itself, whatever the
8833
+ * standing epoch says, so a release that knows no epoch tightening the policy still ends it (design
8834
+ * 2026-10-19-standing-approvals, S8, the lead's call after review).
8835
+ */
8836
+ const CHANGE_POLICY_TIGHTENED_REASON = "the change policy was tightened since this was approved";
8837
+ /** Whether `live` is stricter than `approved`: `confirm` over `chat`. */
8838
+ function changePolicyTightened(approved, live) {
8839
+ return live === "confirm" && approved !== "confirm";
8840
+ }
8841
+ /**
8842
+ * Whether even a look writes this outcome's revocation down: a standing approval ended by a tightened change policy.
8843
+ * Every other revocation a look derives is from something that never goes back — an epoch, an owner's id — so the next
8844
+ * action can write it; a policy can be loosened again, and what it ended must stay ended (S8).
8845
+ */
8846
+ function writtenAtALook(outcome) {
8847
+ return outcome.revokes && outcome.record?.kind === "standing" && outcome.reason === "the change policy was tightened since this was approved";
8848
+ }
8737
8849
  /** What an outcome says of a pending or approved send under a live `never`, before anything revokes it. */
8738
8850
  const NEVER_NOTICE = "sending is turned off (policy: never); any use revokes it";
8739
8851
  /** The refusal of a claim, an approval or an answer by a store opened without a configuration. */
@@ -8742,7 +8854,11 @@ const NO_CONFIGURATION = "this approval store was opened without a configuration
8742
8854
  const sentAtWords = (usedAt) => `sent at ${usedAt}`;
8743
8855
  /** The refusal prefix and detail key of each kind: a send sends nothing, a change changes nothing, a question saves nothing. */
8744
8856
  function refusalOf(kind) {
8745
- const [prefix, idKey, hint] = kind === "disclosure" ? [
8857
+ const [prefix, idKey, hint] = kind === "standing" ? [
8858
+ "nothing was sent",
8859
+ "approvalId",
8860
+ "This standing approval cannot be used. Ask the person instead; only they can set up another, at their own terminal."
8861
+ ] : kind === "disclosure" ? [
8746
8862
  "nothing was disclosed",
8747
8863
  "approvalId",
8748
8864
  "Prepare a new standing disclosure authorisation and show its exact activation preview to the person."
@@ -8773,7 +8889,7 @@ function refusalOf(kind) {
8773
8889
  * or one a pinned surface may not see — byte for byte the same, with `approval: null`, and nothing of any record in it.
8774
8890
  */
8775
8891
  function approvalNotFound(approvalId, kind) {
8776
- 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}`, {
8892
+ 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}`, {
8777
8893
  hint: "Check the id: an approval is only found by the surface, the kind and the owner it was prepared for.",
8778
8894
  details: { approval: null }
8779
8895
  });
@@ -9030,12 +9146,20 @@ function classifyV2(read, context) {
9030
9146
  const ownerRemoved = read.ownerScope === "owner" && live?.owner === "removed";
9031
9147
  const active = read.state === "pending" || read.state === "approved";
9032
9148
  const acting = action === "approve" || action === "claim";
9033
- const never = read.kind === "send" && live?.owner === "present" && stricterPolicy(live.sendPolicy ?? "never", read.requiredPolicy) === "never";
9149
+ const sends = read.kind === "send" || read.kind === "standing";
9150
+ const standingBound = read.kind === "standing" || read.kind === "send" && read.route === "standing";
9151
+ const never = sends && live?.owner === "present" && stricterPolicy(live.sendPolicy ?? "never", read.requiredPolicy) === "never";
9034
9152
  let derived = null;
9153
+ let revokedByNever = false;
9035
9154
  if (active && ownerRemoved) derived = OWNER_REMOVED_REASON;
9036
9155
  else if (active && never) {
9037
- 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)";
9038
- } else if (active && read.kind === "send" && live?.owner === "present" && (read.sendEpoch ?? 0) !== live.sendEpoch) derived = SEND_EPOCH_REASON;
9156
+ if (acting) {
9157
+ derived = live?.sendPolicy === "never" ? `sending is turned off for this ${accountNoun(read.channel)} (policy: never)` : "sending is turned off for this approval (policy: never)";
9158
+ revokedByNever = true;
9159
+ }
9160
+ } else if (active && sends && live?.owner === "present" && (read.sendEpoch ?? 0) !== live.sendEpoch) derived = SEND_EPOCH_REASON;
9161
+ else if (active && standingBound && live !== null && live.standingEpoch !== void 0 && read.standingEpoch !== live.standingEpoch) derived = STANDING_EPOCH_REASON;
9162
+ else if (read.kind === "standing" && read.state === "approved" && live?.owner === "present" && changePolicyTightened(read.approvedChangePolicy, live.changePolicy)) derived = CHANGE_POLICY_TIGHTENED_REASON;
9039
9163
  const record = derived === null ? read : {
9040
9164
  ...read,
9041
9165
  state: "revoked",
@@ -9043,7 +9167,6 @@ function classifyV2(read, context) {
9043
9167
  revokedAt: at,
9044
9168
  challengeHash: void 0
9045
9169
  };
9046
- const revokedByNever = derived !== null && derived !== "its mailbox or account was removed" && derived !== "sending was turned off since this was prepared (policy: never)";
9047
9170
  let claimable = false;
9048
9171
  let reason = record.reason;
9049
9172
  if (record.state === "pending" || record.state === "approved") {
@@ -9109,6 +9232,7 @@ function isClaimable(record, live) {
9109
9232
  case "change": return record.state === "approved" || record.route === "chat" && live.changePolicy === "chat";
9110
9233
  case "download": return downloadClaimable(record, live.changePolicy);
9111
9234
  case "disclosure": return record.state === "approved";
9235
+ case "standing": return false;
9112
9236
  }
9113
9237
  }
9114
9238
  /**
@@ -9157,7 +9281,7 @@ function errorOf(record, approval, context) {
9157
9281
  if (download) return refuse("APPROVAL_EXPIRED", downloadExpiredWords(record.approvedVia !== void 0), record, approval);
9158
9282
  const nothing = `nothing was ${record.kind === "change" ? "changed" : "sent"} with it`;
9159
9283
  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}`, {
9160
- 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.",
9284
+ 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.",
9161
9285
  details: {
9162
9286
  approvalId: record.approvalId,
9163
9287
  state: record.state,
@@ -9241,6 +9365,578 @@ var AuditLog = class {
9241
9365
  }
9242
9366
  };
9243
9367
  /**
9368
+ * The emoji a standing approval may react with (design 2026-10-19-standing-approvals, S3 and §8.4): a curated list of
9369
+ * standard Unicode names, each with its code point, shipped in the code.
9370
+ *
9371
+ * Only standard names, because a custom workspace emoji's image can be replaced under the same name: the person would
9372
+ * have approved a picture that is no longer the one sent. And a short list rather than every Unicode name, because each
9373
+ * one here is a word the agent may say on the person's behalf without asking.
9374
+ */
9375
+ const STANDARD_EMOJI = Object.freeze({
9376
+ eyes: "U+1F440",
9377
+ white_check_mark: "U+2705",
9378
+ heavy_check_mark: "U+2714",
9379
+ ballot_box_with_check: "U+2611",
9380
+ "+1": "U+1F44D",
9381
+ thumbsup: "U+1F44D",
9382
+ ok: "U+1F197",
9383
+ ok_hand: "U+1F44C",
9384
+ "100": "U+1F4AF",
9385
+ heavy_plus_sign: "U+2795",
9386
+ handshake: "U+1F91D",
9387
+ raised_hands: "U+1F64C",
9388
+ pray: "U+1F64F",
9389
+ clap: "U+1F44F",
9390
+ wave: "U+1F44B",
9391
+ memo: "U+1F4DD",
9392
+ pencil2: "U+270F",
9393
+ hourglass_flowing_sand: "U+23F3",
9394
+ stopwatch: "U+23F1",
9395
+ inbox_tray: "U+1F4E5",
9396
+ pushpin: "U+1F4CC",
9397
+ mag: "U+1F50D",
9398
+ bulb: "U+1F4A1",
9399
+ speech_balloon: "U+1F4AC",
9400
+ thinking_face: "U+1F914",
9401
+ slightly_smiling_face: "U+1F642",
9402
+ heart: "U+2764",
9403
+ tada: "U+1F389",
9404
+ rocket: "U+1F680",
9405
+ sparkles: "U+2728"
9406
+ });
9407
+ /**
9408
+ * The emoji among them that people read as a yes: ✅ on "can I deploy?" is taken for approval, and a Slack workflow
9409
+ * can start on a reaction (S3). Allowed, with a warning in the preview.
9410
+ */
9411
+ const READS_AS_APPROVAL = Object.freeze([
9412
+ "white_check_mark",
9413
+ "heavy_check_mark",
9414
+ "ballot_box_with_check",
9415
+ "+1",
9416
+ "thumbsup",
9417
+ "ok",
9418
+ "ok_hand",
9419
+ "100",
9420
+ "heavy_plus_sign",
9421
+ "handshake"
9422
+ ]);
9423
+ /** Whether `name` is one of the standard emoji a standing approval may hold, exactly as listed. */
9424
+ function isStandardEmoji(name) {
9425
+ return Object.hasOwn(STANDARD_EMOJI, name);
9426
+ }
9427
+ Object.freeze(["reaction", "reply"]);
9428
+ /** S3: what a standing approval holds when no content is given. */
9429
+ const STANDING_DEFAULT_REACTION = "eyes";
9430
+ /** One day, the unit a standing approval's expiry is counted in. */
9431
+ const STANDING_DAY_MS = 864e5;
9432
+ /** S6's table: each limit's default and the range a person may set it to. */
9433
+ const STANDING_LIMITS = Object.freeze({
9434
+ perThread: Object.freeze({
9435
+ default: 1,
9436
+ min: 1,
9437
+ max: 3
9438
+ }),
9439
+ perHour: Object.freeze({
9440
+ default: 10,
9441
+ min: 1,
9442
+ max: 30
9443
+ }),
9444
+ perDay: Object.freeze({
9445
+ default: 30,
9446
+ min: 1,
9447
+ max: 100
9448
+ }),
9449
+ perSender: Object.freeze({
9450
+ default: 3,
9451
+ min: 1,
9452
+ max: 10
9453
+ }),
9454
+ expiresDays: Object.freeze({
9455
+ default: 7,
9456
+ min: 1,
9457
+ max: 30
9458
+ })
9459
+ });
9460
+ const LIMIT_NAMES$1 = [
9461
+ "perThread",
9462
+ "perHour",
9463
+ "perDay",
9464
+ "perSender",
9465
+ "expiresDays"
9466
+ ];
9467
+ const CONTENT_WORDS = Object.freeze({
9468
+ "too-many-reactions": `a standing approval holds at most 5 reactions`,
9469
+ "too-many-replies": `a standing approval holds at most 5 replies`,
9470
+ "custom-emoji": "only a standard emoji from the list this release ships may be a standing reaction",
9471
+ empty: "a standing reply has no words",
9472
+ "too-long": `a standing reply is at most 200 characters`,
9473
+ "multi-line": "a standing reply is one line",
9474
+ "control-character": "a standing reply holds a control or invisible character",
9475
+ mention: "a standing reply mentions nobody: no @, and no <@…> or <!…>",
9476
+ link: "a standing reply holds no link, address or domain",
9477
+ placeholder: "a standing reply is sent exactly as written: it holds no placeholder",
9478
+ markup: "a standing reply is plain text: no formatting characters",
9479
+ "emoji-code": "a standing reply holds no :emoji: code; a reaction is its own content",
9480
+ pictograph: "a standing reply holds no emoji or pictograph, such as ✅, ✔ or 👍; a reaction is its own content",
9481
+ duplicate: "a standing approval holds each content once"
9482
+ });
9483
+ /** The refusal of a content, as BAD_DATA, with its reason and which content it was. */
9484
+ function contentRefused(reason, where) {
9485
+ return new CommsError("BAD_DATA", `nothing was prepared: ${CONTENT_WORDS[reason]}`, {
9486
+ 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.",
9487
+ details: {
9488
+ reason,
9489
+ ...where === void 0 ? {} : {
9490
+ kind: where.kind,
9491
+ position: where.position
9492
+ }
9493
+ }
9494
+ });
9495
+ }
9496
+ /** The digest of one content: its kind and exact text. */
9497
+ function standingContentDigest(kind, text) {
9498
+ return sha256Hex(canonicalJson$1({
9499
+ kind,
9500
+ text
9501
+ }));
9502
+ }
9503
+ /** A reaction's name, exactly as listed — `:name:` read as `name` — or the refusal of a custom one. */
9504
+ function reactionName(written, position) {
9505
+ const trimmed = written.trim();
9506
+ const name = /^:[^:]+:$/.test(trimmed) ? trimmed.slice(1, -1) : trimmed;
9507
+ if (!isStandardEmoji(name)) throw contentRefused("custom-emoji", {
9508
+ kind: "reaction",
9509
+ position
9510
+ });
9511
+ return name;
9512
+ }
9513
+ /** Each reason a reply's text is refused for, in the order checked: the first that applies is the one given. */
9514
+ const REPLY_RULES = Object.freeze([
9515
+ ["multi-line", (text) => /[\r\n\u2028\u2029]/.test(text)],
9516
+ ["pictograph", (text) => /[\p{Extended_Pictographic}\u2713]/u.test(text)],
9517
+ ["control-character", (text) => [...text].some((char) => char === " " || isDangerous(char.codePointAt(0) ?? 0))],
9518
+ ["mention", (text) => /@|<[@!#]/.test(text)],
9519
+ ["link", (text) => /:\/\/|www\.|mailto:|[A-Za-z0-9-]+\.[A-Za-z]{2,}/i.test(text)],
9520
+ ["placeholder", (text) => /[{}[\]]/.test(text)],
9521
+ ["markup", (text) => /[<>*_~`|]/.test(text)],
9522
+ ["emoji-code", (text) => /:[A-Za-z0-9_+-]+:/.test(text)]
9523
+ ]);
9524
+ /**
9525
+ * 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
9526
+ * approval as surely as ✅ is. Matched whole, in any case. Allowed, with a warning in the preview.
9527
+ */
9528
+ const APPROVAL_WORDS = Object.freeze([
9529
+ ["yes", /\byes\b/i],
9530
+ ["approved", /\bapproved\b/i],
9531
+ ["approve", /\bapprove\b/i],
9532
+ ["go ahead", /\bgo\s+ahead\b/i],
9533
+ ["lgtm", /\blgtm\b/i],
9534
+ ["ok", /\bok\b/i],
9535
+ ["done", /\bdone\b/i],
9536
+ ["confirmed", /\bconfirmed\b/i]
9537
+ ]);
9538
+ /** What a reply's reads-as-approval warning begins with: the preview shows it under that reply. */
9539
+ function replyWarningPrefix(text) {
9540
+ return `The reply "${text}" can read as approval:`;
9541
+ }
9542
+ /** S3's warning for a reply holding words that read as a yes, or null. */
9543
+ function replyReadsAsApproval(text) {
9544
+ const said = APPROVAL_WORDS.filter(([, pattern]) => pattern.test(text)).map(([word]) => `"${word}"`);
9545
+ if (said.length === 0) return null;
9546
+ return `${replyWarningPrefix(text)} it says ${said.join(", ")}. People read "OK" or "Done" on "can I deploy?" as a yes.`;
9547
+ }
9548
+ /** A reply's text, trimmed and checked (S3), or the refusal of the first rule it breaks. */
9549
+ function replyText(written, position) {
9550
+ const where = {
9551
+ kind: "reply",
9552
+ position
9553
+ };
9554
+ const text = written.trim();
9555
+ if (text === "") throw contentRefused("empty", where);
9556
+ for (const [reason, breaks] of REPLY_RULES) if (breaks(text)) throw contentRefused(reason, where);
9557
+ if ([...text].length > 200) throw contentRefused("too-long", where);
9558
+ return text;
9559
+ }
9560
+ /** S3's warning for an emoji that reads as approval, in the spec's words. */
9561
+ function readsAsApprovalWarning(name) {
9562
+ return `:${name}: can read as approval: people read ✅ on "can I deploy?" as a yes, and a Slack workflow can start on a reaction.`;
9563
+ }
9564
+ /**
9565
+ * The contents a person wrote, checked (S3): reactions first, then replies, each as written, each with its digest; and
9566
+ * the warnings the preview shows. With no content given at all, `eyes` alone. Refuses BAD_DATA, with `details.reason`,
9567
+ * for anything S3 does not allow.
9568
+ */
9569
+ function checkedContents(input) {
9570
+ const reactions = input.reactions ?? [];
9571
+ const replies = input.replies ?? [];
9572
+ if (reactions.length > 5) throw contentRefused("too-many-reactions");
9573
+ if (replies.length > 5) throw contentRefused("too-many-replies");
9574
+ const names = reactions.length === 0 && replies.length === 0 ? [STANDING_DEFAULT_REACTION] : reactions.map((written, index) => reactionName(written, index + 1));
9575
+ const texts = replies.map((written, index) => replyText(written, index + 1));
9576
+ const contents = [...names.map((text) => ({
9577
+ kind: "reaction",
9578
+ text,
9579
+ digest: standingContentDigest("reaction", text)
9580
+ })), ...texts.map((text) => ({
9581
+ kind: "reply",
9582
+ text,
9583
+ digest: standingContentDigest("reply", text)
9584
+ }))];
9585
+ if (new Set(contents.map((content) => content.digest)).size !== contents.length) throw contentRefused("duplicate");
9586
+ return {
9587
+ contents,
9588
+ warnings: [...names.filter((name) => READS_AS_APPROVAL.includes(name)).map(readsAsApprovalWarning), ...texts.map(replyReadsAsApproval).filter((warning) => warning !== null)]
9589
+ };
9590
+ }
9591
+ /** S6's limits, each from the table: its default when left out, and BAD_DATA outside its range. */
9592
+ function checkedLimits(input) {
9593
+ const limits = {};
9594
+ for (const name of LIMIT_NAMES$1) {
9595
+ const { default: fallback, min, max } = STANDING_LIMITS[name];
9596
+ const given = input[name];
9597
+ const value = given ?? fallback;
9598
+ 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}`, {
9599
+ hint: `Leave it out for ${fallback}, or give a whole number from ${min} to ${max}.`,
9600
+ details: {
9601
+ reason: "limit-out-of-range",
9602
+ limit: name,
9603
+ min,
9604
+ max,
9605
+ given: given ?? null
9606
+ }
9607
+ });
9608
+ limits[name] = value;
9609
+ }
9610
+ return limits;
9611
+ }
9612
+ /** The canonical form of a place, as the digest binds it. */
9613
+ function canonicalPlaces(places) {
9614
+ if (places.kind === "threads-i-am-in") return { kind: "threads-i-am-in" };
9615
+ return {
9616
+ kind: "exact",
9617
+ places: places.places.map((place) => ({
9618
+ id: place.id,
9619
+ name: place.name,
9620
+ members: place.members,
9621
+ shared: {
9622
+ extShared: place.shared.extShared,
9623
+ pendingExtShared: place.shared.pendingExtShared,
9624
+ externalMembers: place.shared.externalMembers
9625
+ }
9626
+ }))
9627
+ };
9628
+ }
9629
+ /**
9630
+ * The digest a standing record is bound to: everything its binding holds but the summary — the account, every content
9631
+ * with its digest in order, every place as recorded, the limits, who proposed it, what it replaces, and the warnings
9632
+ * the person was shown.
9633
+ */
9634
+ function standingDigest(binding) {
9635
+ return sha256Hex(canonicalJson$1({
9636
+ kind: "standing",
9637
+ target: {
9638
+ kind: binding.target.kind,
9639
+ name: binding.target.name,
9640
+ id: binding.target.id
9641
+ },
9642
+ contents: binding.contents.map((content) => ({
9643
+ kind: content.kind,
9644
+ text: content.text,
9645
+ digest: content.digest
9646
+ })),
9647
+ places: canonicalPlaces(binding.places),
9648
+ limits: {
9649
+ perThread: binding.limits.perThread,
9650
+ perHour: binding.limits.perHour,
9651
+ perDay: binding.limits.perDay,
9652
+ perSender: binding.limits.perSender,
9653
+ expiresDays: binding.limits.expiresDays
9654
+ },
9655
+ proposedBy: binding.proposedBy,
9656
+ replaces: binding.replaces ?? null,
9657
+ warnings: [...binding.warnings]
9658
+ }));
9659
+ }
9660
+ /** A narrowing that is not one: USAGE, saying what to do instead. */
9661
+ function notANarrowing(message) {
9662
+ 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." });
9663
+ }
9664
+ /**
9665
+ * The standing approval as it applies after `narrowed`: only the contents whose digests it keeps, only the exact
9666
+ * places it keeps, and the lower of each limit — so a narrowing never raises one, whatever it says. USAGE for a digest
9667
+ * or a place the approval does not hold, for keeping none, and for places kept from `threads-i-am-in`, which names none.
9668
+ */
9669
+ function effectiveStanding(binding, narrowed) {
9670
+ if (narrowed === void 0) return binding;
9671
+ let contents = binding.contents;
9672
+ if (narrowed.keep !== void 0) {
9673
+ const keep = new Set(narrowed.keep);
9674
+ 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");
9675
+ contents = binding.contents.filter((content) => keep.has(content.digest));
9676
+ if (contents.length === 0) throw notANarrowing("it would keep no content at all");
9677
+ }
9678
+ let places = binding.places;
9679
+ if (narrowed.places !== void 0) {
9680
+ if (binding.places.kind !== "exact") throw notANarrowing("threads-i-am-in names no places to keep; narrow its contents or limits, or revoke it");
9681
+ const keep = new Set(narrowed.places);
9682
+ const held = binding.places.places;
9683
+ for (const id of keep) if (!held.some((place) => place.id === id)) throw notANarrowing("it keeps a place this standing approval does not cover");
9684
+ const kept = held.filter((place) => keep.has(place.id));
9685
+ if (kept.length === 0) throw notANarrowing("it would keep no place at all");
9686
+ places = {
9687
+ kind: "exact",
9688
+ places: kept
9689
+ };
9690
+ }
9691
+ const limits = { ...binding.limits };
9692
+ for (const name of LIMIT_NAMES$1) {
9693
+ const lower = narrowed.limits?.[name];
9694
+ if (lower !== void 0 && Number.isFinite(lower)) limits[name] = Math.min(limits[name], lower);
9695
+ }
9696
+ return {
9697
+ ...binding,
9698
+ contents,
9699
+ places,
9700
+ limits
9701
+ };
9702
+ }
9703
+ /**
9704
+ * The narrowing `narrowing` makes of what is in force — `binding` as `narrowed` already leaves it — as the `narrowed` to
9705
+ * store beside `binding`: the contents and exact places kept, and every limit, as asked. Applies at once, so it may
9706
+ * only narrow (S8): USAGE for anything that would widen what is in force — a higher limit, a later expiry, a content
9707
+ * or a place not held — and BAD_DATA for a limit outside the table.
9708
+ */
9709
+ function narrowedFurther(binding, narrowed, narrowing) {
9710
+ const before = effectiveStanding(binding, narrowed);
9711
+ const kept = effectiveStanding(before, {
9712
+ ...narrowing.keep === void 0 ? {} : { keep: narrowing.keep },
9713
+ ...narrowing.places === void 0 ? {} : { places: narrowing.places }
9714
+ });
9715
+ const asked = { ...kept.limits };
9716
+ for (const name of LIMIT_NAMES$1) {
9717
+ const value = narrowing.limits?.[name];
9718
+ if (value !== void 0) asked[name] = value;
9719
+ }
9720
+ const after = {
9721
+ ...kept,
9722
+ limits: checkedLimits(asked)
9723
+ };
9724
+ if (classifyStandingChange(before, after) === "widens") throw new CommsError("USAGE", "nothing was narrowed: this would widen the standing approval, not narrow it", {
9725
+ 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.",
9726
+ details: { reason: "widens" }
9727
+ });
9728
+ return {
9729
+ keep: after.contents.map((content) => content.digest),
9730
+ ...after.places.kind === "exact" ? { places: after.places.places.map((place) => place.id) } : {},
9731
+ limits: { ...after.limits }
9732
+ };
9733
+ }
9734
+ /** How many days a standing approval lasts once approved, as narrowed: the lower of its own and the narrowing's. */
9735
+ function standingDays(binding, narrowed) {
9736
+ const lower = narrowed?.limits?.expiresDays;
9737
+ return typeof lower === "number" && Number.isFinite(lower) ? Math.min(binding.limits.expiresDays, lower) : binding.limits.expiresDays;
9738
+ }
9739
+ /** A Slack conversation a standing approval may name exactly: a channel, a private channel or group DM, or a DM. */
9740
+ const STANDING_PLACE_PATTERN = /^[CGD][A-Z0-9]{2,}$/;
9741
+ const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
9742
+ const isStrings$1 = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
9743
+ /** Why `shared` is not a recorded sharing state, or null. */
9744
+ function sharedProblem(shared) {
9745
+ if (!isRecord$3(shared)) return "a place has no sharing state";
9746
+ if (typeof shared.extShared !== "boolean" || typeof shared.pendingExtShared !== "boolean") return "a place has no sharing state";
9747
+ if (shared.externalMembers !== null && typeof shared.externalMembers !== "boolean") return "a place has no sharing state";
9748
+ return null;
9749
+ }
9750
+ /** Why `places` is not a standing approval's places, or null. */
9751
+ function placesProblem(places) {
9752
+ if (!isRecord$3(places)) return "it has no places";
9753
+ if (places.kind === "threads-i-am-in") return Object.keys(places).length === 1 ? null : "its places are not one kind";
9754
+ if (places.kind !== "exact" || !Array.isArray(places.places)) return "it has no places";
9755
+ if (places.places.length === 0) return "it names no place";
9756
+ const seen = /* @__PURE__ */ new Set();
9757
+ for (const place of places.places) {
9758
+ 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";
9759
+ if (seen.has(place.id)) return "a place is named twice";
9760
+ seen.add(place.id);
9761
+ if (place.name !== null && typeof place.name !== "string") return "a place has no name or null";
9762
+ if (!Number.isSafeInteger(place.members) || place.members < 0) return "a place has no member count";
9763
+ const shared = sharedProblem(place.shared);
9764
+ if (shared !== null) return shared;
9765
+ }
9766
+ return null;
9767
+ }
9768
+ /**
9769
+ * Why `binding` is not one a standing record may hold, or null: every content one `checkedContents` gives for its
9770
+ * texts, in order, with its digest; exactly the warnings those contents give (S3); every limit inside the table; places
9771
+ * 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
9772
+ * the validator a record that holds one, so nothing a caller assembles by hand — a custom emoji, a 31-day expiry, a
9773
+ * reply with a link — can become a standing approval.
9774
+ */
9775
+ function standingBindingProblem(binding) {
9776
+ if (!isRecord$3(binding)) return "it is not a standing approval";
9777
+ if (typeof binding.summary !== "string") return "it has no summary";
9778
+ const target = binding.target;
9779
+ if (!isRecord$3(target) || target.kind !== "account" || typeof target.name !== "string") return "it names no account";
9780
+ if (typeof target.id !== "string" || !ACCOUNT_ID_PATTERN.test(target.id)) return "it names no account by its id";
9781
+ if (!Array.isArray(binding.contents)) return "it has no contents";
9782
+ const written = binding.contents;
9783
+ if (!written.every((content) => isRecord$3(content) && typeof content.text === "string")) return "a content is unreadable";
9784
+ const contents = written;
9785
+ let checked;
9786
+ let warnings;
9787
+ try {
9788
+ ({contents: checked, warnings} = checkedContents({
9789
+ reactions: contents.filter((content) => content.kind === "reaction").map((content) => content.text),
9790
+ replies: contents.filter((content) => content.kind === "reply").map((content) => content.text)
9791
+ }));
9792
+ } catch {
9793
+ return "a content is not one a standing approval may hold";
9794
+ }
9795
+ if (contents.length === 0) return "it holds no content";
9796
+ const canonical = (list) => canonicalJson$1(list.map((content) => ({
9797
+ kind: content.kind,
9798
+ text: content.text,
9799
+ digest: content.digest
9800
+ })));
9801
+ if (canonical(checked) !== canonical(contents)) return "its contents are not as checked, in order, with their digests";
9802
+ const places = placesProblem(binding.places);
9803
+ if (places !== null) return places;
9804
+ if (!isRecord$3(binding.limits)) return "it has no limits";
9805
+ try {
9806
+ if (canonicalJson$1(checkedLimits(binding.limits)) !== canonicalJson$1(binding.limits)) return "its limits are not whole and complete";
9807
+ } catch {
9808
+ return "a limit is outside the table";
9809
+ }
9810
+ if (binding.proposedBy !== "agent" && binding.proposedBy !== "person") return "it does not say who proposed it";
9811
+ if (binding.replaces !== void 0 && (typeof binding.replaces !== "string" || !APPROVAL_ID_PATTERN.test(binding.replaces))) return "what it replaces is not an approval id";
9812
+ if (!isStrings$1(binding.warnings)) return "its warnings are unreadable";
9813
+ if (canonicalJson$1(binding.warnings) !== canonicalJson$1(warnings)) return "its warnings are not the ones its contents give";
9814
+ return null;
9815
+ }
9816
+ /** A copy of `binding` holding exactly its own fields, or BAD_DATA naming why it is not one (`standingBindingProblem`). */
9817
+ function canonicalStandingBinding(binding) {
9818
+ const problem = standingBindingProblem(binding);
9819
+ if (problem !== null) throw new CommsError("BAD_DATA", `nothing was prepared: ${problem}`, {
9820
+ hint: "This is a bug — please report it.",
9821
+ details: { reason: "binding-invalid" }
9822
+ });
9823
+ return {
9824
+ summary: binding.summary,
9825
+ target: {
9826
+ kind: "account",
9827
+ name: binding.target.name,
9828
+ id: binding.target.id
9829
+ },
9830
+ contents: binding.contents.map((content) => ({
9831
+ kind: content.kind,
9832
+ text: content.text,
9833
+ digest: content.digest
9834
+ })),
9835
+ places: binding.places.kind === "threads-i-am-in" ? { kind: "threads-i-am-in" } : {
9836
+ kind: "exact",
9837
+ places: binding.places.places.map((place) => ({
9838
+ id: place.id,
9839
+ name: place.name,
9840
+ members: place.members,
9841
+ shared: {
9842
+ extShared: place.shared.extShared,
9843
+ pendingExtShared: place.shared.pendingExtShared,
9844
+ externalMembers: place.shared.externalMembers
9845
+ }
9846
+ }))
9847
+ },
9848
+ limits: { ...binding.limits },
9849
+ proposedBy: binding.proposedBy,
9850
+ ...binding.replaces === void 0 ? {} : { replaces: binding.replaces },
9851
+ warnings: [...binding.warnings]
9852
+ };
9853
+ }
9854
+ /**
9855
+ * Why `narrowed`, stored beside `binding`, is not a narrowing of it, or null: each key what a narrowing holds, each
9856
+ * content and place one the binding has, and no limit above the binding's or outside the table. Not bound into the
9857
+ * record's digest, since it changes after approval; only ever able to narrow, so a hand that edits it can only take away.
9858
+ */
9859
+ function standingNarrowingProblem(binding, narrowed) {
9860
+ if (narrowed === void 0) return null;
9861
+ if (!isRecord$3(narrowed)) return "it is not a narrowing";
9862
+ for (const key of Object.keys(narrowed)) if (![
9863
+ "keep",
9864
+ "places",
9865
+ "limits"
9866
+ ].includes(key)) return "it is not a narrowing";
9867
+ if (narrowed.keep !== void 0 && !isStrings$1(narrowed.keep)) return "it keeps something that is not a digest";
9868
+ if (narrowed.places !== void 0 && !isStrings$1(narrowed.places)) return "it keeps something that is not a place";
9869
+ if (narrowed.limits !== void 0) {
9870
+ if (!isRecord$3(narrowed.limits)) return "its limits are unreadable";
9871
+ for (const [name, value] of Object.entries(narrowed.limits)) {
9872
+ if (!LIMIT_NAMES$1.includes(name)) return "it narrows a limit there is none of";
9873
+ const limit = name;
9874
+ if (!Number.isSafeInteger(value) || value < STANDING_LIMITS[limit].min) return "a limit is outside the table";
9875
+ if (value > binding.limits[limit]) return "it raises a limit";
9876
+ }
9877
+ }
9878
+ try {
9879
+ effectiveStanding(binding, narrowed);
9880
+ } catch {
9881
+ return "it keeps what the standing approval does not hold";
9882
+ }
9883
+ return null;
9884
+ }
9885
+ /** Whether a place recorded as `after` is one `before` approved: the same id, shared the same, and no bigger a room. */
9886
+ function placeCovered(before, after) {
9887
+ const held = before.find((place) => place.id === after.id);
9888
+ if (held === void 0) return false;
9889
+ if (canonicalJson$1(held.shared) !== canonicalJson$1(after.shared)) return false;
9890
+ return after.members < 50 || held.members >= 50;
9891
+ }
9892
+ /**
9893
+ * What `after` does to `before` (S8): `widens` with any content digest or exact place `before` does not hold — a place
9894
+ * shared differently, or grown to 50 or more members, is not the place it approved — a swap between exact places and
9895
+ * `threads-i-am-in` either way, another account, any higher limit, or a later expiry; `same` when it permits exactly
9896
+ * what `before` does; anything else that differs `narrows`. Unclear is a widening: it costs a person a code, never a
9897
+ * yes they did not give.
9898
+ */
9899
+ function classifyStandingChange(before, after) {
9900
+ if (before.target.id !== after.target.id) return "widens";
9901
+ const held = new Set(before.contents.map((content) => content.digest));
9902
+ if (after.contents.some((content) => !held.has(content.digest))) return "widens";
9903
+ if (before.places.kind !== after.places.kind) return "widens";
9904
+ if (before.places.kind === "exact" && after.places.kind === "exact") {
9905
+ const approved = before.places.places;
9906
+ if (after.places.places.some((place) => !placeCovered(approved, place))) return "widens";
9907
+ }
9908
+ for (const name of LIMIT_NAMES$1) if (after.limits[name] > before.limits[name]) return "widens";
9909
+ const kept = new Set(after.contents.map((content) => content.digest));
9910
+ const fewerContents = before.contents.some((content) => !kept.has(content.digest));
9911
+ const afterPlaces = after.places.kind === "exact" ? after.places.places : [];
9912
+ const fewerPlaces = before.places.kind === "exact" && before.places.places.some((place) => !afterPlaces.some((now) => now.id === place.id));
9913
+ const lower = LIMIT_NAMES$1.some((name) => after.limits[name] < before.limits[name]);
9914
+ return fewerContents || fewerPlaces || lower ? "narrows" : "same";
9915
+ }
9916
+ Object.freeze({
9917
+ "not-fresh": "the message is older than 15 minutes, or than this standing approval",
9918
+ "not-newest": "the message is not the newest from someone else in its thread or channel",
9919
+ "already-acknowledged": "this message or thread was already acknowledged under a standing approval",
9920
+ "not-human": "the message, or its thread's first message, is not from a person",
9921
+ interactive: "the message has buttons or menus, or came from a workflow",
9922
+ "place-not-covered": "this standing approval does not cover this place",
9923
+ "big-room": "this place has 50 or more members, and was not approved with that count",
9924
+ "count-unreadable": "how many people are in this place could not be read",
9925
+ external: "someone from another organisation is in this place",
9926
+ "shared-state-changed": "who shares this place changed since it was approved",
9927
+ "reaction-as-person": "a standing reaction goes only as the agent app, and here it would go as you",
9928
+ "app-not-member": "the agent app is not a member of this place, or whether it is could not be read",
9929
+ "content-not-held": "this standing approval holds no such content",
9930
+ "clock-behind": "the clock is earlier than this account's last standing act",
9931
+ unreadable: "the message or its thread could not be read"
9932
+ });
9933
+ Object.freeze({
9934
+ perThread: "per thread",
9935
+ perHour: "an hour",
9936
+ perDay: "a day",
9937
+ perSender: "a day on messages from one person"
9938
+ });
9939
+ /**
9244
9940
  * The digest a download's question is bound to: the account, the download, the request, the files it listed and the
9245
9941
  * names it showed them under.
9246
9942
  *
@@ -9387,6 +10083,19 @@ function refuseDisclosure(code, reason, record, hint) {
9387
10083
  } : {}
9388
10084
  });
9389
10085
  }
10086
+ /**
10087
+ * The same refusal for a standing approval, which sends only through its children: nothing was sent under it. Its hint
10088
+ * never names a command that makes or widens one — only the person does that, at their terminal.
10089
+ */
10090
+ function refuseStanding(code, reason, record, hint) {
10091
+ return new CommsError(code, `nothing was sent: ${reason}`, {
10092
+ hint: hint ?? "This standing approval cannot be used. Ask the person instead; only they can set up another, at their own terminal.",
10093
+ details: record ? {
10094
+ approvalId: record.approvalId,
10095
+ state: record.state
10096
+ } : {}
10097
+ });
10098
+ }
9390
10099
  /** The refusal in the words of the record's own kind. */
9391
10100
  function refusalFor(record) {
9392
10101
  switch (approvalKind(record)) {
@@ -9394,6 +10103,7 @@ function refusalFor(record) {
9394
10103
  case "change": return refuseChange;
9395
10104
  case "download": return refuseDownload;
9396
10105
  case "disclosure": return refuseDisclosure;
10106
+ case "standing": return refuseStanding;
9397
10107
  }
9398
10108
  }
9399
10109
  /**
@@ -9444,6 +10154,14 @@ function cancelledClaim(record) {
9444
10154
  reason: "cancelled"
9445
10155
  }
9446
10156
  });
10157
+ case "standing": return new CommsError("USAGE", "cancelled: nothing was sent", {
10158
+ hint: "The standing approval was not used: it is as it was.",
10159
+ details: {
10160
+ approvalId: record.approvalId,
10161
+ state: record.state,
10162
+ reason: "cancelled"
10163
+ }
10164
+ });
9447
10165
  }
9448
10166
  }
9449
10167
  /** The refusal of a renewal or an outcome from a call that does not hold the send's claim. It names no token. */
@@ -9563,7 +10281,11 @@ var ApprovalStore = class {
9563
10281
  reason: CLOCK_ANOMALY,
9564
10282
  expiredAt: observed.toISOString()
9565
10283
  };
9566
- const deadline = record.state === "approved" && record.kind !== "download" && record.usableUntil !== void 0 ? record.usableUntil : record.expiresAt;
10284
+ let deadline = record.state === "approved" && record.kind !== "download" && record.usableUntil !== void 0 ? record.usableUntil : record.expiresAt;
10285
+ if (record.kind === "standing" && record.state === "approved" && record.approvedAt !== void 0 && record.standing) {
10286
+ const narrowed = Date.parse(record.approvedAt) + standingDays(record.standing, record.narrowed) * STANDING_DAY_MS;
10287
+ if (narrowed < Date.parse(deadline)) deadline = new Date(narrowed).toISOString();
10288
+ }
9567
10289
  if (now >= Date.parse(deadline)) return {
9568
10290
  ...record,
9569
10291
  state: "expired",
@@ -9731,6 +10453,7 @@ var ApprovalStore = class {
9731
10453
  const objectOf = (record) => approvalObjectOf(record, live, this.#now());
9732
10454
  let next;
9733
10455
  try {
10456
+ await options.before?.(current, outcome);
9734
10457
  next = decide(current, outcome, live);
9735
10458
  } catch (error) {
9736
10459
  throw withApproval(error, objectOf(current));
@@ -9771,7 +10494,7 @@ var ApprovalStore = class {
9771
10494
  usedSaid: options.usedSaid
9772
10495
  });
9773
10496
  if (found.form === "v2" && derived.form === "v2") {
9774
- const current = outcome.revokes && action !== "inspect" && action !== "wait" && outcome.record !== null && outcome.record !== null ? outcome.record : derived.record;
10497
+ const current = outcome.revokes && outcome.record !== null && (action !== "inspect" && action !== "wait" || writtenAtALook(outcome)) && outcome.record !== null ? outcome.record : derived.record;
9775
10498
  if (current.state !== found.record.state) await this.#write({
9776
10499
  ...current,
9777
10500
  updatedAt: this.#now().toISOString()
@@ -9855,6 +10578,10 @@ var ApprovalStore = class {
9855
10578
  hint: "Nothing more is needed from the person: complete the same activation with the same approval.",
9856
10579
  details: detail
9857
10580
  });
10581
+ case "standing": return new CommsError("USAGE", "nothing was sent: it is approved already, and a person approves it once", {
10582
+ hint: "Nothing more is needed from the person: the standing approval is in force until it ends.",
10583
+ details: detail
10584
+ });
9858
10585
  }
9859
10586
  }
9860
10587
  /** Issues a new challenge to show a human; only its hash is kept. */
@@ -10015,7 +10742,7 @@ var ApprovalStore = class {
10015
10742
  async approve(approvalId, via, live, answer, kind = "send", _platform = process.platform, options = {}) {
10016
10743
  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");
10017
10744
  let failure = null;
10018
- const done = await this.#transition(approvalId, { kind }, "approve", (current, outcome) => {
10745
+ const done = await this.#transition(approvalId, { kind }, "approve", (current, outcome, gate) => {
10019
10746
  if (outcome.error) throw outcome.error;
10020
10747
  if (current.state !== "pending") throw this.#alreadyApproved(current, outcome);
10021
10748
  if (!current.challengeHash) throw refusalFor(current)("APPROVAL_REQUIRED", "no challenge was issued for this approval", current);
@@ -10032,10 +10759,22 @@ var ApprovalStore = class {
10032
10759
  revokedAt: at.toISOString()
10033
10760
  };
10034
10761
  }
10762
+ if (kind === "standing" && via !== "terminal") {
10763
+ failure = {
10764
+ code: "APPROVAL_VOID",
10765
+ reason: "a standing approval is approved only with its code at a terminal, and this was not approved at a terminal"
10766
+ };
10767
+ return {
10768
+ ...current,
10769
+ state: "revoked",
10770
+ reason: failure.reason,
10771
+ revokedAt: at.toISOString()
10772
+ };
10773
+ }
10035
10774
  if (live.draftMessageId !== current.draftMessageId || live.contentDigest !== current.contentDigest) {
10036
10775
  failure = {
10037
10776
  code: "APPROVAL_VOID",
10038
- reason: kind === "change" ? "the change shown is not the one the approval was prepared for" : "the draft changed after the preview was prepared"
10777
+ 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"
10039
10778
  };
10040
10779
  return {
10041
10780
  ...current,
@@ -10075,7 +10814,8 @@ var ApprovalStore = class {
10075
10814
  approvedAt: at.toISOString(),
10076
10815
  usableUntil: new Date(at.getTime() + (current.approvedMs ?? APPROVAL_LIFETIMES.approved)).toISOString(),
10077
10816
  approvedBindingDigest: current.bindingDigest,
10078
- challengeHash: void 0
10817
+ challengeHash: void 0,
10818
+ ...kind === "standing" ? { approvedChangePolicy: gate?.changePolicy ?? "chat" } : {}
10079
10819
  };
10080
10820
  }, { usedSaid: options.usedSaid });
10081
10821
  const failed = failure;
@@ -10094,6 +10834,7 @@ var ApprovalStore = class {
10094
10834
  */
10095
10835
  async claimForSend(approvalId, live, options = {}) {
10096
10836
  let failure = null;
10837
+ let parentProblem = null;
10097
10838
  const done = await this.#transition(approvalId, {
10098
10839
  kind: "send",
10099
10840
  owner: live.inboxId
@@ -10114,6 +10855,7 @@ var ApprovalStore = class {
10114
10855
  challengeHash: void 0
10115
10856
  };
10116
10857
  };
10858
+ if (parentProblem !== null) return voidWith("APPROVAL_VOID", parentProblem);
10117
10859
  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");
10118
10860
  if (live.draftMessageId !== current.draftMessageId) return voidWith("APPROVAL_VOID", "the draft was edited after the preview");
10119
10861
  if (live.contentDigest !== current.contentDigest) return voidWith("APPROVAL_VOID", "the draft content changed after the preview");
@@ -10125,7 +10867,12 @@ var ApprovalStore = class {
10125
10867
  state: "sending",
10126
10868
  sendingAt: at
10127
10869
  };
10128
- }, { usedSaid: options.usedSaid });
10870
+ }, {
10871
+ usedSaid: options.usedSaid,
10872
+ before: async (current, outcome) => {
10873
+ if (outcome.error === void 0 && current.route === "standing" && current.state === "approved") parentProblem = await this.#standingParentProblem(current);
10874
+ }
10875
+ });
10129
10876
  const failed = failure;
10130
10877
  if (failed) throw refusedAfter(refuse$1(failed.code, failed.reason, done.record), done);
10131
10878
  const claimToken = randomBytes(16).toString("hex");
@@ -10138,6 +10885,31 @@ var ApprovalStore = class {
10138
10885
  };
10139
10886
  }
10140
10887
  /**
10888
+ * Why a standing child may not be claimed now, or null (design 2026-10-19-standing-approvals, S9, the lead's call after
10889
+ * review): its standing approval read again, under that one's own lock — ended by any of the store's rules, an epoch
10890
+ * or a tightened change policy among them, or narrowed so that it no longer holds the child's content or covers its
10891
+ * place. Called with the child's lock held: no lock is ever taken the other way round.
10892
+ */
10893
+ async #standingParentProblem(child) {
10894
+ const use = child.standingUse;
10895
+ if (child.standingId === void 0 || use === void 0) return "it names no standing approval it is a use of";
10896
+ let seen;
10897
+ try {
10898
+ seen = await this.inspect(child.standingId, {
10899
+ kind: "standing",
10900
+ owner: child.inboxId
10901
+ }, { action: "claim" });
10902
+ } catch {
10903
+ return "its standing approval could not be read";
10904
+ }
10905
+ const parent = seen.outcome.record;
10906
+ 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})`}`;
10907
+ const inForce = effectiveStanding(parent.standing, parent.narrowed);
10908
+ if (!inForce.contents.some((content) => content.digest === use.content)) return "its standing approval no longer holds what it sends";
10909
+ if (inForce.places.kind === "exact" && !inForce.places.places.some((place) => place.id === use.channel)) return "its standing approval no longer covers where it goes";
10910
+ return null;
10911
+ }
10912
+ /**
10141
10913
  * The file system's O_EXCL is the single-use guarantee, independent of the lock. A send's marker also holds its
10142
10914
  * claim token, the one thing that lets the claimant — and nobody else — renew its lease and record its outcome.
10143
10915
  */
@@ -10299,6 +11071,208 @@ var ApprovalStore = class {
10299
11071
  return done.record;
10300
11072
  }
10301
11073
  /**
11074
+ * A standing approval (design 2026-10-19-standing-approvals, S8), pending, bound to `standingDigest` of its binding —
11075
+ * computed here, never taken from the caller — and to the send epoch of its account and the standing epoch, both as
11076
+ * read when it is made.
11077
+ *
11078
+ * Its route is `confirm` and its `requiredPolicy` `confirm` whatever the change policy is, because what it permits
11079
+ * happens with nobody asked: it is approved only with the code typed at a terminal (`approve`, `via: 'terminal'`),
11080
+ * never by a yes in the chat, a client's form, or an answer carried in a call. It lasts its days once approved
11081
+ * (`approvedMs`). Nothing ever claims it: each use mints a child (`createStandingChild`).
11082
+ *
11083
+ * The binding is checked before anything is written (`canonicalStandingBinding`): a custom emoji, a reply with a
11084
+ * link, a limit outside the table or a place that is no Slack id is refused, however the caller assembled it.
11085
+ */
11086
+ async createStanding(input) {
11087
+ const standing = canonicalStandingBinding(input.binding);
11088
+ 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." });
11089
+ requireEpochs(input.sendEpoch, input.standingEpoch);
11090
+ requireKnownChannel("slack");
11091
+ await this.ensurePruned();
11092
+ const now = this.#now();
11093
+ const digest = standingDigest(standing);
11094
+ const pendingMs = APPROVAL_LIFETIMES.confirm;
11095
+ const record = this.#bound({
11096
+ approvalId: newApprovalId(),
11097
+ kind: "standing",
11098
+ digestVersion: 2,
11099
+ channel: "slack",
11100
+ ownerScope: "owner",
11101
+ route: "confirm",
11102
+ pendingMs,
11103
+ approvedMs: standing.limits.expiresDays * STANDING_DAY_MS,
11104
+ inboxId: standing.target.id,
11105
+ inboxSub: input.inboxSub,
11106
+ draftId: "standing",
11107
+ draftMessageId: digest,
11108
+ contentDigest: digest,
11109
+ sendEpoch: input.sendEpoch,
11110
+ standingEpoch: input.standingEpoch,
11111
+ policy: "confirm",
11112
+ requiredPolicy: "confirm",
11113
+ riskFlags: [],
11114
+ expect: {
11115
+ to: [],
11116
+ cc: [],
11117
+ bcc: [],
11118
+ subject: standing.summary
11119
+ },
11120
+ challengeAttempts: 0,
11121
+ state: "pending",
11122
+ createdAt: now.toISOString(),
11123
+ expiresAt: new Date(now.getTime() + pendingMs).toISOString(),
11124
+ updatedAt: now.toISOString(),
11125
+ standing
11126
+ });
11127
+ await this.#write(record);
11128
+ return record;
11129
+ }
11130
+ /**
11131
+ * Runs `use` under the standing record's own lock, with the record as it reads there and its classification for a
11132
+ * use (`claim`): its expiry, a revocation the configuration derives — its account removed, `never`, a send epoch or
11133
+ * a standing epoch behind the live one — written before `use` runs, and `outcome.error` set for every state that
11134
+ * refuses a use. `owner` is the account the caller acts for: any other's record is the one `NOT_FOUND`.
11135
+ *
11136
+ * What `use` decides is decided while no revocation, narrowing or approval of this record can land. `use` must not
11137
+ * take this record's lock again: it is held.
11138
+ */
11139
+ async withStanding(standingId, owner, use) {
11140
+ const path = this.#path(standingId);
11141
+ const expect = {
11142
+ kind: "standing",
11143
+ owner
11144
+ };
11145
+ return withFileLock(`${path}.lock`, async () => {
11146
+ const config = await this.#config();
11147
+ const found = await this.#read(standingId, config);
11148
+ if (found === null || !matchesExpectation(found, expect)) throw approvalNotFound(standingId, "standing");
11149
+ const derived = this.#derived(found);
11150
+ const outcome = approvalOutcome(derived, {
11151
+ action: "claim",
11152
+ live: config === null || derived.form !== "v2" ? null : liveGateOf(config, derived.record),
11153
+ now: this.#now()
11154
+ });
11155
+ if (found.form !== "v2" || derived.form !== "v2" || outcome.record === null) throw outcome.error ?? integrityRefusal(found);
11156
+ const current = outcome.revokes ? outcome.record : derived.record;
11157
+ if (current.state !== found.record.state) await this.#write({
11158
+ ...current,
11159
+ updatedAt: this.#now().toISOString()
11160
+ });
11161
+ return use(current, outcome);
11162
+ });
11163
+ }
11164
+ /**
11165
+ * One use of a standing approval (S9): a send record, approved the moment it is made — `route: 'standing'`,
11166
+ * `approvedVia: 'standing'`, `approvedAt` its creation — bound to the standing id, the standing epoch and the send
11167
+ * epoch, and usable once, within `APPROVAL_LIFETIMES.standingChild`. It then takes the one send path every send
11168
+ * takes: `claimForSend`, its O_EXCL marker, the fence and the outcome.
11169
+ *
11170
+ * Minted only by the use (`mintStandingUse`), under the account's acknowledgement ledger lock and then the standing
11171
+ * record's, after every check there. Checked again here, read without that lock — which the caller holds: the
11172
+ * standing record must be this account's, approved at a terminal, under the same epochs and the same account user,
11173
+ * and not ended, or nothing is written. A child never names anything but what its use was checked for.
11174
+ */
11175
+ async createStandingChild(input) {
11176
+ 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." });
11177
+ 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." });
11178
+ requireEpochs(input.sendEpoch, input.standingEpoch);
11179
+ requireKnownChannel("slack");
11180
+ const config = await this.#config();
11181
+ if (input.approvalId !== void 0) {
11182
+ 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." });
11183
+ }
11184
+ const found = await this.#read(input.standingId, config);
11185
+ if (found === null || !matchesExpectation(found, {
11186
+ kind: "standing",
11187
+ owner: input.inboxId
11188
+ })) throw approvalNotFound(input.standingId, "standing");
11189
+ if (found.form !== "v2") throw integrityRefusal(found);
11190
+ const parent = this.#derive(found.record);
11191
+ const live = config === null ? null : liveGateOf(config, parent);
11192
+ const outcome = approvalOutcome({
11193
+ form: "v2",
11194
+ record: parent
11195
+ }, {
11196
+ action: "claim",
11197
+ live,
11198
+ now: this.#now()
11199
+ });
11200
+ if (outcome.error) throw outcome.error;
11201
+ const standing = outcome.record ?? parent;
11202
+ if (standing.state === "pending") throw refuseStanding("APPROVAL_PENDING", "the standing approval waits for the person to approve it at a terminal", standing);
11203
+ 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;
11204
+ if (unbound !== null) throw refuseStanding("APPROVAL_VOID", unbound, standing);
11205
+ const now = this.#now();
11206
+ const at = now.toISOString();
11207
+ const lifetime = APPROVAL_LIFETIMES.standingChild;
11208
+ const until = new Date(now.getTime() + lifetime).toISOString();
11209
+ const sealed = this.#bound({
11210
+ approvalId: input.approvalId ?? newApprovalId(),
11211
+ kind: "send",
11212
+ digestVersion: 2,
11213
+ channel: "slack",
11214
+ ownerScope: "owner",
11215
+ route: "standing",
11216
+ pendingMs: lifetime,
11217
+ approvedMs: lifetime,
11218
+ inboxId: input.inboxId,
11219
+ inboxSub: input.inboxSub,
11220
+ draftId: input.draftId,
11221
+ draftMessageId: input.draftMessageId,
11222
+ contentDigest: input.contentDigest,
11223
+ sendEpoch: input.sendEpoch,
11224
+ standingEpoch: input.standingEpoch,
11225
+ standingId: input.standingId,
11226
+ standingUse: {
11227
+ channel: input.use.channel,
11228
+ thread: input.use.thread,
11229
+ content: input.use.content
11230
+ },
11231
+ policy: live?.sendPolicy === "chat" ? "chat" : "confirm",
11232
+ requiredPolicy: "confirm",
11233
+ riskFlags: [...input.riskFlags],
11234
+ expect: {
11235
+ to: [...input.expect.to],
11236
+ cc: [...input.expect.cc],
11237
+ bcc: [...input.expect.bcc],
11238
+ subject: input.expect.subject
11239
+ },
11240
+ challengeAttempts: 0,
11241
+ state: "approved",
11242
+ createdAt: at,
11243
+ expiresAt: until,
11244
+ updatedAt: at,
11245
+ approvedVia: "standing",
11246
+ approvedAt: at,
11247
+ usableUntil: until
11248
+ });
11249
+ const record = {
11250
+ ...sealed,
11251
+ approvedBindingDigest: sealed.bindingDigest
11252
+ };
11253
+ await this.#write(record);
11254
+ return record;
11255
+ }
11256
+ /**
11257
+ * Narrows a standing approval at once (S8): fewer contents or places, a lower limit, an earlier end. Nothing is asked
11258
+ * for, because nothing more is permitted: anything `classifyStandingChange` calls a widening of what is in force is
11259
+ * refused with USAGE, and the record is left as it was. The binding the person approved stays the record's own, and
11260
+ * what is narrowed is kept beside it, where it can only take away.
11261
+ */
11262
+ async narrowStanding(standingId, owner, narrowing) {
11263
+ return (await this.#transition(standingId, {
11264
+ kind: "standing",
11265
+ owner
11266
+ }, "revoke", (current, outcome) => {
11267
+ if (outcome.error) throw outcome.error;
11268
+ 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);
11269
+ return {
11270
+ ...current,
11271
+ narrowed: narrowedFurther(current.standing, current.narrowed, narrowing)
11272
+ };
11273
+ })).record;
11274
+ }
11275
+ /**
10302
11276
  * A download's question, pending, bound to `downloadDigest(input.download)` — computed here, never taken from the
10303
11277
  * caller, as a change's digest is. It stands in for the draft revision too.
10304
11278
  *
@@ -10583,13 +11557,28 @@ var ApprovalStore = class {
10583
11557
  ...derived.record,
10584
11558
  updatedAt: this.#now().toISOString()
10585
11559
  });
11560
+ const outcome = approvalOutcome(derived, {
11561
+ action: "inspect",
11562
+ live: config === null || derived.form !== "v2" ? null : liveGateOf(config, derived.record),
11563
+ now: this.#now()
11564
+ });
11565
+ if (writtenAtALook(outcome) && outcome.record !== null) {
11566
+ const ended = {
11567
+ ...outcome.record,
11568
+ updatedAt: this.#now().toISOString()
11569
+ };
11570
+ await this.#write(ended);
11571
+ return {
11572
+ stored: {
11573
+ form: "v2",
11574
+ record: ended
11575
+ },
11576
+ outcome
11577
+ };
11578
+ }
10586
11579
  return {
10587
11580
  stored: derived,
10588
- outcome: approvalOutcome(derived, {
10589
- action: "inspect",
10590
- live: config === null || derived.form !== "v2" ? null : liveGateOf(config, derived.record),
10591
- now: this.#now()
10592
- })
11581
+ outcome
10593
11582
  };
10594
11583
  });
10595
11584
  if (seen === null) continue;
@@ -10628,6 +11617,10 @@ var ApprovalStore = class {
10628
11617
  return found.sort(byCreation);
10629
11618
  }
10630
11619
  };
11620
+ /** Both epochs a standing record or child binds: whole numbers of 0 or more, or a programming error. */
11621
+ function requireEpochs(sendEpoch, standingEpoch) {
11622
+ 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." });
11623
+ }
10631
11624
  /** Oldest first; a record that cannot be read has no time to be ordered by, and follows, by id. */
10632
11625
  function byCreation(a, b) {
10633
11626
  const createdOf = (stored) => stored.form === "v2" ? stored.record.createdAt : stored.form === "legacy" ? stored.view.createdAt : null;
@@ -10665,7 +11658,7 @@ function sameExpectation(a, b) {
10665
11658
  const CLOCK_ANOMALY = "clock-anomaly";
10666
11659
  const HEX64 = /^[0-9a-f]{64}$/;
10667
11660
  /** Exactly what `Date.prototype.toISOString` writes, which is all the store ever writes. */
10668
- const ISO$1 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
11661
+ const ISO$2 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
10669
11662
  const STATES$1 = [
10670
11663
  "pending",
10671
11664
  "approved",
@@ -10676,7 +11669,10 @@ const STATES$1 = [
10676
11669
  "expired",
10677
11670
  "revoked"
10678
11671
  ];
10679
- /** The states each kind can reach. A change or a download is claimed straight to `used`: it is never sent. */
11672
+ /**
11673
+ * The states each kind can reach. A change or a download is claimed straight to `used`: it is never sent. A standing
11674
+ * approval is never claimed at all — its children are — so it ends expired or revoked.
11675
+ */
10680
11676
  const STATES_OF = {
10681
11677
  send: STATES$1,
10682
11678
  change: [
@@ -10692,8 +11688,24 @@ const STATES_OF = {
10692
11688
  "used",
10693
11689
  "expired",
10694
11690
  "revoked"
11691
+ ],
11692
+ standing: [
11693
+ "pending",
11694
+ "approved",
11695
+ "expired",
11696
+ "revoked"
10695
11697
  ]
10696
11698
  };
11699
+ /** A standing approval's child, approved when it is made: never pending. */
11700
+ const CHILD_STATES = [
11701
+ "approved",
11702
+ "sending",
11703
+ "used",
11704
+ "failed",
11705
+ "unknown",
11706
+ "expired",
11707
+ "revoked"
11708
+ ];
10697
11709
  /** The send states that went through a claim, and so carry `sendingAt`. */
10698
11710
  const CLAIMED_SEND = [
10699
11711
  "sending",
@@ -10701,10 +11713,14 @@ const CLAIMED_SEND = [
10701
11713
  "failed",
10702
11714
  "unknown"
10703
11715
  ];
11716
+ /** Whether a record is one use of a standing approval: a send on the `standing` route. */
11717
+ function isChild(raw) {
11718
+ return raw.kind === "send" && raw.route === "standing";
11719
+ }
10704
11720
  /** A record's timestamp field: absent, invalid, or the time it names. */
10705
11721
  function timeOf(value) {
10706
11722
  if (value === void 0) return null;
10707
- if (typeof value !== "string" || !ISO$1.test(value)) return "invalid";
11723
+ if (typeof value !== "string" || !ISO$2.test(value)) return "invalid";
10708
11724
  const at = Date.parse(value);
10709
11725
  return Number.isFinite(at) && new Date(at).toISOString() === value ? at : "invalid";
10710
11726
  }
@@ -10751,7 +11767,7 @@ function checkVerified(record, raw) {
10751
11767
  if (raw.digestVersion !== 2) return fail("digest-version");
10752
11768
  const kind = raw.kind;
10753
11769
  if (kind === "disclosure") return checkDisclosure(record, raw);
10754
- if (kind !== "send" && kind !== "change" && kind !== "download") return fail("wrong-shape");
11770
+ if (kind !== "send" && kind !== "change" && kind !== "download" && kind !== "standing") return fail("wrong-shape");
10755
11771
  if (!STATES$1.includes(raw.state)) return fail("state-impossible");
10756
11772
  const state = raw.state;
10757
11773
  if (typeof raw.contentDigest !== "string" || !HEX64.test(raw.contentDigest)) return fail("content-digest-malformed");
@@ -10761,10 +11777,16 @@ function checkVerified(record, raw) {
10761
11777
  if (kind === "download") {
10762
11778
  if (record.download === void 0 || downloadDigest(record.download) !== record.contentDigest) return fail("content-digest-mismatch");
10763
11779
  }
11780
+ if (kind === "standing") {
11781
+ if (record.standing === void 0 || standingBindingProblem(record.standing) !== null) return fail("wrong-shape");
11782
+ if (standingDigest(record.standing) !== record.contentDigest) return fail("content-digest-mismatch");
11783
+ if (standingNarrowingProblem(record.standing, raw.narrowed) !== null) return fail("narrowing-invalid");
11784
+ } else if (raw.standing !== void 0 || raw.narrowed !== void 0) return fail("wrong-shape");
10764
11785
  const identity = checkIdentity(record, raw, kind);
10765
11786
  if (identity !== null) return identity;
10766
11787
  if (!STATES_OF[kind].includes(state)) return fail("state-impossible");
10767
- const lifetime = checkLifetime(raw, kind);
11788
+ if (isChild(raw) && !CHILD_STATES.includes(state)) return fail("state-impossible");
11789
+ const lifetime = checkLifetime(record, raw, kind);
10768
11790
  if (lifetime !== null) return lifetime;
10769
11791
  const times = {};
10770
11792
  for (const field of TIMESTAMPS) {
@@ -10839,9 +11861,19 @@ function checkIdentity(record, raw, kind) {
10839
11861
  if (scope !== expected) return fail("identity-contradiction");
10840
11862
  if (raw.inboxId !== (expected === "owner" ? target?.id : "")) return fail("identity-contradiction");
10841
11863
  }
10842
- if (kind === "send") {
10843
- if (typeof raw.sendEpoch !== "number" || !Number.isInteger(raw.sendEpoch) || raw.sendEpoch < 0) return fail("identity-contradiction");
11864
+ const wholeEpoch = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
11865
+ if (kind === "send" || kind === "standing") {
11866
+ if (!wholeEpoch(raw.sendEpoch)) return fail("identity-contradiction");
10844
11867
  } else if (raw.sendEpoch !== void 0) return fail("identity-contradiction");
11868
+ if (kind === "standing" || isChild(raw)) {
11869
+ if (raw.channel !== "slack" || !wholeEpoch(raw.standingEpoch)) return fail("identity-contradiction");
11870
+ } else if (raw.standingEpoch !== void 0) return fail("identity-contradiction");
11871
+ if (isChild(raw)) {
11872
+ if (typeof raw.standingId !== "string" || !APPROVAL_ID_PATTERN.test(raw.standingId)) return fail("identity-contradiction");
11873
+ if (raw.standingId === raw.approvalId) return fail("identity-contradiction");
11874
+ if (standingUseProblem(raw.standingUse) !== null) return fail("identity-contradiction");
11875
+ } else if (raw.standingId !== void 0 || raw.standingUse !== void 0) return fail("identity-contradiction");
11876
+ if (kind === "standing" && record.standing.target.id !== raw.inboxId) return fail("identity-contradiction");
10845
11877
  if (kind === "download") {
10846
11878
  const download = record.download;
10847
11879
  if (download === void 0 || download.target.id !== raw.inboxId) return fail("identity-contradiction");
@@ -10851,13 +11883,31 @@ function checkIdentity(record, raw, kind) {
10851
11883
  }
10852
11884
  return null;
10853
11885
  }
10854
- /** The lifetime profile: what the route says, and nothing the record could have chosen for itself. */
10855
- function checkLifetime(raw, kind) {
11886
+ /**
11887
+ * The lifetime profile: what the route says, and nothing the record could have chosen for itself. A standing approval
11888
+ * waits as a `confirm` change does and lasts the whole days its limits name, from 1 to 30; its child lives
11889
+ * `standingChild`, pending and approved alike.
11890
+ */
11891
+ function checkLifetime(record, raw, kind) {
10856
11892
  if (kind === "download") {
10857
11893
  if (raw.pendingMs !== APPROVAL_LIFETIMES.download) return fail("lifetime-mismatch");
10858
11894
  if (raw.route !== void 0 || raw.approvedMs !== void 0) return fail("lifetime-mismatch");
10859
11895
  return null;
10860
11896
  }
11897
+ if (kind === "standing") {
11898
+ const days = record.standing.limits.expiresDays;
11899
+ const { min, max } = STANDING_LIMITS.expiresDays;
11900
+ if (raw.route !== "confirm" || raw.pendingMs !== APPROVAL_LIFETIMES.confirm) return fail("lifetime-mismatch");
11901
+ if (!Number.isSafeInteger(days) || days < min || days > max) return fail("lifetime-mismatch");
11902
+ if (raw.approvedMs !== days * 864e5) return fail("lifetime-mismatch");
11903
+ if (raw.policy !== "confirm" || raw.requiredPolicy !== "confirm") return fail("lifetime-mismatch");
11904
+ return null;
11905
+ }
11906
+ if (isChild(raw)) {
11907
+ const lifetime = APPROVAL_LIFETIMES.standingChild;
11908
+ if (raw.pendingMs !== lifetime || raw.approvedMs !== lifetime) return fail("lifetime-mismatch");
11909
+ return null;
11910
+ }
10861
11911
  if (raw.route !== "chat" && raw.route !== "confirm") return fail("lifetime-mismatch");
10862
11912
  if (raw.pendingMs !== pendingMsOf(raw.route)) return fail("lifetime-mismatch");
10863
11913
  if (raw.approvedMs !== APPROVAL_LIFETIMES.approved) return fail("lifetime-mismatch");
@@ -10907,6 +11957,10 @@ function checkTimestamps(raw, kind, state, times) {
10907
11957
  if (!(createdAt <= approvedAt && approvedAt < expiresAt)) return fail("timestamp-misordered");
10908
11958
  if (usableUntil - approvedAt !== raw.approvedMs) return fail("lifetime-mismatch");
10909
11959
  }
11960
+ if (isChild(raw)) {
11961
+ if (approvedAt === void 0) return fail("timestamp-missing");
11962
+ if (approvedAt !== createdAt) return fail("timestamp-misordered");
11963
+ }
10910
11964
  const deadline = usableUntil ?? expiresAt;
10911
11965
  const since = approvedAt ?? createdAt;
10912
11966
  const { sendingAt, sendingHeartbeatAt, usedAt, sentAt, failedAt, revokedAt, expiredAt } = times;
@@ -10934,7 +11988,9 @@ function checkTimestamps(raw, kind, state, times) {
10934
11988
  */
10935
11989
  function checkEvidence(record, raw, kind, state) {
10936
11990
  const via = raw.approvedVia;
10937
- if (via !== void 0 && via !== "terminal" && via !== "elicitation") return fail("evidence-invalid");
11991
+ if (via !== void 0 && via !== "terminal" && via !== "elicitation" && via !== "standing") return fail("evidence-invalid");
11992
+ if (via === "standing" !== (isChild(raw) && via !== void 0)) return fail("evidence-invalid");
11993
+ if (raw.approvedChangePolicy !== void 0 && (kind !== "standing" || raw.approvedAt === void 0)) return fail("evidence-contradictory");
10938
11994
  if (kind === "download") {
10939
11995
  const answer = record.download?.answer;
10940
11996
  const answered = via !== void 0 || raw.approvedDigest !== void 0 || answer !== void 0;
@@ -10952,7 +12008,9 @@ function checkEvidence(record, raw, kind, state) {
10952
12008
  if (raw.approvedDigest !== void 0) return fail("evidence-contradictory");
10953
12009
  if (!(raw.approvedAt !== void 0)) return via !== void 0 || raw.approvedBindingDigest !== void 0 ? fail("evidence-contradictory") : null;
10954
12010
  if (via === void 0 || raw.approvedBindingDigest === void 0) return fail("evidence-missing");
10955
- if (kind === "change" && via !== "terminal") return fail("evidence-invalid");
12011
+ if (kind === "standing" && raw.approvedChangePolicy !== "chat" && raw.approvedChangePolicy !== "confirm") return fail("evidence-missing");
12012
+ if ((kind === "change" || kind === "standing") && via !== "terminal") return fail("evidence-invalid");
12013
+ if (isChild(raw) && via !== "standing") return fail("evidence-invalid");
10956
12014
  if (raw.approvedBindingDigest !== record.bindingDigest) return fail("evidence-contradictory");
10957
12015
  return null;
10958
12016
  }
@@ -10972,9 +12030,11 @@ const KINDS = [
10972
12030
  "download",
10973
12031
  "disclosure"
10974
12032
  ];
12033
+ /** Version 2 alone has standing approvals (design 2026-10-19-standing-approvals): no earlier release wrote one. */
12034
+ const V2_KINDS = [...KINDS, "standing"];
10975
12035
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10976
- const isString = (value) => typeof value === "string";
10977
- const isStrings = (value) => Array.isArray(value) && value.every(isString);
12036
+ const isString$1 = (value) => typeof value === "string";
12037
+ const isStrings = (value) => Array.isArray(value) && value.every(isString$1);
10978
12038
  const optional = (value, check) => value === void 0 || check(value);
10979
12039
  function unreadable(approvalId, reason) {
10980
12040
  return {
@@ -10987,22 +12047,22 @@ function unreadable(approvalId, reason) {
10987
12047
  };
10988
12048
  }
10989
12049
  function expectationShaped(value) {
10990
- return isObject(value) && isStrings(value.to) && isStrings(value.cc) && isStrings(value.bcc) && isString(value.subject);
12050
+ return isObject(value) && isStrings(value.to) && isStrings(value.cc) && isStrings(value.bcc) && isString$1(value.subject);
10991
12051
  }
10992
12052
  /**
10993
12053
  * The types every record shares, version 1 or 2: anything of the wrong type cannot reach the classifier. Values —
10994
12054
  * a timestamp that is a string but not a time, a digest that is a string but not hex — are the validator's.
10995
12055
  */
10996
12056
  function commonShape(raw) {
10997
- if (!isString(raw.inboxId)) return "missing-ownership";
10998
- if (!isString(raw.state) || !STATES.includes(raw.state)) return "wrong-shape";
12057
+ if (!isString$1(raw.inboxId)) return "missing-ownership";
12058
+ if (!isString$1(raw.state) || !STATES.includes(raw.state)) return "wrong-shape";
10999
12059
  for (const field of [
11000
12060
  "createdAt",
11001
12061
  "expiresAt",
11002
12062
  "updatedAt",
11003
12063
  "draftId",
11004
12064
  "draftMessageId"
11005
- ]) if (!isString(raw[field])) return "wrong-shape";
12065
+ ]) if (!isString$1(raw[field])) return "wrong-shape";
11006
12066
  if (!expectationShaped(raw.expect)) return "wrong-shape";
11007
12067
  for (const field of [
11008
12068
  "inboxSub",
@@ -11013,17 +12073,23 @@ function commonShape(raw) {
11013
12073
  "reason",
11014
12074
  "policy",
11015
12075
  "requiredPolicy"
11016
- ]) if (!optional(raw[field], isString)) return "wrong-shape";
12076
+ ]) if (!optional(raw[field], isString$1)) return "wrong-shape";
11017
12077
  if (!optional(raw.riskFlags, isStrings)) return "wrong-shape";
11018
12078
  if (!optional(raw.challengeAttempts, (value) => typeof value === "number")) return "wrong-shape";
11019
12079
  const kind = raw.kind ?? "send";
11020
12080
  if (kind === "change") {
11021
12081
  const change = raw.change;
11022
- if (!isObject(change) || !isString(change.summary) || !(change.target === null || isObject(change.target)) || !Array.isArray(change.loosened) || !isStrings(change.effects)) return "wrong-shape";
12082
+ if (!isObject(change) || !isString$1(change.summary) || !(change.target === null || isObject(change.target)) || !Array.isArray(change.loosened) || !isStrings(change.effects)) return "wrong-shape";
12083
+ }
12084
+ if (kind === "standing") {
12085
+ const standing = raw.standing;
12086
+ 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";
12087
+ const narrowed = raw.narrowed;
12088
+ if (!optional(narrowed, (value) => isObject(value) && optional(value.keep, isStrings) && optional(value.places, isStrings) && optional(value.limits, isObject))) return "wrong-shape";
11023
12089
  }
11024
12090
  if (kind === "download") {
11025
12091
  const download = raw.download;
11026
- 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";
12092
+ 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";
11027
12093
  }
11028
12094
  return null;
11029
12095
  }
@@ -11036,7 +12102,7 @@ function disclosureShape(raw) {
11036
12102
  "expiresAt",
11037
12103
  "updatedAt",
11038
12104
  "bindingDigest"
11039
- ]) if (!isString(raw[field])) return "wrong-shape";
12105
+ ]) if (!isString$1(raw[field])) return "wrong-shape";
11040
12106
  for (const field of [
11041
12107
  "pendingMs",
11042
12108
  "approvedMs",
@@ -11052,9 +12118,9 @@ function disclosureShape(raw) {
11052
12118
  "revokedAt",
11053
12119
  "expiredAt",
11054
12120
  "reason"
11055
- ]) if (!optional(raw[field], isString)) return "wrong-shape";
12121
+ ]) if (!optional(raw[field], isString$1)) return "wrong-shape";
11056
12122
  const disclosure = raw.disclosure;
11057
- 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";
12123
+ 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";
11058
12124
  for (const field of [
11059
12125
  "channel",
11060
12126
  "ownerScope",
@@ -11069,6 +12135,8 @@ function disclosureShape(raw) {
11069
12135
  "requiredPolicy",
11070
12136
  "riskFlags",
11071
12137
  "approvedBindingDigest",
12138
+ "approvedChangePolicy",
12139
+ "standingUse",
11072
12140
  "sendingAt",
11073
12141
  "sendingHeartbeatAt",
11074
12142
  "sentAt",
@@ -11086,16 +12154,20 @@ function v2Shape(raw) {
11086
12154
  "channel",
11087
12155
  "ownerScope",
11088
12156
  "contentDigest"
11089
- ]) if (!optional(raw[field], isString)) return "wrong-shape";
12157
+ ]) if (!optional(raw[field], isString$1)) return "wrong-shape";
11090
12158
  for (const field of [
11091
12159
  "pendingMs",
11092
12160
  "approvedMs",
11093
- "sendEpoch"
12161
+ "sendEpoch",
12162
+ "standingEpoch"
11094
12163
  ]) if (!optional(raw[field], (value) => typeof value === "number")) return "wrong-shape";
12164
+ if (!optional(raw.standingId, isString$1)) return "wrong-shape";
12165
+ if (!optional(raw.standingUse, isObject)) return "wrong-shape";
11095
12166
  for (const field of [
11096
12167
  "route",
11097
12168
  "bindingDigest",
11098
12169
  "approvedBindingDigest",
12170
+ "approvedChangePolicy",
11099
12171
  "approvedAt",
11100
12172
  "usableUntil",
11101
12173
  "sendingAt",
@@ -11105,7 +12177,7 @@ function v2Shape(raw) {
11105
12177
  "failedAt",
11106
12178
  "revokedAt",
11107
12179
  "expiredAt"
11108
- ]) if (!optional(raw[field], isString)) return "wrong-shape";
12180
+ ]) if (!optional(raw[field], isString$1)) return "wrong-shape";
11109
12181
  return null;
11110
12182
  }
11111
12183
  /**
@@ -11121,7 +12193,7 @@ function decodeStored(fileId, text, config, now) {
11121
12193
  return unreadable(fileId, trimmed === "" || trimmed.startsWith("{") && !trimmed.endsWith("}") ? "truncated" : "invalid-json");
11122
12194
  }
11123
12195
  if (!isObject(raw)) return unreadable(fileId, "wrong-shape");
11124
- if (!isString(raw.approvalId)) return unreadable(fileId, "wrong-shape");
12196
+ if (!isString$1(raw.approvalId)) return unreadable(fileId, "wrong-shape");
11125
12197
  if (raw.kind === "disclosure") {
11126
12198
  if (raw.digestVersion === 1) return unreadable(fileId, "wrong-shape");
11127
12199
  const shape = disclosureShape(raw);
@@ -11143,8 +12215,8 @@ function decodeStored(fileId, text, config, now) {
11143
12215
  const shape = commonShape(raw);
11144
12216
  if (shape !== null) return unreadable(fileId, shape);
11145
12217
  if (raw.digestVersion === 1) {
11146
- if (!optional(raw.kind, (kind) => isString(kind) && KINDS.includes(kind))) return unreadable(fileId, "wrong-shape");
11147
- if (!isString(raw.digest)) return unreadable(fileId, "wrong-shape");
12218
+ if (!optional(raw.kind, (kind) => isString$1(kind) && KINDS.includes(kind))) return unreadable(fileId, "wrong-shape");
12219
+ if (!isString$1(raw.digest)) return unreadable(fileId, "wrong-shape");
11148
12220
  if (raw.approvalId !== fileId) return unreadable(fileId, "file-name-mismatch");
11149
12221
  const record = raw;
11150
12222
  return {
@@ -11153,7 +12225,7 @@ function decodeStored(fileId, text, config, now) {
11153
12225
  record
11154
12226
  };
11155
12227
  }
11156
- if (!isString(raw.kind) || !KINDS.includes(raw.kind)) return unreadable(fileId, "missing-kind");
12228
+ if (!isString$1(raw.kind) || !V2_KINDS.includes(raw.kind)) return unreadable(fileId, "missing-kind");
11157
12229
  const v2 = v2Shape(raw);
11158
12230
  if (v2 !== null) return unreadable(fileId, v2);
11159
12231
  const record = raw;
@@ -11277,9 +12349,9 @@ const NEVER = Object.freeze({
11277
12349
  cursor: null
11278
12350
  });
11279
12351
  /** Exactly what `Date.prototype.toISOString` writes. */
11280
- const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
12352
+ const ISO$1 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
11281
12353
  function isoTime(value) {
11282
- if (typeof value !== "string" || !ISO.test(value)) return null;
12354
+ if (typeof value !== "string" || !ISO$1.test(value)) return null;
11283
12355
  const at = Date.parse(value);
11284
12356
  return Number.isFinite(at) && new Date(at).toISOString() === value ? at : null;
11285
12357
  }
@@ -11904,7 +12976,7 @@ function folding(platform) {
11904
12976
  return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
11905
12977
  }
11906
12978
  /** The package version, read from package.json at build time. */
11907
- const VERSION = "0.22.0";
12979
+ const VERSION = "0.24.0";
11908
12980
  /**
11909
12981
  * 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).
11910
12982
  *
@@ -12079,8 +13151,8 @@ function locateCliCommand(request, runtime = {
12079
13151
  const theirs = typeof manifest.version === "string" ? manifest.version : "of no version";
12080
13152
  return fail("version", `${caller.packageName} ${version} finds ${target.packageName} ${theirs}, at ${installed}`);
12081
13153
  }
12082
- const callerWorkspace = workspaceOf(callerRoot);
12083
- const targetWorkspace = workspaceOf(installed);
13154
+ const callerWorkspace = workspaceOf$1(callerRoot);
13155
+ const targetWorkspace = workspaceOf$1(installed);
12084
13156
  const sameCheckout = callerWorkspace !== null && callerWorkspace === targetWorkspace;
12085
13157
  const found = {
12086
13158
  root: installed,
@@ -12490,7 +13562,7 @@ function installedPackage(start, name) {
12490
13562
  * `pnpm-workspace.yaml` above it, and its `packages` globs. A package under the checkout but not one of them — an
12491
13563
  * installed copy in its `node_modules` — is not a workspace package of it.
12492
13564
  */
12493
- function workspaceOf(root) {
13565
+ function workspaceOf$1(root) {
12494
13566
  let dir = dirname(root);
12495
13567
  for (;;) {
12496
13568
  const globs = workspaceGlobs(dir);
@@ -12563,7 +13635,7 @@ Object.freeze({
12563
13635
  packageName: "@agentcomms/core"
12564
13636
  });
12565
13637
  /** A channel of this release, by its word; a programming error for a word that is none. */
12566
- function entryOf(channel) {
13638
+ function entryOf$1(channel) {
12567
13639
  const entry = CHANNEL_SNAPSHOT.find((each) => each.manifest.channel === channel);
12568
13640
  if (entry === void 0) throw new TypeError(`"${channel}" is not a channel of this release`);
12569
13641
  return entry;
@@ -12595,8 +13667,8 @@ function cliHandoffs(options) {
12595
13667
  caller,
12596
13668
  platform,
12597
13669
  own: (words, use) => locate(own, words, use),
12598
- core: (words, use) => locate(entryOf("core"), words, use),
12599
- of: (channel, words, use) => locate(entryOf(channel), words, use),
13670
+ core: (words, use) => locate(entryOf$1("core"), words, use),
13671
+ of: (channel, words, use) => locate(entryOf$1(channel), words, use),
12600
13672
  on: (other) => other === platform ? handoffs : cliHandoffs({
12601
13673
  ...options,
12602
13674
  platform: other
@@ -13490,7 +14562,7 @@ async function gatedChangeAtTerminal(core, change, options) {
13490
14562
  if ((await approveChangeAtTerminal(core, prepared.approvalId, options.env, options.output, streams)).state !== "approved") throw cancelled();
13491
14563
  } else if (options.answered !== true) {
13492
14564
  streams.stdout.write(`${prepared.preview}\n\n`);
13493
- if ((await askLine(streams, `Type ${paint(options.output.color, "bold", "yes")} to apply this change: `)).trim().toLowerCase() !== "yes") {
14565
+ if ((await askLine$1(streams, `Type ${paint(options.output.color, "bold", "yes")} to apply this change: `)).trim().toLowerCase() !== "yes") {
13494
14566
  await revokeChange(core, prepared.approvalId, "cancelled at the terminal", {
13495
14567
  surface: "cli",
13496
14568
  disposition: "person"
@@ -13555,7 +14627,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
13555
14627
  }
13556
14628
  const prompt = await beginChangeApproval(core, approvalId, { surface: "cli" });
13557
14629
  streams.stdout.write(`${prompt.preview}\n\n`);
13558
- const answer = await askLine(streams, `Type ${paint(output.color, "bold", prompt.challenge)} to approve this change, or press Enter to cancel: `);
14630
+ const answer = await askLine$1(streams, `Type ${paint(output.color, "bold", prompt.challenge)} to approve this change, or press Enter to cancel: `);
13559
14631
  if (!answer.trim()) {
13560
14632
  await revokeChange(core, approvalId, "cancelled at the terminal", {
13561
14633
  surface: "cli",
@@ -13575,7 +14647,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
13575
14647
  function cancelled() {
13576
14648
  return new CommsError("USAGE", "cancelled: nothing was changed");
13577
14649
  }
13578
- async function askLine(streams, question) {
14650
+ async function askLine$1(streams, question) {
13579
14651
  const { createInterface } = await import("node:readline/promises");
13580
14652
  const rl = createInterface({
13581
14653
  input: streams.stdin,
@@ -13910,6 +14982,186 @@ async function openSecretStore(kind, options) {
13910
14982
  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." });
13911
14983
  return new KeychainSecretStore(keyring, options.namespace, KEYCHAIN_TIMEOUT_MS, told);
13912
14984
  }
14985
+ /** Exactly what `Date.prototype.toISOString` writes. */
14986
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
14987
+ const OUTCOMES = [
14988
+ "sent",
14989
+ "failed",
14990
+ "unknown"
14991
+ ];
14992
+ const isString = (value) => typeof value === "string";
14993
+ const isTime$2 = (value) => isString(value) && ISO.test(value) && !Number.isNaN(Date.parse(value));
14994
+ /** One line as an entry, or null when it is not one this release wrote. */
14995
+ function entryOf(line) {
14996
+ let raw;
14997
+ try {
14998
+ raw = JSON.parse(line);
14999
+ } catch {
15000
+ return null;
15001
+ }
15002
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
15003
+ const entry = raw;
15004
+ if (!isTime$2(entry.at)) return null;
15005
+ if (entry.kind === "use") {
15006
+ if (![
15007
+ "standingId",
15008
+ "childId",
15009
+ "contentDigest",
15010
+ "channel",
15011
+ "ts",
15012
+ "thread",
15013
+ "sender",
15014
+ "targetDigest"
15015
+ ].every((field) => isString(entry[field]) && entry[field] !== "")) return null;
15016
+ if (entry.act !== "reaction" && entry.act !== "reply") return null;
15017
+ if (typeof entry.edited !== "boolean") return null;
15018
+ return {
15019
+ kind: "use",
15020
+ at: entry.at,
15021
+ standingId: entry.standingId,
15022
+ childId: entry.childId,
15023
+ act: entry.act,
15024
+ contentDigest: entry.contentDigest,
15025
+ channel: entry.channel,
15026
+ ts: entry.ts,
15027
+ thread: entry.thread,
15028
+ sender: entry.sender,
15029
+ targetDigest: entry.targetDigest,
15030
+ edited: entry.edited
15031
+ };
15032
+ }
15033
+ if (entry.kind === "outcome") {
15034
+ if (!isString(entry.childId) || !isString(entry.outcome) || !OUTCOMES.includes(entry.outcome)) return null;
15035
+ if (!isString(entry.notice) || entry.link !== void 0 && !isString(entry.link)) return null;
15036
+ if (entry.actAt !== void 0 && !isTime$2(entry.actAt)) return null;
15037
+ return {
15038
+ kind: "outcome",
15039
+ at: entry.at,
15040
+ ...entry.actAt === void 0 ? {} : { actAt: entry.actAt },
15041
+ childId: entry.childId,
15042
+ outcome: entry.outcome,
15043
+ ...entry.link === void 0 ? {} : { link: entry.link },
15044
+ notice: entry.notice
15045
+ };
15046
+ }
15047
+ return null;
15048
+ }
15049
+ var AckLedger = class {
15050
+ directory;
15051
+ #now;
15052
+ constructor(stateDir, now = () => /* @__PURE__ */ new Date()) {
15053
+ this.directory = join(stateDir, "standing");
15054
+ this.#now = now;
15055
+ }
15056
+ /** The clock this ledger was opened with: what a use is timed by, and its counts' windows measured from. */
15057
+ now() {
15058
+ return this.#now();
15059
+ }
15060
+ /** The account's ledger file. Refuses anything but an account id, so no name can reach another path. */
15061
+ #path(accountId) {
15062
+ 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." });
15063
+ return join(this.directory, `${accountId}.jsonl`);
15064
+ }
15065
+ /** Every line, read: the entries, and the number — from 1 — of each line this release cannot read. */
15066
+ async #read(path) {
15067
+ let text = "";
15068
+ try {
15069
+ text = await readFile(path, "utf8");
15070
+ } catch (error) {
15071
+ if (error.code !== "ENOENT") throw error;
15072
+ }
15073
+ const entries = [];
15074
+ const unreadable = [];
15075
+ for (const [index, line] of text.split("\n").entries()) {
15076
+ if (line.trim() === "") continue;
15077
+ const entry = entryOf(line);
15078
+ if (entry === null) unreadable.push(index + 1);
15079
+ else entries.push(entry);
15080
+ }
15081
+ return {
15082
+ entries,
15083
+ unreadable
15084
+ };
15085
+ }
15086
+ /** Where an account's ledger is kept: `<state>/standing/<accountId>.jsonl`, for a person told to move it aside. */
15087
+ pathOf(accountId) {
15088
+ return this.#path(accountId);
15089
+ }
15090
+ /**
15091
+ * Every account's ledger on this machine that holds a line this release cannot read, with the number of each such
15092
+ * line, for the doctor (design 2026-10-19-standing-approvals, the lead's call after review): while one does, no
15093
+ * standing approval of that account can be used — what it counts cannot be known — and nothing else says so. Reads
15094
+ * without the lock: a look, and every line it reads was written whole.
15095
+ */
15096
+ async unreadable() {
15097
+ let names;
15098
+ try {
15099
+ names = await readdir(this.directory);
15100
+ } catch (error) {
15101
+ if (error.code === "ENOENT") return [];
15102
+ throw error;
15103
+ }
15104
+ const found = [];
15105
+ for (const name of names.sort()) {
15106
+ if (!name.endsWith(".jsonl")) continue;
15107
+ const accountId = name.slice(0, -6);
15108
+ if (!ACCOUNT_ID_PATTERN.test(accountId)) continue;
15109
+ const path = this.#path(accountId);
15110
+ const { unreadable } = await this.#read(path);
15111
+ if (unreadable.length > 0) found.push({
15112
+ accountId,
15113
+ path,
15114
+ lines: unreadable
15115
+ });
15116
+ }
15117
+ return found;
15118
+ }
15119
+ /**
15120
+ * Runs `fn` under the account's ledger lock, with every entry as it reads there and `append`, which writes a `use`
15121
+ * line under the lock already held. `fn` must never take this lock again.
15122
+ *
15123
+ * A line this release cannot read refuses the whole call, BAD_DATA, before `fn` runs: a count read without it could
15124
+ * be lower than the truth, and a count is never guessed downwards.
15125
+ */
15126
+ withLock(accountId, fn) {
15127
+ let path;
15128
+ try {
15129
+ path = this.#path(accountId);
15130
+ } catch (error) {
15131
+ return Promise.reject(error);
15132
+ }
15133
+ return withFileLock(`${path}.lock`, async () => {
15134
+ const { entries, unreadable } = await this.#read(path);
15135
+ 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", {
15136
+ 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.",
15137
+ details: {
15138
+ unreadable: unreadable.length,
15139
+ lines: unreadable
15140
+ }
15141
+ });
15142
+ const append = async (entry) => {
15143
+ if (entry.kind !== "use" || entryOf(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." });
15144
+ await appendPrivateLine(path, JSON.stringify(entry), { durable: true });
15145
+ };
15146
+ return fn(entries, append);
15147
+ });
15148
+ }
15149
+ /** Records what became of a use's act, taking the lock itself. It never frees a count. */
15150
+ async recordOutcome(accountId, entry) {
15151
+ const path = this.#path(accountId);
15152
+ const written = {
15153
+ ...entry,
15154
+ kind: "outcome",
15155
+ at: entry.at ?? this.#now().toISOString()
15156
+ };
15157
+ if (entryOf(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." });
15158
+ await withFileLock(`${path}.lock`, () => appendPrivateLine(path, JSON.stringify(written), { durable: true }));
15159
+ }
15160
+ /** Every entry this release can read, oldest first, for a report: a line it cannot read is left out of it. */
15161
+ async entries(accountId) {
15162
+ return (await this.#read(this.#path(accountId))).entries;
15163
+ }
15164
+ };
13913
15165
  var InboxStateStore = class {
13914
15166
  directory;
13915
15167
  constructor(stateDir) {
@@ -14744,6 +15996,7 @@ function openCore(options = {}) {
14744
15996
  answered: (approvalId) => securityAnswered(paths.stateDir, approvalId)
14745
15997
  }),
14746
15998
  ledger: new SendLedger(paths.stateDir, now),
15999
+ acks: new AckLedger(paths.stateDir, now),
14747
16000
  plans: new PlanStore(paths.stateDir, now),
14748
16001
  taint: new TaintStore(paths.stateDir, now),
14749
16002
  audit,
@@ -14811,9 +16064,26 @@ function stillWaiting(stored, outcome) {
14811
16064
  if (outcome.state === "sending") return true;
14812
16065
  return outcome.state === "pending" && !outcome.claimable;
14813
16066
  }
16067
+ /**
16068
+ * A standing approval's own state, as `standing list` reads it: `never` on its account ends it at a look, before any
16069
+ * use writes that down. Undefined for every other kind.
16070
+ */
16071
+ function standingStateOf(outcome) {
16072
+ if (outcome.record?.kind !== "standing") return void 0;
16073
+ const never = outcome.reason === NEVER_NOTICE;
16074
+ if (outcome.state === "pending" && !never) return { state: "pending" };
16075
+ if (outcome.state === "approved" && !never) return { state: "active" };
16076
+ const reason = outcome.reason ?? outcome.record.reason;
16077
+ return {
16078
+ state: "ended",
16079
+ ...reason === void 0 ? {} : { reason }
16080
+ };
16081
+ }
14814
16082
  /** Why a wait that has stopped waiting ended, from the classification it stopped on. */
14815
- function endOf(outcome) {
16083
+ function endOf$1(outcome) {
14816
16084
  if (outcome.claimable) return "claimable";
16085
+ const standing = standingStateOf(outcome);
16086
+ if (standing !== void 0) return standing.state === "active" ? "active" : "final";
14817
16087
  if (outcome.record !== null && outcome.state === "approved") return "blocked";
14818
16088
  return "final";
14819
16089
  }
@@ -14868,6 +16138,7 @@ async function waitHeld(core, approvalId, waitSeconds, options) {
14868
16138
  ended,
14869
16139
  waitedSeconds
14870
16140
  };
16141
+ const standing = standingStateOf(outcome);
14871
16142
  return {
14872
16143
  approvalId,
14873
16144
  state: outcome.state,
@@ -14875,13 +16146,14 @@ async function waitHeld(core, approvalId, waitSeconds, options) {
14875
16146
  approval,
14876
16147
  ended,
14877
16148
  waitedSeconds,
14878
- ...ended === "timeout" ? { hint: waitAgainHint(outcome.state, options.channel) } : {}
16149
+ ...ended === "timeout" ? { hint: waitAgainHint(outcome.state, options.channel) } : {},
16150
+ ...standing === void 0 ? {} : { standing }
14879
16151
  };
14880
16152
  };
14881
16153
  if (waitSeconds === 0) return finish("now", seen.outcome);
14882
16154
  let lastProgress = started;
14883
16155
  for (;;) {
14884
- if (!stillWaiting(seen.stored, seen.outcome)) return finish(endOf(seen.outcome), seen.outcome);
16156
+ if (!stillWaiting(seen.stored, seen.outcome)) return finish(endOf$1(seen.outcome), seen.outcome);
14885
16157
  if (options.signal?.aborted) return cancelled();
14886
16158
  if (clock.now() >= deadline) return finish("timeout", seen.outcome);
14887
16159
  const next = lastLook + WAIT_LOOK_MS;
@@ -14909,7 +16181,7 @@ async function waitHeld(core, approvalId, waitSeconds, options) {
14909
16181
  lastLook = clock.now();
14910
16182
  seen = await core.approvals.inspect(approvalId, expect, { action: "wait" });
14911
16183
  }
14912
- if (!stillWaiting(seen.stored, seen.outcome)) return finish(endOf(seen.outcome), seen.outcome);
16184
+ if (!stillWaiting(seen.stored, seen.outcome)) return finish(endOf$1(seen.outcome), seen.outcome);
14913
16185
  return finish("cancelled", seen.outcome);
14914
16186
  }
14915
16187
  }
@@ -15891,7 +17163,7 @@ function serverPruneChange(core, env, request, own) {
15891
17163
  };
15892
17164
  const { packageName, version } = ownProduct(request.channel, own) ?? {
15893
17165
  packageName: serverFacts(request.channel).packageName,
15894
- version: "0.22.0"
17166
+ version: "0.24.0"
15895
17167
  };
15896
17168
  const product = {
15897
17169
  packageName,
@@ -16039,6 +17311,12 @@ function everyServerPruneChange(core, env, request) {
16039
17311
  }
16040
17312
  };
16041
17313
  }
17314
+ Object.freeze([
17315
+ "narrow",
17316
+ "revoke",
17317
+ "off"
17318
+ ]);
17319
+ Object.keys(STANDING_LIMITS);
16042
17320
  /**
16043
17321
  * Bringing a machine to the latest release, from a terminal (`agentcomms update`) or a chat (`comms_update`).
16044
17322
  *
@@ -16556,8 +17834,9 @@ function updateToolNotice(options) {
16556
17834
  let running = null;
16557
17835
  let given = false;
16558
17836
  let turn = Promise.resolve();
16559
- return (tool) => {
17837
+ return (tool, args) => {
16560
17838
  if (updateCheckSwitchedOff(options.env) !== null) return Promise.resolve(null);
17839
+ if (options.quiet?.(tool, args)) return Promise.resolve(null);
16561
17840
  if (options.refresh && running === null) running = options.refresh().catch(() => void 0).finally(() => {
16562
17841
  running = null;
16563
17842
  });
@@ -17312,7 +18591,7 @@ function terminalUpdateHooks(core, env, options = {}) {
17312
18591
  } };
17313
18592
  }
17314
18593
  //#endregion
17315
- //#region ../core/dist/tool-arguments-H5H3FITT.mjs
18594
+ //#region ../core/dist/tool-arguments-5t8Xd2eK.mjs
17316
18595
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
17317
18596
  const TARGET = "draft-2020-12";
17318
18597
  /**
@@ -17355,7 +18634,7 @@ function strictToolArguments(server, refuse, notice, retired = {}) {
17355
18634
  const parsed = await schema.safeParseAsync(input ?? {});
17356
18635
  if (!parsed.success) return refuse(refusal(name, published, input, parsed.error.issues, retired[name] ?? {}));
17357
18636
  const result = await (declared ? handler(parsed.data, context) : handler(context));
17358
- const line = notice ? await notice(name).catch(() => null) : null;
18637
+ const line = notice ? await notice(name, parsed.data).catch(() => null) : null;
17359
18638
  return line === null ? result : withUpdateNotice(result, line, room);
17360
18639
  });
17361
18640
  };
@@ -27628,6 +28907,27 @@ strictObject({
27628
28907
  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`);
27629
28908
  }
27630
28909
  });
28910
+ /** The environment values the Windows script reads the title and the body from. */
28911
+ const TITLE_VARIABLE = "AGENTCOMMS_NOTICE_TITLE";
28912
+ const BODY_VARIABLE = "AGENTCOMMS_NOTICE_BODY";
28913
+ Object.freeze([
28914
+ "-e",
28915
+ "on run argv",
28916
+ "-e",
28917
+ "display notification (item 1 of argv) with title (item 2 of argv)",
28918
+ "-e",
28919
+ "end run"
28920
+ ]);
28921
+ [
28922
+ "$ErrorActionPreference = 'Stop'",
28923
+ "$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]",
28924
+ "$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)",
28925
+ "$text = $xml.GetElementsByTagName('text')",
28926
+ `$null = $text.Item(0).AppendChild($xml.CreateTextNode([string]$env:${TITLE_VARIABLE}))`,
28927
+ `$null = $text.Item(1).AppendChild($xml.CreateTextNode([string]$env:${BODY_VARIABLE}))`,
28928
+ "$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)",
28929
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show($toast)"
28930
+ ].join("; ");
27631
28931
  /**
27632
28932
  * Email HTML is attacker-controlled. Before any of it becomes text a model reads, this removes what a human reading
27633
28933
  * the message in a mail client would not see — the channel used by hidden-text prompt injection (e.g. zero-size or
@@ -29001,6 +30301,10 @@ async function fenceOrStop(store, approvalId, claimToken, progress) {
29001
30301
  })
29002
30302
  };
29003
30303
  }
30304
+ Object.freeze({
30305
+ min: 5e3,
30306
+ max: 3e4
30307
+ });
29004
30308
  Object.freeze({
29005
30309
  complete: "not sent with any approval in the last 90 days",
29006
30310
  window: "not sent with any of the 500 most recently changed approval records",