@agentcomms/resend 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.23.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-BrR1qAfk.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. */
@@ -3295,12 +3332,12 @@ function collectFromJson(parsed, client, path, topScope = "user") {
3295
3332
  function collectFromToml(text, client, path) {
3296
3333
  const servers = parseToml(text).mcp_servers;
3297
3334
  if (servers === void 0) return [];
3298
- if (!isRecord$1(servers)) throw new TomlError("mcp_servers is not a table");
3335
+ if (!isRecord$2(servers)) throw new TomlError("mcp_servers is not a table");
3299
3336
  const found = [];
3300
3337
  for (const [name, entry] of Object.entries(servers)) {
3301
- if (!isRecord$1(entry)) throw new TomlError(`mcp_servers.${name} is not a table`);
3338
+ if (!isRecord$2(entry)) throw new TomlError(`mcp_servers.${name} is not a table`);
3302
3339
  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`);
3340
+ if (entry.env !== void 0 && !isRecord$2(entry.env)) throw new TomlError(`mcp_servers.${name}.env is not a table`);
3304
3341
  found.push({
3305
3342
  client,
3306
3343
  path,
@@ -3308,12 +3345,12 @@ function collectFromToml(text, client, path) {
3308
3345
  command: typeof entry.command === "string" ? entry.command : "",
3309
3346
  args: Array.isArray(entry.args) ? entry.args.map(String) : [],
3310
3347
  ...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)])) } : {}
3348
+ ...isRecord$2(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
3312
3349
  });
3313
3350
  }
3314
3351
  return found;
3315
3352
  }
3316
- function isRecord$1(value) {
3353
+ function isRecord$2(value) {
3317
3354
  return typeof value === "object" && value !== null && !Array.isArray(value);
3318
3355
  }
3319
3356
  /**
@@ -3326,8 +3363,8 @@ function isRecord$1(value) {
3326
3363
  */
3327
3364
  function codexServerFromGet(name, stdout, path) {
3328
3365
  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;
3366
+ if (!isRecord$2(answer)) throw new SyntaxError("not an object");
3367
+ const entry = isRecord$2(answer.transport) ? answer.transport : answer;
3331
3368
  const command = typeof entry.command === "string" ? entry.command : "";
3332
3369
  const url = typeof entry.url === "string" ? entry.url : void 0;
3333
3370
  if (!command && !url) throw new SyntaxError("neither a command nor a URL");
@@ -3339,7 +3376,7 @@ function codexServerFromGet(name, stdout, path) {
3339
3376
  command,
3340
3377
  args: Array.isArray(entry.args) ? entry.args.map(String) : [],
3341
3378
  ...url ? { url } : {},
3342
- ...isRecord$1(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
3379
+ ...isRecord$2(entry.env) ? { env: Object.fromEntries(Object.entries(entry.env).map(([key, value]) => [key, String(value)])) } : {}
3343
3380
  };
3344
3381
  return {
3345
3382
  ...server,
@@ -3445,7 +3482,7 @@ async function scanRegisteredServers(env = process.env, platform = process.platf
3445
3482
  continue;
3446
3483
  }
3447
3484
  add(collectFromJson(parsed, file.client, file.path));
3448
- if (file.client !== "claude-code" || !isRecord$1(parsed) || !isRecord$1(parsed.projects)) continue;
3485
+ if (file.client !== "claude-code" || !isRecord$2(parsed) || !isRecord$2(parsed.projects)) continue;
3449
3486
  for (const root of Object.keys(parsed.projects)) {
3450
3487
  if (!isAbsolute(root)) continue;
3451
3488
  const path = join(root, ".mcp.json");
@@ -6429,7 +6466,7 @@ const BEHAVIOUR_ACTS = Object.freeze([
6429
6466
  function isBehaviourAct(value) {
6430
6467
  return typeof value === "string" && BEHAVIOUR_ACTS.includes(value);
6431
6468
  }
6432
- function isRecord$2(value) {
6469
+ function isRecord$1(value) {
6433
6470
  return typeof value === "object" && value !== null && !Array.isArray(value);
6434
6471
  }
6435
6472
  /**
@@ -6476,7 +6513,7 @@ function accountNamed(written, names) {
6476
6513
  /** One stored rule, or why it cannot be read. Reasons are fixed words: nothing from the file is repeated in them. */
6477
6514
  function readRule(raw, index, names) {
6478
6515
  const at = `rule ${index + 1}`;
6479
- if (!isRecord$2(raw)) return { unreadable: {
6516
+ if (!isRecord$1(raw)) return { unreadable: {
6480
6517
  account: null,
6481
6518
  reason: `${at} is not an object`
6482
6519
  } };
@@ -6532,7 +6569,7 @@ function readBehaviourSettings(config) {
6532
6569
  rules: [],
6533
6570
  unreadable: []
6534
6571
  };
6535
- if (!isRecord$2(stored)) return unreadableWhole("the behaviour setting is not an object");
6572
+ if (!isRecord$1(stored)) return unreadableWhole("the behaviour setting is not an object");
6536
6573
  const rules = stored.rules;
6537
6574
  if (rules === void 0) return {
6538
6575
  rules: [],
@@ -6909,7 +6946,7 @@ const SHOWN_WIDTH = 120;
6909
6946
  * space, and cut to `SHOWN_WIDTH`. As `shownText` shows a profile's text, which this module cannot import: its module
6910
6947
  * reads the configuration, and the configuration imports this one.
6911
6948
  */
6912
- function shownName(value) {
6949
+ function shownName$1(value) {
6913
6950
  const flat = neutralise(value).text.replace(/[\n\t]+/g, " ");
6914
6951
  const chars = [...flat];
6915
6952
  return chars.length <= SHOWN_WIDTH ? flat : `${chars.slice(0, 119).join("")}…`;
@@ -6922,20 +6959,20 @@ function shownName(value) {
6922
6959
  * act, the place and the rule as the decision holds them, made safe the same way.
6923
6960
  */
6924
6961
  function actNotAllowed(decision, shown, hint) {
6925
- const account = shownName(shown.account);
6962
+ const account = shownName$1(shown.account);
6926
6963
  const named = shown.place ?? decision.place;
6927
- const where = named === null ? `in ${account}` : `in ${shownName(named)} (${account})`;
6964
+ const where = named === null ? `in ${account}` : `in ${shownName$1(named)} (${account})`;
6928
6965
  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)
6966
+ const rule = decision.rule.conversation === void 0 ? { account: shownName$1(decision.rule.account) } : {
6967
+ account: shownName$1(decision.rule.account),
6968
+ conversation: shownName$1(decision.rule.conversation)
6932
6969
  };
6933
6970
  return new CommsError("ACT_NOT_ALLOWED", message, {
6934
6971
  hint,
6935
6972
  details: {
6936
6973
  act: decision.act,
6937
6974
  account,
6938
- place: decision.place === null ? null : shownName(decision.place),
6975
+ place: decision.place === null ? null : shownName$1(decision.place),
6939
6976
  rule,
6940
6977
  allowedActs: [...decision.allowedActs]
6941
6978
  }
@@ -6963,8 +7000,8 @@ function allowedHereLine(decision, shown) {
6963
7000
  const acts = decision.allowedActs;
6964
7001
  const allowed = acts === "all" ? "everything" : acts.length === 0 ? "nothing" : acts.join(", ");
6965
7002
  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})`;
7003
+ const place = conversation === void 0 ? "" : `, ${shownName$1(shown.place ?? conversation)}`;
7004
+ return `Allowed here: ${allowed} (rule: ${shownName$1(shown.account)}${place})`;
6968
7005
  }
6969
7006
  /**
6970
7007
  * `config.behaviour` with every rule that holds the account `id` renamed from `from` to `to` (design
@@ -6979,7 +7016,7 @@ function allowedHereLine(decision, shown) {
6979
7016
  */
6980
7017
  function behaviourRenamed(config, id, from, to) {
6981
7018
  const stored = config.behaviour;
6982
- if (!isRecord$2(stored)) return stored;
7019
+ if (!isRecord$1(stored)) return stored;
6983
7020
  const rules = stored.rules;
6984
7021
  if (!Array.isArray(rules)) return stored;
6985
7022
  const held = new Set([...Object.values(config.inboxes ?? {}), ...Object.values(config.accounts ?? {})].map((entry) => entry.id));
@@ -6990,7 +7027,7 @@ function behaviourRenamed(config, id, from, to) {
6990
7027
  };
6991
7028
  return {
6992
7029
  ...stored,
6993
- rules: rules.map((rule) => isRecord$2(rule) && holds(rule) ? {
7030
+ rules: rules.map((rule) => isRecord$1(rule) && holds(rule) ? {
6994
7031
  ...rule,
6995
7032
  account: to
6996
7033
  } : rule)
@@ -7567,6 +7604,7 @@ const configV2Schema = looseObject({
7567
7604
  /** What version 3 adds, loose as the rest of the file is: a later release's keys inside them are kept. */
7568
7605
  const v3Shape = {
7569
7606
  sendEpochs: record(string().min(1), number().int().min(0)).optional(),
7607
+ standingEpoch: number().int().min(0).optional(),
7570
7608
  legacyDrain: looseObject({
7571
7609
  since: absoluteTimeSchema,
7572
7610
  tracked: record(string().min(1), _enum(["open", ...[
@@ -7850,7 +7888,7 @@ var ConfigStore = class {
7850
7888
  });
7851
7889
  }
7852
7890
  }
7853
- const written = withSendEpochs(current, parsed.data);
7891
+ const written = withStandingEpoch(current, withSendEpochs(current, parsed.data));
7854
7892
  const serialized = `${JSON.stringify(written, null, 2)}\n`;
7855
7893
  await writeFileAtomic(this.path, serialized, 384, options.signal);
7856
7894
  committed = written;
@@ -8033,6 +8071,46 @@ function withSendEpochs(before, after) {
8033
8071
  sendEpochs: epochs
8034
8072
  };
8035
8073
  }
8074
+ /**
8075
+ * The standing epoch in `config` (design 2026-10-19-standing-approvals, S8): its stored value, or 0 when it has none —
8076
+ * or when it is not a whole number of 0 or more, which no write of this release leaves.
8077
+ */
8078
+ function standingEpochOf(config) {
8079
+ const epoch = config.standingEpoch;
8080
+ return typeof epoch === "number" && Number.isInteger(epoch) && epoch >= 0 ? epoch : 0;
8081
+ }
8082
+ /** The change policy of every inbox and account by id, each its own or the default. */
8083
+ function changePoliciesById(config) {
8084
+ const policies = /* @__PURE__ */ new Map();
8085
+ for (const inbox of Object.values(config.inboxes)) policies.set(inbox.id, inbox.changePolicy ?? defaultChangePolicy(config));
8086
+ for (const account of Object.values(config.accounts)) policies.set(account.id, account.changePolicy ?? defaultChangePolicy(config));
8087
+ return policies;
8088
+ }
8089
+ /**
8090
+ * Whether this write turns a change policy from `chat` to `confirm`: the default's, or the effective one of an inbox or
8091
+ * account there before and after, by id.
8092
+ */
8093
+ function tightensAChangePolicy(before, after) {
8094
+ if (defaultChangePolicy(before) === "chat" && defaultChangePolicy(after) === "confirm") return true;
8095
+ const was = changePoliciesById(before);
8096
+ for (const [id, policy] of changePoliciesById(after)) if (policy === "confirm" && was.get(id) === "chat") return true;
8097
+ return false;
8098
+ }
8099
+ /**
8100
+ * `after`, with the standing epoch this write owes (design 2026-10-19-standing-approvals, S8): on version 3, the higher
8101
+ * of the two it holds — never lowered, whatever the write's own copy says, nor dropped — and one more when it tightens a
8102
+ * change policy (`tightensAChangePolicy`). Before version 3 there is no epoch, and no standing approval: every one is
8103
+ * made on version 3.
8104
+ */
8105
+ function withStandingEpoch(before, after) {
8106
+ if (before.version !== 3 || after.version !== 3) return after;
8107
+ const held = Math.max(standingEpochOf(before), standingEpochOf(after));
8108
+ const kept = tightensAChangePolicy(before, after) ? held + 1 : held;
8109
+ return kept === 0 && after.standingEpoch === void 0 ? after : {
8110
+ ...after,
8111
+ standingEpoch: kept
8112
+ };
8113
+ }
8036
8114
  async function readFileIfExists(path) {
8037
8115
  try {
8038
8116
  return await readFile(path, "utf8");
@@ -8717,23 +8795,53 @@ function ownerEntry(config, ownerId) {
8717
8795
  */
8718
8796
  function liveGateOf(config, record) {
8719
8797
  const governing = (fallback) => record.kind === "change" && record.change !== void 0 ? governingChangePolicy(config, record.change) : fallback;
8798
+ const standingEpoch = standingEpochOf(config);
8720
8799
  if (record.ownerScope !== "owner") return {
8721
8800
  owner: "none",
8722
- changePolicy: governing(defaultChangePolicy(config))
8801
+ changePolicy: governing(defaultChangePolicy(config)),
8802
+ standingEpoch
8723
8803
  };
8724
8804
  const entry = ownerEntry(config, record.inboxId);
8725
- if (entry === void 0) return { owner: "removed" };
8805
+ if (entry === void 0) return {
8806
+ owner: "removed",
8807
+ standingEpoch
8808
+ };
8726
8809
  return {
8727
8810
  owner: "present",
8728
8811
  sendPolicy: entry.sendPolicy ?? config.defaults.sendPolicy,
8729
8812
  changePolicy: governing(entry.changePolicy ?? defaultChangePolicy(config)),
8730
- sendEpoch: sendEpochOf(config, record.inboxId)
8813
+ sendEpoch: sendEpochOf(config, record.inboxId),
8814
+ standingEpoch
8731
8815
  };
8732
8816
  }
8733
8817
  /** The reason a record whose owner was removed is revoked with. */
8734
8818
  const OWNER_REMOVED_REASON = "its mailbox or account was removed";
8735
8819
  /** The reason a send prepared before a `never` is revoked with: its stored epoch is behind the live one. */
8736
8820
  const SEND_EPOCH_REASON = "sending was turned off since this was prepared (policy: never)";
8821
+ /**
8822
+ * The reason a standing approval, or one of its unused children, is revoked with once standing approvals were ended
8823
+ * since it was made: its standing epoch is behind the live one (design 2026-10-19-standing-approvals, S8).
8824
+ */
8825
+ const STANDING_EPOCH_REASON = "standing approvals were ended since this was approved";
8826
+ /**
8827
+ * The reason a standing approval is revoked with once its account's change policy is stricter than the one it was
8828
+ * approved under (`approvedChangePolicy`) — `changePolicy-tightened`, read from the configuration itself, whatever the
8829
+ * standing epoch says, so a release that knows no epoch tightening the policy still ends it (design
8830
+ * 2026-10-19-standing-approvals, S8, the lead's call after review).
8831
+ */
8832
+ const CHANGE_POLICY_TIGHTENED_REASON = "the change policy was tightened since this was approved";
8833
+ /** Whether `live` is stricter than `approved`: `confirm` over `chat`. */
8834
+ function changePolicyTightened(approved, live) {
8835
+ return live === "confirm" && approved !== "confirm";
8836
+ }
8837
+ /**
8838
+ * Whether even a look writes this outcome's revocation down: a standing approval ended by a tightened change policy.
8839
+ * Every other revocation a look derives is from something that never goes back — an epoch, an owner's id — so the next
8840
+ * action can write it; a policy can be loosened again, and what it ended must stay ended (S8).
8841
+ */
8842
+ function writtenAtALook(outcome) {
8843
+ return outcome.revokes && outcome.record?.kind === "standing" && outcome.reason === "the change policy was tightened since this was approved";
8844
+ }
8737
8845
  /** What an outcome says of a pending or approved send under a live `never`, before anything revokes it. */
8738
8846
  const NEVER_NOTICE = "sending is turned off (policy: never); any use revokes it";
8739
8847
  /** The refusal of a claim, an approval or an answer by a store opened without a configuration. */
@@ -8742,7 +8850,11 @@ const NO_CONFIGURATION = "this approval store was opened without a configuration
8742
8850
  const sentAtWords = (usedAt) => `sent at ${usedAt}`;
8743
8851
  /** The refusal prefix and detail key of each kind: a send sends nothing, a change changes nothing, a question saves nothing. */
8744
8852
  function refusalOf(kind) {
8745
- const [prefix, idKey, hint] = kind === "disclosure" ? [
8853
+ const [prefix, idKey, hint] = kind === "standing" ? [
8854
+ "nothing was sent",
8855
+ "approvalId",
8856
+ "This standing approval cannot be used. Ask the person instead; only they can set up another, at their own terminal."
8857
+ ] : kind === "disclosure" ? [
8746
8858
  "nothing was disclosed",
8747
8859
  "approvalId",
8748
8860
  "Prepare a new standing disclosure authorisation and show its exact activation preview to the person."
@@ -8773,7 +8885,7 @@ function refusalOf(kind) {
8773
8885
  * or one a pinned surface may not see — byte for byte the same, with `approval: null`, and nothing of any record in it.
8774
8886
  */
8775
8887
  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}`, {
8888
+ 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
8889
  hint: "Check the id: an approval is only found by the surface, the kind and the owner it was prepared for.",
8778
8890
  details: { approval: null }
8779
8891
  });
@@ -9030,12 +9142,20 @@ function classifyV2(read, context) {
9030
9142
  const ownerRemoved = read.ownerScope === "owner" && live?.owner === "removed";
9031
9143
  const active = read.state === "pending" || read.state === "approved";
9032
9144
  const acting = action === "approve" || action === "claim";
9033
- const never = read.kind === "send" && live?.owner === "present" && stricterPolicy(live.sendPolicy ?? "never", read.requiredPolicy) === "never";
9145
+ const sends = read.kind === "send" || read.kind === "standing";
9146
+ const standingBound = read.kind === "standing" || read.kind === "send" && read.route === "standing";
9147
+ const never = sends && live?.owner === "present" && stricterPolicy(live.sendPolicy ?? "never", read.requiredPolicy) === "never";
9034
9148
  let derived = null;
9149
+ let revokedByNever = false;
9035
9150
  if (active && ownerRemoved) derived = OWNER_REMOVED_REASON;
9036
9151
  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;
9152
+ if (acting) {
9153
+ derived = live?.sendPolicy === "never" ? `sending is turned off for this ${accountNoun(read.channel)} (policy: never)` : "sending is turned off for this approval (policy: never)";
9154
+ revokedByNever = true;
9155
+ }
9156
+ } else if (active && sends && live?.owner === "present" && (read.sendEpoch ?? 0) !== live.sendEpoch) derived = SEND_EPOCH_REASON;
9157
+ else if (active && standingBound && live !== null && live.standingEpoch !== void 0 && read.standingEpoch !== live.standingEpoch) derived = STANDING_EPOCH_REASON;
9158
+ else if (read.kind === "standing" && read.state === "approved" && live?.owner === "present" && changePolicyTightened(read.approvedChangePolicy, live.changePolicy)) derived = CHANGE_POLICY_TIGHTENED_REASON;
9039
9159
  const record = derived === null ? read : {
9040
9160
  ...read,
9041
9161
  state: "revoked",
@@ -9043,7 +9163,6 @@ function classifyV2(read, context) {
9043
9163
  revokedAt: at,
9044
9164
  challengeHash: void 0
9045
9165
  };
9046
- const revokedByNever = derived !== null && derived !== "its mailbox or account was removed" && derived !== "sending was turned off since this was prepared (policy: never)";
9047
9166
  let claimable = false;
9048
9167
  let reason = record.reason;
9049
9168
  if (record.state === "pending" || record.state === "approved") {
@@ -9109,6 +9228,7 @@ function isClaimable(record, live) {
9109
9228
  case "change": return record.state === "approved" || record.route === "chat" && live.changePolicy === "chat";
9110
9229
  case "download": return downloadClaimable(record, live.changePolicy);
9111
9230
  case "disclosure": return record.state === "approved";
9231
+ case "standing": return false;
9112
9232
  }
9113
9233
  }
9114
9234
  /**
@@ -9157,7 +9277,7 @@ function errorOf(record, approval, context) {
9157
9277
  if (download) return refuse("APPROVAL_EXPIRED", downloadExpiredWords(record.approvedVia !== void 0), record, approval);
9158
9278
  const nothing = `nothing was ${record.kind === "change" ? "changed" : "sent"} with it`;
9159
9279
  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.",
9280
+ 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
9281
  details: {
9162
9282
  approvalId: record.approvalId,
9163
9283
  state: record.state,
@@ -9241,6 +9361,578 @@ var AuditLog = class {
9241
9361
  }
9242
9362
  };
9243
9363
  /**
9364
+ * The emoji a standing approval may react with (design 2026-10-19-standing-approvals, S3 and §8.4): a curated list of
9365
+ * standard Unicode names, each with its code point, shipped in the code.
9366
+ *
9367
+ * Only standard names, because a custom workspace emoji's image can be replaced under the same name: the person would
9368
+ * have approved a picture that is no longer the one sent. And a short list rather than every Unicode name, because each
9369
+ * one here is a word the agent may say on the person's behalf without asking.
9370
+ */
9371
+ const STANDARD_EMOJI = Object.freeze({
9372
+ eyes: "U+1F440",
9373
+ white_check_mark: "U+2705",
9374
+ heavy_check_mark: "U+2714",
9375
+ ballot_box_with_check: "U+2611",
9376
+ "+1": "U+1F44D",
9377
+ thumbsup: "U+1F44D",
9378
+ ok: "U+1F197",
9379
+ ok_hand: "U+1F44C",
9380
+ "100": "U+1F4AF",
9381
+ heavy_plus_sign: "U+2795",
9382
+ handshake: "U+1F91D",
9383
+ raised_hands: "U+1F64C",
9384
+ pray: "U+1F64F",
9385
+ clap: "U+1F44F",
9386
+ wave: "U+1F44B",
9387
+ memo: "U+1F4DD",
9388
+ pencil2: "U+270F",
9389
+ hourglass_flowing_sand: "U+23F3",
9390
+ stopwatch: "U+23F1",
9391
+ inbox_tray: "U+1F4E5",
9392
+ pushpin: "U+1F4CC",
9393
+ mag: "U+1F50D",
9394
+ bulb: "U+1F4A1",
9395
+ speech_balloon: "U+1F4AC",
9396
+ thinking_face: "U+1F914",
9397
+ slightly_smiling_face: "U+1F642",
9398
+ heart: "U+2764",
9399
+ tada: "U+1F389",
9400
+ rocket: "U+1F680",
9401
+ sparkles: "U+2728"
9402
+ });
9403
+ /**
9404
+ * The emoji among them that people read as a yes: ✅ on "can I deploy?" is taken for approval, and a Slack workflow
9405
+ * can start on a reaction (S3). Allowed, with a warning in the preview.
9406
+ */
9407
+ const READS_AS_APPROVAL = Object.freeze([
9408
+ "white_check_mark",
9409
+ "heavy_check_mark",
9410
+ "ballot_box_with_check",
9411
+ "+1",
9412
+ "thumbsup",
9413
+ "ok",
9414
+ "ok_hand",
9415
+ "100",
9416
+ "heavy_plus_sign",
9417
+ "handshake"
9418
+ ]);
9419
+ /** Whether `name` is one of the standard emoji a standing approval may hold, exactly as listed. */
9420
+ function isStandardEmoji(name) {
9421
+ return Object.hasOwn(STANDARD_EMOJI, name);
9422
+ }
9423
+ Object.freeze(["reaction", "reply"]);
9424
+ /** S3: what a standing approval holds when no content is given. */
9425
+ const STANDING_DEFAULT_REACTION = "eyes";
9426
+ /** One day, the unit a standing approval's expiry is counted in. */
9427
+ const STANDING_DAY_MS = 864e5;
9428
+ /** S6's table: each limit's default and the range a person may set it to. */
9429
+ const STANDING_LIMITS = Object.freeze({
9430
+ perThread: Object.freeze({
9431
+ default: 1,
9432
+ min: 1,
9433
+ max: 3
9434
+ }),
9435
+ perHour: Object.freeze({
9436
+ default: 10,
9437
+ min: 1,
9438
+ max: 30
9439
+ }),
9440
+ perDay: Object.freeze({
9441
+ default: 30,
9442
+ min: 1,
9443
+ max: 100
9444
+ }),
9445
+ perSender: Object.freeze({
9446
+ default: 3,
9447
+ min: 1,
9448
+ max: 10
9449
+ }),
9450
+ expiresDays: Object.freeze({
9451
+ default: 7,
9452
+ min: 1,
9453
+ max: 30
9454
+ })
9455
+ });
9456
+ const LIMIT_NAMES$1 = [
9457
+ "perThread",
9458
+ "perHour",
9459
+ "perDay",
9460
+ "perSender",
9461
+ "expiresDays"
9462
+ ];
9463
+ const CONTENT_WORDS = Object.freeze({
9464
+ "too-many-reactions": `a standing approval holds at most 5 reactions`,
9465
+ "too-many-replies": `a standing approval holds at most 5 replies`,
9466
+ "custom-emoji": "only a standard emoji from the list this release ships may be a standing reaction",
9467
+ empty: "a standing reply has no words",
9468
+ "too-long": `a standing reply is at most 200 characters`,
9469
+ "multi-line": "a standing reply is one line",
9470
+ "control-character": "a standing reply holds a control or invisible character",
9471
+ mention: "a standing reply mentions nobody: no @, and no <@…> or <!…>",
9472
+ link: "a standing reply holds no link, address or domain",
9473
+ placeholder: "a standing reply is sent exactly as written: it holds no placeholder",
9474
+ markup: "a standing reply is plain text: no formatting characters",
9475
+ "emoji-code": "a standing reply holds no :emoji: code; a reaction is its own content",
9476
+ pictograph: "a standing reply holds no emoji or pictograph, such as ✅, ✔ or 👍; a reaction is its own content",
9477
+ duplicate: "a standing approval holds each content once"
9478
+ });
9479
+ /** The refusal of a content, as BAD_DATA, with its reason and which content it was. */
9480
+ function contentRefused(reason, where) {
9481
+ return new CommsError("BAD_DATA", `nothing was prepared: ${CONTENT_WORDS[reason]}`, {
9482
+ 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.",
9483
+ details: {
9484
+ reason,
9485
+ ...where === void 0 ? {} : {
9486
+ kind: where.kind,
9487
+ position: where.position
9488
+ }
9489
+ }
9490
+ });
9491
+ }
9492
+ /** The digest of one content: its kind and exact text. */
9493
+ function standingContentDigest(kind, text) {
9494
+ return sha256Hex(canonicalJson$1({
9495
+ kind,
9496
+ text
9497
+ }));
9498
+ }
9499
+ /** A reaction's name, exactly as listed — `:name:` read as `name` — or the refusal of a custom one. */
9500
+ function reactionName(written, position) {
9501
+ const trimmed = written.trim();
9502
+ const name = /^:[^:]+:$/.test(trimmed) ? trimmed.slice(1, -1) : trimmed;
9503
+ if (!isStandardEmoji(name)) throw contentRefused("custom-emoji", {
9504
+ kind: "reaction",
9505
+ position
9506
+ });
9507
+ return name;
9508
+ }
9509
+ /** Each reason a reply's text is refused for, in the order checked: the first that applies is the one given. */
9510
+ const REPLY_RULES = Object.freeze([
9511
+ ["multi-line", (text) => /[\r\n\u2028\u2029]/.test(text)],
9512
+ ["pictograph", (text) => /[\p{Extended_Pictographic}\u2713]/u.test(text)],
9513
+ ["control-character", (text) => [...text].some((char) => char === " " || isDangerous(char.codePointAt(0) ?? 0))],
9514
+ ["mention", (text) => /@|<[@!#]/.test(text)],
9515
+ ["link", (text) => /:\/\/|www\.|mailto:|[A-Za-z0-9-]+\.[A-Za-z]{2,}/i.test(text)],
9516
+ ["placeholder", (text) => /[{}[\]]/.test(text)],
9517
+ ["markup", (text) => /[<>*_~`|]/.test(text)],
9518
+ ["emoji-code", (text) => /:[A-Za-z0-9_+-]+:/.test(text)]
9519
+ ]);
9520
+ /**
9521
+ * 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
9522
+ * approval as surely as ✅ is. Matched whole, in any case. Allowed, with a warning in the preview.
9523
+ */
9524
+ const APPROVAL_WORDS = Object.freeze([
9525
+ ["yes", /\byes\b/i],
9526
+ ["approved", /\bapproved\b/i],
9527
+ ["approve", /\bapprove\b/i],
9528
+ ["go ahead", /\bgo\s+ahead\b/i],
9529
+ ["lgtm", /\blgtm\b/i],
9530
+ ["ok", /\bok\b/i],
9531
+ ["done", /\bdone\b/i],
9532
+ ["confirmed", /\bconfirmed\b/i]
9533
+ ]);
9534
+ /** What a reply's reads-as-approval warning begins with: the preview shows it under that reply. */
9535
+ function replyWarningPrefix(text) {
9536
+ return `The reply "${text}" can read as approval:`;
9537
+ }
9538
+ /** S3's warning for a reply holding words that read as a yes, or null. */
9539
+ function replyReadsAsApproval(text) {
9540
+ const said = APPROVAL_WORDS.filter(([, pattern]) => pattern.test(text)).map(([word]) => `"${word}"`);
9541
+ if (said.length === 0) return null;
9542
+ return `${replyWarningPrefix(text)} it says ${said.join(", ")}. People read "OK" or "Done" on "can I deploy?" as a yes.`;
9543
+ }
9544
+ /** A reply's text, trimmed and checked (S3), or the refusal of the first rule it breaks. */
9545
+ function replyText(written, position) {
9546
+ const where = {
9547
+ kind: "reply",
9548
+ position
9549
+ };
9550
+ const text = written.trim();
9551
+ if (text === "") throw contentRefused("empty", where);
9552
+ for (const [reason, breaks] of REPLY_RULES) if (breaks(text)) throw contentRefused(reason, where);
9553
+ if ([...text].length > 200) throw contentRefused("too-long", where);
9554
+ return text;
9555
+ }
9556
+ /** S3's warning for an emoji that reads as approval, in the spec's words. */
9557
+ function readsAsApprovalWarning(name) {
9558
+ return `:${name}: can read as approval: people read ✅ on "can I deploy?" as a yes, and a Slack workflow can start on a reaction.`;
9559
+ }
9560
+ /**
9561
+ * The contents a person wrote, checked (S3): reactions first, then replies, each as written, each with its digest; and
9562
+ * the warnings the preview shows. With no content given at all, `eyes` alone. Refuses BAD_DATA, with `details.reason`,
9563
+ * for anything S3 does not allow.
9564
+ */
9565
+ function checkedContents(input) {
9566
+ const reactions = input.reactions ?? [];
9567
+ const replies = input.replies ?? [];
9568
+ if (reactions.length > 5) throw contentRefused("too-many-reactions");
9569
+ if (replies.length > 5) throw contentRefused("too-many-replies");
9570
+ const names = reactions.length === 0 && replies.length === 0 ? [STANDING_DEFAULT_REACTION] : reactions.map((written, index) => reactionName(written, index + 1));
9571
+ const texts = replies.map((written, index) => replyText(written, index + 1));
9572
+ const contents = [...names.map((text) => ({
9573
+ kind: "reaction",
9574
+ text,
9575
+ digest: standingContentDigest("reaction", text)
9576
+ })), ...texts.map((text) => ({
9577
+ kind: "reply",
9578
+ text,
9579
+ digest: standingContentDigest("reply", text)
9580
+ }))];
9581
+ if (new Set(contents.map((content) => content.digest)).size !== contents.length) throw contentRefused("duplicate");
9582
+ return {
9583
+ contents,
9584
+ warnings: [...names.filter((name) => READS_AS_APPROVAL.includes(name)).map(readsAsApprovalWarning), ...texts.map(replyReadsAsApproval).filter((warning) => warning !== null)]
9585
+ };
9586
+ }
9587
+ /** S6's limits, each from the table: its default when left out, and BAD_DATA outside its range. */
9588
+ function checkedLimits(input) {
9589
+ const limits = {};
9590
+ for (const name of LIMIT_NAMES$1) {
9591
+ const { default: fallback, min, max } = STANDING_LIMITS[name];
9592
+ const given = input[name];
9593
+ const value = given ?? fallback;
9594
+ 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}`, {
9595
+ hint: `Leave it out for ${fallback}, or give a whole number from ${min} to ${max}.`,
9596
+ details: {
9597
+ reason: "limit-out-of-range",
9598
+ limit: name,
9599
+ min,
9600
+ max,
9601
+ given: given ?? null
9602
+ }
9603
+ });
9604
+ limits[name] = value;
9605
+ }
9606
+ return limits;
9607
+ }
9608
+ /** The canonical form of a place, as the digest binds it. */
9609
+ function canonicalPlaces(places) {
9610
+ if (places.kind === "threads-i-am-in") return { kind: "threads-i-am-in" };
9611
+ return {
9612
+ kind: "exact",
9613
+ places: places.places.map((place) => ({
9614
+ id: place.id,
9615
+ name: place.name,
9616
+ members: place.members,
9617
+ shared: {
9618
+ extShared: place.shared.extShared,
9619
+ pendingExtShared: place.shared.pendingExtShared,
9620
+ externalMembers: place.shared.externalMembers
9621
+ }
9622
+ }))
9623
+ };
9624
+ }
9625
+ /**
9626
+ * The digest a standing record is bound to: everything its binding holds but the summary — the account, every content
9627
+ * with its digest in order, every place as recorded, the limits, who proposed it, what it replaces, and the warnings
9628
+ * the person was shown.
9629
+ */
9630
+ function standingDigest(binding) {
9631
+ return sha256Hex(canonicalJson$1({
9632
+ kind: "standing",
9633
+ target: {
9634
+ kind: binding.target.kind,
9635
+ name: binding.target.name,
9636
+ id: binding.target.id
9637
+ },
9638
+ contents: binding.contents.map((content) => ({
9639
+ kind: content.kind,
9640
+ text: content.text,
9641
+ digest: content.digest
9642
+ })),
9643
+ places: canonicalPlaces(binding.places),
9644
+ limits: {
9645
+ perThread: binding.limits.perThread,
9646
+ perHour: binding.limits.perHour,
9647
+ perDay: binding.limits.perDay,
9648
+ perSender: binding.limits.perSender,
9649
+ expiresDays: binding.limits.expiresDays
9650
+ },
9651
+ proposedBy: binding.proposedBy,
9652
+ replaces: binding.replaces ?? null,
9653
+ warnings: [...binding.warnings]
9654
+ }));
9655
+ }
9656
+ /** A narrowing that is not one: USAGE, saying what to do instead. */
9657
+ function notANarrowing(message) {
9658
+ 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." });
9659
+ }
9660
+ /**
9661
+ * The standing approval as it applies after `narrowed`: only the contents whose digests it keeps, only the exact
9662
+ * places it keeps, and the lower of each limit — so a narrowing never raises one, whatever it says. USAGE for a digest
9663
+ * or a place the approval does not hold, for keeping none, and for places kept from `threads-i-am-in`, which names none.
9664
+ */
9665
+ function effectiveStanding(binding, narrowed) {
9666
+ if (narrowed === void 0) return binding;
9667
+ let contents = binding.contents;
9668
+ if (narrowed.keep !== void 0) {
9669
+ const keep = new Set(narrowed.keep);
9670
+ 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");
9671
+ contents = binding.contents.filter((content) => keep.has(content.digest));
9672
+ if (contents.length === 0) throw notANarrowing("it would keep no content at all");
9673
+ }
9674
+ let places = binding.places;
9675
+ if (narrowed.places !== void 0) {
9676
+ if (binding.places.kind !== "exact") throw notANarrowing("threads-i-am-in names no places to keep; narrow its contents or limits, or revoke it");
9677
+ const keep = new Set(narrowed.places);
9678
+ const held = binding.places.places;
9679
+ for (const id of keep) if (!held.some((place) => place.id === id)) throw notANarrowing("it keeps a place this standing approval does not cover");
9680
+ const kept = held.filter((place) => keep.has(place.id));
9681
+ if (kept.length === 0) throw notANarrowing("it would keep no place at all");
9682
+ places = {
9683
+ kind: "exact",
9684
+ places: kept
9685
+ };
9686
+ }
9687
+ const limits = { ...binding.limits };
9688
+ for (const name of LIMIT_NAMES$1) {
9689
+ const lower = narrowed.limits?.[name];
9690
+ if (lower !== void 0 && Number.isFinite(lower)) limits[name] = Math.min(limits[name], lower);
9691
+ }
9692
+ return {
9693
+ ...binding,
9694
+ contents,
9695
+ places,
9696
+ limits
9697
+ };
9698
+ }
9699
+ /**
9700
+ * The narrowing `narrowing` makes of what is in force — `binding` as `narrowed` already leaves it — as the `narrowed` to
9701
+ * store beside `binding`: the contents and exact places kept, and every limit, as asked. Applies at once, so it may
9702
+ * only narrow (S8): USAGE for anything that would widen what is in force — a higher limit, a later expiry, a content
9703
+ * or a place not held — and BAD_DATA for a limit outside the table.
9704
+ */
9705
+ function narrowedFurther(binding, narrowed, narrowing) {
9706
+ const before = effectiveStanding(binding, narrowed);
9707
+ const kept = effectiveStanding(before, {
9708
+ ...narrowing.keep === void 0 ? {} : { keep: narrowing.keep },
9709
+ ...narrowing.places === void 0 ? {} : { places: narrowing.places }
9710
+ });
9711
+ const asked = { ...kept.limits };
9712
+ for (const name of LIMIT_NAMES$1) {
9713
+ const value = narrowing.limits?.[name];
9714
+ if (value !== void 0) asked[name] = value;
9715
+ }
9716
+ const after = {
9717
+ ...kept,
9718
+ limits: checkedLimits(asked)
9719
+ };
9720
+ if (classifyStandingChange(before, after) === "widens") throw new CommsError("USAGE", "nothing was narrowed: this would widen the standing approval, not narrow it", {
9721
+ 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.",
9722
+ details: { reason: "widens" }
9723
+ });
9724
+ return {
9725
+ keep: after.contents.map((content) => content.digest),
9726
+ ...after.places.kind === "exact" ? { places: after.places.places.map((place) => place.id) } : {},
9727
+ limits: { ...after.limits }
9728
+ };
9729
+ }
9730
+ /** How many days a standing approval lasts once approved, as narrowed: the lower of its own and the narrowing's. */
9731
+ function standingDays(binding, narrowed) {
9732
+ const lower = narrowed?.limits?.expiresDays;
9733
+ return typeof lower === "number" && Number.isFinite(lower) ? Math.min(binding.limits.expiresDays, lower) : binding.limits.expiresDays;
9734
+ }
9735
+ /** A Slack conversation a standing approval may name exactly: a channel, a private channel or group DM, or a DM. */
9736
+ const STANDING_PLACE_PATTERN = /^[CGD][A-Z0-9]{2,}$/;
9737
+ const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
9738
+ const isStrings$1 = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
9739
+ /** Why `shared` is not a recorded sharing state, or null. */
9740
+ function sharedProblem(shared) {
9741
+ if (!isRecord$3(shared)) return "a place has no sharing state";
9742
+ if (typeof shared.extShared !== "boolean" || typeof shared.pendingExtShared !== "boolean") return "a place has no sharing state";
9743
+ if (shared.externalMembers !== null && typeof shared.externalMembers !== "boolean") return "a place has no sharing state";
9744
+ return null;
9745
+ }
9746
+ /** Why `places` is not a standing approval's places, or null. */
9747
+ function placesProblem(places) {
9748
+ if (!isRecord$3(places)) return "it has no places";
9749
+ if (places.kind === "threads-i-am-in") return Object.keys(places).length === 1 ? null : "its places are not one kind";
9750
+ if (places.kind !== "exact" || !Array.isArray(places.places)) return "it has no places";
9751
+ if (places.places.length === 0) return "it names no place";
9752
+ const seen = /* @__PURE__ */ new Set();
9753
+ for (const place of places.places) {
9754
+ 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";
9755
+ if (seen.has(place.id)) return "a place is named twice";
9756
+ seen.add(place.id);
9757
+ if (place.name !== null && typeof place.name !== "string") return "a place has no name or null";
9758
+ if (!Number.isSafeInteger(place.members) || place.members < 0) return "a place has no member count";
9759
+ const shared = sharedProblem(place.shared);
9760
+ if (shared !== null) return shared;
9761
+ }
9762
+ return null;
9763
+ }
9764
+ /**
9765
+ * Why `binding` is not one a standing record may hold, or null: every content one `checkedContents` gives for its
9766
+ * texts, in order, with its digest; exactly the warnings those contents give (S3); every limit inside the table; places
9767
+ * 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
9768
+ * the validator a record that holds one, so nothing a caller assembles by hand — a custom emoji, a 31-day expiry, a
9769
+ * reply with a link — can become a standing approval.
9770
+ */
9771
+ function standingBindingProblem(binding) {
9772
+ if (!isRecord$3(binding)) return "it is not a standing approval";
9773
+ if (typeof binding.summary !== "string") return "it has no summary";
9774
+ const target = binding.target;
9775
+ if (!isRecord$3(target) || target.kind !== "account" || typeof target.name !== "string") return "it names no account";
9776
+ if (typeof target.id !== "string" || !ACCOUNT_ID_PATTERN.test(target.id)) return "it names no account by its id";
9777
+ if (!Array.isArray(binding.contents)) return "it has no contents";
9778
+ const written = binding.contents;
9779
+ if (!written.every((content) => isRecord$3(content) && typeof content.text === "string")) return "a content is unreadable";
9780
+ const contents = written;
9781
+ let checked;
9782
+ let warnings;
9783
+ try {
9784
+ ({contents: checked, warnings} = checkedContents({
9785
+ reactions: contents.filter((content) => content.kind === "reaction").map((content) => content.text),
9786
+ replies: contents.filter((content) => content.kind === "reply").map((content) => content.text)
9787
+ }));
9788
+ } catch {
9789
+ return "a content is not one a standing approval may hold";
9790
+ }
9791
+ if (contents.length === 0) return "it holds no content";
9792
+ const canonical = (list) => canonicalJson$1(list.map((content) => ({
9793
+ kind: content.kind,
9794
+ text: content.text,
9795
+ digest: content.digest
9796
+ })));
9797
+ if (canonical(checked) !== canonical(contents)) return "its contents are not as checked, in order, with their digests";
9798
+ const places = placesProblem(binding.places);
9799
+ if (places !== null) return places;
9800
+ if (!isRecord$3(binding.limits)) return "it has no limits";
9801
+ try {
9802
+ if (canonicalJson$1(checkedLimits(binding.limits)) !== canonicalJson$1(binding.limits)) return "its limits are not whole and complete";
9803
+ } catch {
9804
+ return "a limit is outside the table";
9805
+ }
9806
+ if (binding.proposedBy !== "agent" && binding.proposedBy !== "person") return "it does not say who proposed it";
9807
+ if (binding.replaces !== void 0 && (typeof binding.replaces !== "string" || !APPROVAL_ID_PATTERN.test(binding.replaces))) return "what it replaces is not an approval id";
9808
+ if (!isStrings$1(binding.warnings)) return "its warnings are unreadable";
9809
+ if (canonicalJson$1(binding.warnings) !== canonicalJson$1(warnings)) return "its warnings are not the ones its contents give";
9810
+ return null;
9811
+ }
9812
+ /** A copy of `binding` holding exactly its own fields, or BAD_DATA naming why it is not one (`standingBindingProblem`). */
9813
+ function canonicalStandingBinding(binding) {
9814
+ const problem = standingBindingProblem(binding);
9815
+ if (problem !== null) throw new CommsError("BAD_DATA", `nothing was prepared: ${problem}`, {
9816
+ hint: "This is a bug — please report it.",
9817
+ details: { reason: "binding-invalid" }
9818
+ });
9819
+ return {
9820
+ summary: binding.summary,
9821
+ target: {
9822
+ kind: "account",
9823
+ name: binding.target.name,
9824
+ id: binding.target.id
9825
+ },
9826
+ contents: binding.contents.map((content) => ({
9827
+ kind: content.kind,
9828
+ text: content.text,
9829
+ digest: content.digest
9830
+ })),
9831
+ places: binding.places.kind === "threads-i-am-in" ? { kind: "threads-i-am-in" } : {
9832
+ kind: "exact",
9833
+ places: binding.places.places.map((place) => ({
9834
+ id: place.id,
9835
+ name: place.name,
9836
+ members: place.members,
9837
+ shared: {
9838
+ extShared: place.shared.extShared,
9839
+ pendingExtShared: place.shared.pendingExtShared,
9840
+ externalMembers: place.shared.externalMembers
9841
+ }
9842
+ }))
9843
+ },
9844
+ limits: { ...binding.limits },
9845
+ proposedBy: binding.proposedBy,
9846
+ ...binding.replaces === void 0 ? {} : { replaces: binding.replaces },
9847
+ warnings: [...binding.warnings]
9848
+ };
9849
+ }
9850
+ /**
9851
+ * Why `narrowed`, stored beside `binding`, is not a narrowing of it, or null: each key what a narrowing holds, each
9852
+ * content and place one the binding has, and no limit above the binding's or outside the table. Not bound into the
9853
+ * record's digest, since it changes after approval; only ever able to narrow, so a hand that edits it can only take away.
9854
+ */
9855
+ function standingNarrowingProblem(binding, narrowed) {
9856
+ if (narrowed === void 0) return null;
9857
+ if (!isRecord$3(narrowed)) return "it is not a narrowing";
9858
+ for (const key of Object.keys(narrowed)) if (![
9859
+ "keep",
9860
+ "places",
9861
+ "limits"
9862
+ ].includes(key)) return "it is not a narrowing";
9863
+ if (narrowed.keep !== void 0 && !isStrings$1(narrowed.keep)) return "it keeps something that is not a digest";
9864
+ if (narrowed.places !== void 0 && !isStrings$1(narrowed.places)) return "it keeps something that is not a place";
9865
+ if (narrowed.limits !== void 0) {
9866
+ if (!isRecord$3(narrowed.limits)) return "its limits are unreadable";
9867
+ for (const [name, value] of Object.entries(narrowed.limits)) {
9868
+ if (!LIMIT_NAMES$1.includes(name)) return "it narrows a limit there is none of";
9869
+ const limit = name;
9870
+ if (!Number.isSafeInteger(value) || value < STANDING_LIMITS[limit].min) return "a limit is outside the table";
9871
+ if (value > binding.limits[limit]) return "it raises a limit";
9872
+ }
9873
+ }
9874
+ try {
9875
+ effectiveStanding(binding, narrowed);
9876
+ } catch {
9877
+ return "it keeps what the standing approval does not hold";
9878
+ }
9879
+ return null;
9880
+ }
9881
+ /** Whether a place recorded as `after` is one `before` approved: the same id, shared the same, and no bigger a room. */
9882
+ function placeCovered(before, after) {
9883
+ const held = before.find((place) => place.id === after.id);
9884
+ if (held === void 0) return false;
9885
+ if (canonicalJson$1(held.shared) !== canonicalJson$1(after.shared)) return false;
9886
+ return after.members < 50 || held.members >= 50;
9887
+ }
9888
+ /**
9889
+ * What `after` does to `before` (S8): `widens` with any content digest or exact place `before` does not hold — a place
9890
+ * shared differently, or grown to 50 or more members, is not the place it approved — a swap between exact places and
9891
+ * `threads-i-am-in` either way, another account, any higher limit, or a later expiry; `same` when it permits exactly
9892
+ * what `before` does; anything else that differs `narrows`. Unclear is a widening: it costs a person a code, never a
9893
+ * yes they did not give.
9894
+ */
9895
+ function classifyStandingChange(before, after) {
9896
+ if (before.target.id !== after.target.id) return "widens";
9897
+ const held = new Set(before.contents.map((content) => content.digest));
9898
+ if (after.contents.some((content) => !held.has(content.digest))) return "widens";
9899
+ if (before.places.kind !== after.places.kind) return "widens";
9900
+ if (before.places.kind === "exact" && after.places.kind === "exact") {
9901
+ const approved = before.places.places;
9902
+ if (after.places.places.some((place) => !placeCovered(approved, place))) return "widens";
9903
+ }
9904
+ for (const name of LIMIT_NAMES$1) if (after.limits[name] > before.limits[name]) return "widens";
9905
+ const kept = new Set(after.contents.map((content) => content.digest));
9906
+ const fewerContents = before.contents.some((content) => !kept.has(content.digest));
9907
+ const afterPlaces = after.places.kind === "exact" ? after.places.places : [];
9908
+ const fewerPlaces = before.places.kind === "exact" && before.places.places.some((place) => !afterPlaces.some((now) => now.id === place.id));
9909
+ const lower = LIMIT_NAMES$1.some((name) => after.limits[name] < before.limits[name]);
9910
+ return fewerContents || fewerPlaces || lower ? "narrows" : "same";
9911
+ }
9912
+ Object.freeze({
9913
+ "not-fresh": "the message is older than 15 minutes, or than this standing approval",
9914
+ "not-newest": "the message is not the newest from someone else in its thread or channel",
9915
+ "already-acknowledged": "this message or thread was already acknowledged under a standing approval",
9916
+ "not-human": "the message, or its thread's first message, is not from a person",
9917
+ interactive: "the message has buttons or menus, or came from a workflow",
9918
+ "place-not-covered": "this standing approval does not cover this place",
9919
+ "big-room": "this place has 50 or more members, and was not approved with that count",
9920
+ "count-unreadable": "how many people are in this place could not be read",
9921
+ external: "someone from another organisation is in this place",
9922
+ "shared-state-changed": "who shares this place changed since it was approved",
9923
+ "reaction-as-person": "a standing reaction goes only as the agent app, and here it would go as you",
9924
+ "app-not-member": "the agent app is not a member of this place, or whether it is could not be read",
9925
+ "content-not-held": "this standing approval holds no such content",
9926
+ "clock-behind": "the clock is earlier than this account's last standing act",
9927
+ unreadable: "the message or its thread could not be read"
9928
+ });
9929
+ Object.freeze({
9930
+ perThread: "per thread",
9931
+ perHour: "an hour",
9932
+ perDay: "a day",
9933
+ perSender: "a day on messages from one person"
9934
+ });
9935
+ /**
9244
9936
  * The digest a download's question is bound to: the account, the download, the request, the files it listed and the
9245
9937
  * names it showed them under.
9246
9938
  *
@@ -9387,6 +10079,19 @@ function refuseDisclosure(code, reason, record, hint) {
9387
10079
  } : {}
9388
10080
  });
9389
10081
  }
10082
+ /**
10083
+ * The same refusal for a standing approval, which sends only through its children: nothing was sent under it. Its hint
10084
+ * never names a command that makes or widens one — only the person does that, at their terminal.
10085
+ */
10086
+ function refuseStanding(code, reason, record, hint) {
10087
+ return new CommsError(code, `nothing was sent: ${reason}`, {
10088
+ hint: hint ?? "This standing approval cannot be used. Ask the person instead; only they can set up another, at their own terminal.",
10089
+ details: record ? {
10090
+ approvalId: record.approvalId,
10091
+ state: record.state
10092
+ } : {}
10093
+ });
10094
+ }
9390
10095
  /** The refusal in the words of the record's own kind. */
9391
10096
  function refusalFor(record) {
9392
10097
  switch (approvalKind(record)) {
@@ -9394,6 +10099,7 @@ function refusalFor(record) {
9394
10099
  case "change": return refuseChange;
9395
10100
  case "download": return refuseDownload;
9396
10101
  case "disclosure": return refuseDisclosure;
10102
+ case "standing": return refuseStanding;
9397
10103
  }
9398
10104
  }
9399
10105
  /**
@@ -9444,6 +10150,14 @@ function cancelledClaim(record) {
9444
10150
  reason: "cancelled"
9445
10151
  }
9446
10152
  });
10153
+ case "standing": return new CommsError("USAGE", "cancelled: nothing was sent", {
10154
+ hint: "The standing approval was not used: it is as it was.",
10155
+ details: {
10156
+ approvalId: record.approvalId,
10157
+ state: record.state,
10158
+ reason: "cancelled"
10159
+ }
10160
+ });
9447
10161
  }
9448
10162
  }
9449
10163
  /** 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 +10277,11 @@ var ApprovalStore = class {
9563
10277
  reason: CLOCK_ANOMALY,
9564
10278
  expiredAt: observed.toISOString()
9565
10279
  };
9566
- const deadline = record.state === "approved" && record.kind !== "download" && record.usableUntil !== void 0 ? record.usableUntil : record.expiresAt;
10280
+ let deadline = record.state === "approved" && record.kind !== "download" && record.usableUntil !== void 0 ? record.usableUntil : record.expiresAt;
10281
+ if (record.kind === "standing" && record.state === "approved" && record.approvedAt !== void 0 && record.standing) {
10282
+ const narrowed = Date.parse(record.approvedAt) + standingDays(record.standing, record.narrowed) * STANDING_DAY_MS;
10283
+ if (narrowed < Date.parse(deadline)) deadline = new Date(narrowed).toISOString();
10284
+ }
9567
10285
  if (now >= Date.parse(deadline)) return {
9568
10286
  ...record,
9569
10287
  state: "expired",
@@ -9731,6 +10449,7 @@ var ApprovalStore = class {
9731
10449
  const objectOf = (record) => approvalObjectOf(record, live, this.#now());
9732
10450
  let next;
9733
10451
  try {
10452
+ await options.before?.(current, outcome);
9734
10453
  next = decide(current, outcome, live);
9735
10454
  } catch (error) {
9736
10455
  throw withApproval(error, objectOf(current));
@@ -9771,7 +10490,7 @@ var ApprovalStore = class {
9771
10490
  usedSaid: options.usedSaid
9772
10491
  });
9773
10492
  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;
10493
+ const current = outcome.revokes && outcome.record !== null && (action !== "inspect" && action !== "wait" || writtenAtALook(outcome)) && outcome.record !== null ? outcome.record : derived.record;
9775
10494
  if (current.state !== found.record.state) await this.#write({
9776
10495
  ...current,
9777
10496
  updatedAt: this.#now().toISOString()
@@ -9855,6 +10574,10 @@ var ApprovalStore = class {
9855
10574
  hint: "Nothing more is needed from the person: complete the same activation with the same approval.",
9856
10575
  details: detail
9857
10576
  });
10577
+ case "standing": return new CommsError("USAGE", "nothing was sent: it is approved already, and a person approves it once", {
10578
+ hint: "Nothing more is needed from the person: the standing approval is in force until it ends.",
10579
+ details: detail
10580
+ });
9858
10581
  }
9859
10582
  }
9860
10583
  /** Issues a new challenge to show a human; only its hash is kept. */
@@ -10015,7 +10738,7 @@ var ApprovalStore = class {
10015
10738
  async approve(approvalId, via, live, answer, kind = "send", _platform = process.platform, options = {}) {
10016
10739
  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
10740
  let failure = null;
10018
- const done = await this.#transition(approvalId, { kind }, "approve", (current, outcome) => {
10741
+ const done = await this.#transition(approvalId, { kind }, "approve", (current, outcome, gate) => {
10019
10742
  if (outcome.error) throw outcome.error;
10020
10743
  if (current.state !== "pending") throw this.#alreadyApproved(current, outcome);
10021
10744
  if (!current.challengeHash) throw refusalFor(current)("APPROVAL_REQUIRED", "no challenge was issued for this approval", current);
@@ -10032,10 +10755,22 @@ var ApprovalStore = class {
10032
10755
  revokedAt: at.toISOString()
10033
10756
  };
10034
10757
  }
10758
+ if (kind === "standing" && via !== "terminal") {
10759
+ failure = {
10760
+ code: "APPROVAL_VOID",
10761
+ reason: "a standing approval is approved only with its code at a terminal, and this was not approved at a terminal"
10762
+ };
10763
+ return {
10764
+ ...current,
10765
+ state: "revoked",
10766
+ reason: failure.reason,
10767
+ revokedAt: at.toISOString()
10768
+ };
10769
+ }
10035
10770
  if (live.draftMessageId !== current.draftMessageId || live.contentDigest !== current.contentDigest) {
10036
10771
  failure = {
10037
10772
  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"
10773
+ 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
10774
  };
10040
10775
  return {
10041
10776
  ...current,
@@ -10075,7 +10810,8 @@ var ApprovalStore = class {
10075
10810
  approvedAt: at.toISOString(),
10076
10811
  usableUntil: new Date(at.getTime() + (current.approvedMs ?? APPROVAL_LIFETIMES.approved)).toISOString(),
10077
10812
  approvedBindingDigest: current.bindingDigest,
10078
- challengeHash: void 0
10813
+ challengeHash: void 0,
10814
+ ...kind === "standing" ? { approvedChangePolicy: gate?.changePolicy ?? "chat" } : {}
10079
10815
  };
10080
10816
  }, { usedSaid: options.usedSaid });
10081
10817
  const failed = failure;
@@ -10094,6 +10830,7 @@ var ApprovalStore = class {
10094
10830
  */
10095
10831
  async claimForSend(approvalId, live, options = {}) {
10096
10832
  let failure = null;
10833
+ let parentProblem = null;
10097
10834
  const done = await this.#transition(approvalId, {
10098
10835
  kind: "send",
10099
10836
  owner: live.inboxId
@@ -10114,6 +10851,7 @@ var ApprovalStore = class {
10114
10851
  challengeHash: void 0
10115
10852
  };
10116
10853
  };
10854
+ if (parentProblem !== null) return voidWith("APPROVAL_VOID", parentProblem);
10117
10855
  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
10856
  if (live.draftMessageId !== current.draftMessageId) return voidWith("APPROVAL_VOID", "the draft was edited after the preview");
10119
10857
  if (live.contentDigest !== current.contentDigest) return voidWith("APPROVAL_VOID", "the draft content changed after the preview");
@@ -10125,7 +10863,12 @@ var ApprovalStore = class {
10125
10863
  state: "sending",
10126
10864
  sendingAt: at
10127
10865
  };
10128
- }, { usedSaid: options.usedSaid });
10866
+ }, {
10867
+ usedSaid: options.usedSaid,
10868
+ before: async (current, outcome) => {
10869
+ if (outcome.error === void 0 && current.route === "standing" && current.state === "approved") parentProblem = await this.#standingParentProblem(current);
10870
+ }
10871
+ });
10129
10872
  const failed = failure;
10130
10873
  if (failed) throw refusedAfter(refuse$1(failed.code, failed.reason, done.record), done);
10131
10874
  const claimToken = randomBytes(16).toString("hex");
@@ -10138,6 +10881,31 @@ var ApprovalStore = class {
10138
10881
  };
10139
10882
  }
10140
10883
  /**
10884
+ * Why a standing child may not be claimed now, or null (design 2026-10-19-standing-approvals, S9, the lead's call after
10885
+ * review): its standing approval read again, under that one's own lock — ended by any of the store's rules, an epoch
10886
+ * or a tightened change policy among them, or narrowed so that it no longer holds the child's content or covers its
10887
+ * place. Called with the child's lock held: no lock is ever taken the other way round.
10888
+ */
10889
+ async #standingParentProblem(child) {
10890
+ const use = child.standingUse;
10891
+ if (child.standingId === void 0 || use === void 0) return "it names no standing approval it is a use of";
10892
+ let seen;
10893
+ try {
10894
+ seen = await this.inspect(child.standingId, {
10895
+ kind: "standing",
10896
+ owner: child.inboxId
10897
+ }, { action: "claim" });
10898
+ } catch {
10899
+ return "its standing approval could not be read";
10900
+ }
10901
+ const parent = seen.outcome.record;
10902
+ 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})`}`;
10903
+ const inForce = effectiveStanding(parent.standing, parent.narrowed);
10904
+ if (!inForce.contents.some((content) => content.digest === use.content)) return "its standing approval no longer holds what it sends";
10905
+ if (inForce.places.kind === "exact" && !inForce.places.places.some((place) => place.id === use.channel)) return "its standing approval no longer covers where it goes";
10906
+ return null;
10907
+ }
10908
+ /**
10141
10909
  * The file system's O_EXCL is the single-use guarantee, independent of the lock. A send's marker also holds its
10142
10910
  * claim token, the one thing that lets the claimant — and nobody else — renew its lease and record its outcome.
10143
10911
  */
@@ -10299,6 +11067,208 @@ var ApprovalStore = class {
10299
11067
  return done.record;
10300
11068
  }
10301
11069
  /**
11070
+ * A standing approval (design 2026-10-19-standing-approvals, S8), pending, bound to `standingDigest` of its binding —
11071
+ * computed here, never taken from the caller — and to the send epoch of its account and the standing epoch, both as
11072
+ * read when it is made.
11073
+ *
11074
+ * Its route is `confirm` and its `requiredPolicy` `confirm` whatever the change policy is, because what it permits
11075
+ * happens with nobody asked: it is approved only with the code typed at a terminal (`approve`, `via: 'terminal'`),
11076
+ * never by a yes in the chat, a client's form, or an answer carried in a call. It lasts its days once approved
11077
+ * (`approvedMs`). Nothing ever claims it: each use mints a child (`createStandingChild`).
11078
+ *
11079
+ * The binding is checked before anything is written (`canonicalStandingBinding`): a custom emoji, a reply with a
11080
+ * link, a limit outside the table or a place that is no Slack id is refused, however the caller assembled it.
11081
+ */
11082
+ async createStanding(input) {
11083
+ const standing = canonicalStandingBinding(input.binding);
11084
+ 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." });
11085
+ requireEpochs(input.sendEpoch, input.standingEpoch);
11086
+ requireKnownChannel("slack");
11087
+ await this.ensurePruned();
11088
+ const now = this.#now();
11089
+ const digest = standingDigest(standing);
11090
+ const pendingMs = APPROVAL_LIFETIMES.confirm;
11091
+ const record = this.#bound({
11092
+ approvalId: newApprovalId(),
11093
+ kind: "standing",
11094
+ digestVersion: 2,
11095
+ channel: "slack",
11096
+ ownerScope: "owner",
11097
+ route: "confirm",
11098
+ pendingMs,
11099
+ approvedMs: standing.limits.expiresDays * STANDING_DAY_MS,
11100
+ inboxId: standing.target.id,
11101
+ inboxSub: input.inboxSub,
11102
+ draftId: "standing",
11103
+ draftMessageId: digest,
11104
+ contentDigest: digest,
11105
+ sendEpoch: input.sendEpoch,
11106
+ standingEpoch: input.standingEpoch,
11107
+ policy: "confirm",
11108
+ requiredPolicy: "confirm",
11109
+ riskFlags: [],
11110
+ expect: {
11111
+ to: [],
11112
+ cc: [],
11113
+ bcc: [],
11114
+ subject: standing.summary
11115
+ },
11116
+ challengeAttempts: 0,
11117
+ state: "pending",
11118
+ createdAt: now.toISOString(),
11119
+ expiresAt: new Date(now.getTime() + pendingMs).toISOString(),
11120
+ updatedAt: now.toISOString(),
11121
+ standing
11122
+ });
11123
+ await this.#write(record);
11124
+ return record;
11125
+ }
11126
+ /**
11127
+ * Runs `use` under the standing record's own lock, with the record as it reads there and its classification for a
11128
+ * use (`claim`): its expiry, a revocation the configuration derives — its account removed, `never`, a send epoch or
11129
+ * a standing epoch behind the live one — written before `use` runs, and `outcome.error` set for every state that
11130
+ * refuses a use. `owner` is the account the caller acts for: any other's record is the one `NOT_FOUND`.
11131
+ *
11132
+ * What `use` decides is decided while no revocation, narrowing or approval of this record can land. `use` must not
11133
+ * take this record's lock again: it is held.
11134
+ */
11135
+ async withStanding(standingId, owner, use) {
11136
+ const path = this.#path(standingId);
11137
+ const expect = {
11138
+ kind: "standing",
11139
+ owner
11140
+ };
11141
+ return withFileLock(`${path}.lock`, async () => {
11142
+ const config = await this.#config();
11143
+ const found = await this.#read(standingId, config);
11144
+ if (found === null || !matchesExpectation(found, expect)) throw approvalNotFound(standingId, "standing");
11145
+ const derived = this.#derived(found);
11146
+ const outcome = approvalOutcome(derived, {
11147
+ action: "claim",
11148
+ live: config === null || derived.form !== "v2" ? null : liveGateOf(config, derived.record),
11149
+ now: this.#now()
11150
+ });
11151
+ if (found.form !== "v2" || derived.form !== "v2" || outcome.record === null) throw outcome.error ?? integrityRefusal(found);
11152
+ const current = outcome.revokes ? outcome.record : derived.record;
11153
+ if (current.state !== found.record.state) await this.#write({
11154
+ ...current,
11155
+ updatedAt: this.#now().toISOString()
11156
+ });
11157
+ return use(current, outcome);
11158
+ });
11159
+ }
11160
+ /**
11161
+ * One use of a standing approval (S9): a send record, approved the moment it is made — `route: 'standing'`,
11162
+ * `approvedVia: 'standing'`, `approvedAt` its creation — bound to the standing id, the standing epoch and the send
11163
+ * epoch, and usable once, within `APPROVAL_LIFETIMES.standingChild`. It then takes the one send path every send
11164
+ * takes: `claimForSend`, its O_EXCL marker, the fence and the outcome.
11165
+ *
11166
+ * Minted only by the use (`mintStandingUse`), under the account's acknowledgement ledger lock and then the standing
11167
+ * record's, after every check there. Checked again here, read without that lock — which the caller holds: the
11168
+ * standing record must be this account's, approved at a terminal, under the same epochs and the same account user,
11169
+ * and not ended, or nothing is written. A child never names anything but what its use was checked for.
11170
+ */
11171
+ async createStandingChild(input) {
11172
+ 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." });
11173
+ 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." });
11174
+ requireEpochs(input.sendEpoch, input.standingEpoch);
11175
+ requireKnownChannel("slack");
11176
+ const config = await this.#config();
11177
+ if (input.approvalId !== void 0) {
11178
+ 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." });
11179
+ }
11180
+ const found = await this.#read(input.standingId, config);
11181
+ if (found === null || !matchesExpectation(found, {
11182
+ kind: "standing",
11183
+ owner: input.inboxId
11184
+ })) throw approvalNotFound(input.standingId, "standing");
11185
+ if (found.form !== "v2") throw integrityRefusal(found);
11186
+ const parent = this.#derive(found.record);
11187
+ const live = config === null ? null : liveGateOf(config, parent);
11188
+ const outcome = approvalOutcome({
11189
+ form: "v2",
11190
+ record: parent
11191
+ }, {
11192
+ action: "claim",
11193
+ live,
11194
+ now: this.#now()
11195
+ });
11196
+ if (outcome.error) throw outcome.error;
11197
+ const standing = outcome.record ?? parent;
11198
+ if (standing.state === "pending") throw refuseStanding("APPROVAL_PENDING", "the standing approval waits for the person to approve it at a terminal", standing);
11199
+ 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;
11200
+ if (unbound !== null) throw refuseStanding("APPROVAL_VOID", unbound, standing);
11201
+ const now = this.#now();
11202
+ const at = now.toISOString();
11203
+ const lifetime = APPROVAL_LIFETIMES.standingChild;
11204
+ const until = new Date(now.getTime() + lifetime).toISOString();
11205
+ const sealed = this.#bound({
11206
+ approvalId: input.approvalId ?? newApprovalId(),
11207
+ kind: "send",
11208
+ digestVersion: 2,
11209
+ channel: "slack",
11210
+ ownerScope: "owner",
11211
+ route: "standing",
11212
+ pendingMs: lifetime,
11213
+ approvedMs: lifetime,
11214
+ inboxId: input.inboxId,
11215
+ inboxSub: input.inboxSub,
11216
+ draftId: input.draftId,
11217
+ draftMessageId: input.draftMessageId,
11218
+ contentDigest: input.contentDigest,
11219
+ sendEpoch: input.sendEpoch,
11220
+ standingEpoch: input.standingEpoch,
11221
+ standingId: input.standingId,
11222
+ standingUse: {
11223
+ channel: input.use.channel,
11224
+ thread: input.use.thread,
11225
+ content: input.use.content
11226
+ },
11227
+ policy: live?.sendPolicy === "chat" ? "chat" : "confirm",
11228
+ requiredPolicy: "confirm",
11229
+ riskFlags: [...input.riskFlags],
11230
+ expect: {
11231
+ to: [...input.expect.to],
11232
+ cc: [...input.expect.cc],
11233
+ bcc: [...input.expect.bcc],
11234
+ subject: input.expect.subject
11235
+ },
11236
+ challengeAttempts: 0,
11237
+ state: "approved",
11238
+ createdAt: at,
11239
+ expiresAt: until,
11240
+ updatedAt: at,
11241
+ approvedVia: "standing",
11242
+ approvedAt: at,
11243
+ usableUntil: until
11244
+ });
11245
+ const record = {
11246
+ ...sealed,
11247
+ approvedBindingDigest: sealed.bindingDigest
11248
+ };
11249
+ await this.#write(record);
11250
+ return record;
11251
+ }
11252
+ /**
11253
+ * Narrows a standing approval at once (S8): fewer contents or places, a lower limit, an earlier end. Nothing is asked
11254
+ * for, because nothing more is permitted: anything `classifyStandingChange` calls a widening of what is in force is
11255
+ * refused with USAGE, and the record is left as it was. The binding the person approved stays the record's own, and
11256
+ * what is narrowed is kept beside it, where it can only take away.
11257
+ */
11258
+ async narrowStanding(standingId, owner, narrowing) {
11259
+ return (await this.#transition(standingId, {
11260
+ kind: "standing",
11261
+ owner
11262
+ }, "revoke", (current, outcome) => {
11263
+ if (outcome.error) throw outcome.error;
11264
+ 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);
11265
+ return {
11266
+ ...current,
11267
+ narrowed: narrowedFurther(current.standing, current.narrowed, narrowing)
11268
+ };
11269
+ })).record;
11270
+ }
11271
+ /**
10302
11272
  * A download's question, pending, bound to `downloadDigest(input.download)` — computed here, never taken from the
10303
11273
  * caller, as a change's digest is. It stands in for the draft revision too.
10304
11274
  *
@@ -10583,13 +11553,28 @@ var ApprovalStore = class {
10583
11553
  ...derived.record,
10584
11554
  updatedAt: this.#now().toISOString()
10585
11555
  });
11556
+ const outcome = approvalOutcome(derived, {
11557
+ action: "inspect",
11558
+ live: config === null || derived.form !== "v2" ? null : liveGateOf(config, derived.record),
11559
+ now: this.#now()
11560
+ });
11561
+ if (writtenAtALook(outcome) && outcome.record !== null) {
11562
+ const ended = {
11563
+ ...outcome.record,
11564
+ updatedAt: this.#now().toISOString()
11565
+ };
11566
+ await this.#write(ended);
11567
+ return {
11568
+ stored: {
11569
+ form: "v2",
11570
+ record: ended
11571
+ },
11572
+ outcome
11573
+ };
11574
+ }
10586
11575
  return {
10587
11576
  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
- })
11577
+ outcome
10593
11578
  };
10594
11579
  });
10595
11580
  if (seen === null) continue;
@@ -10628,6 +11613,10 @@ var ApprovalStore = class {
10628
11613
  return found.sort(byCreation);
10629
11614
  }
10630
11615
  };
11616
+ /** Both epochs a standing record or child binds: whole numbers of 0 or more, or a programming error. */
11617
+ function requireEpochs(sendEpoch, standingEpoch) {
11618
+ 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." });
11619
+ }
10631
11620
  /** Oldest first; a record that cannot be read has no time to be ordered by, and follows, by id. */
10632
11621
  function byCreation(a, b) {
10633
11622
  const createdOf = (stored) => stored.form === "v2" ? stored.record.createdAt : stored.form === "legacy" ? stored.view.createdAt : null;
@@ -10665,7 +11654,7 @@ function sameExpectation(a, b) {
10665
11654
  const CLOCK_ANOMALY = "clock-anomaly";
10666
11655
  const HEX64 = /^[0-9a-f]{64}$/;
10667
11656
  /** 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$/;
11657
+ const ISO$2 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
10669
11658
  const STATES$1 = [
10670
11659
  "pending",
10671
11660
  "approved",
@@ -10676,7 +11665,10 @@ const STATES$1 = [
10676
11665
  "expired",
10677
11666
  "revoked"
10678
11667
  ];
10679
- /** The states each kind can reach. A change or a download is claimed straight to `used`: it is never sent. */
11668
+ /**
11669
+ * The states each kind can reach. A change or a download is claimed straight to `used`: it is never sent. A standing
11670
+ * approval is never claimed at all — its children are — so it ends expired or revoked.
11671
+ */
10680
11672
  const STATES_OF = {
10681
11673
  send: STATES$1,
10682
11674
  change: [
@@ -10692,8 +11684,24 @@ const STATES_OF = {
10692
11684
  "used",
10693
11685
  "expired",
10694
11686
  "revoked"
11687
+ ],
11688
+ standing: [
11689
+ "pending",
11690
+ "approved",
11691
+ "expired",
11692
+ "revoked"
10695
11693
  ]
10696
11694
  };
11695
+ /** A standing approval's child, approved when it is made: never pending. */
11696
+ const CHILD_STATES = [
11697
+ "approved",
11698
+ "sending",
11699
+ "used",
11700
+ "failed",
11701
+ "unknown",
11702
+ "expired",
11703
+ "revoked"
11704
+ ];
10697
11705
  /** The send states that went through a claim, and so carry `sendingAt`. */
10698
11706
  const CLAIMED_SEND = [
10699
11707
  "sending",
@@ -10701,10 +11709,14 @@ const CLAIMED_SEND = [
10701
11709
  "failed",
10702
11710
  "unknown"
10703
11711
  ];
11712
+ /** Whether a record is one use of a standing approval: a send on the `standing` route. */
11713
+ function isChild(raw) {
11714
+ return raw.kind === "send" && raw.route === "standing";
11715
+ }
10704
11716
  /** A record's timestamp field: absent, invalid, or the time it names. */
10705
11717
  function timeOf(value) {
10706
11718
  if (value === void 0) return null;
10707
- if (typeof value !== "string" || !ISO$1.test(value)) return "invalid";
11719
+ if (typeof value !== "string" || !ISO$2.test(value)) return "invalid";
10708
11720
  const at = Date.parse(value);
10709
11721
  return Number.isFinite(at) && new Date(at).toISOString() === value ? at : "invalid";
10710
11722
  }
@@ -10751,7 +11763,7 @@ function checkVerified(record, raw) {
10751
11763
  if (raw.digestVersion !== 2) return fail("digest-version");
10752
11764
  const kind = raw.kind;
10753
11765
  if (kind === "disclosure") return checkDisclosure(record, raw);
10754
- if (kind !== "send" && kind !== "change" && kind !== "download") return fail("wrong-shape");
11766
+ if (kind !== "send" && kind !== "change" && kind !== "download" && kind !== "standing") return fail("wrong-shape");
10755
11767
  if (!STATES$1.includes(raw.state)) return fail("state-impossible");
10756
11768
  const state = raw.state;
10757
11769
  if (typeof raw.contentDigest !== "string" || !HEX64.test(raw.contentDigest)) return fail("content-digest-malformed");
@@ -10761,10 +11773,16 @@ function checkVerified(record, raw) {
10761
11773
  if (kind === "download") {
10762
11774
  if (record.download === void 0 || downloadDigest(record.download) !== record.contentDigest) return fail("content-digest-mismatch");
10763
11775
  }
11776
+ if (kind === "standing") {
11777
+ if (record.standing === void 0 || standingBindingProblem(record.standing) !== null) return fail("wrong-shape");
11778
+ if (standingDigest(record.standing) !== record.contentDigest) return fail("content-digest-mismatch");
11779
+ if (standingNarrowingProblem(record.standing, raw.narrowed) !== null) return fail("narrowing-invalid");
11780
+ } else if (raw.standing !== void 0 || raw.narrowed !== void 0) return fail("wrong-shape");
10764
11781
  const identity = checkIdentity(record, raw, kind);
10765
11782
  if (identity !== null) return identity;
10766
11783
  if (!STATES_OF[kind].includes(state)) return fail("state-impossible");
10767
- const lifetime = checkLifetime(raw, kind);
11784
+ if (isChild(raw) && !CHILD_STATES.includes(state)) return fail("state-impossible");
11785
+ const lifetime = checkLifetime(record, raw, kind);
10768
11786
  if (lifetime !== null) return lifetime;
10769
11787
  const times = {};
10770
11788
  for (const field of TIMESTAMPS) {
@@ -10839,9 +11857,19 @@ function checkIdentity(record, raw, kind) {
10839
11857
  if (scope !== expected) return fail("identity-contradiction");
10840
11858
  if (raw.inboxId !== (expected === "owner" ? target?.id : "")) return fail("identity-contradiction");
10841
11859
  }
10842
- if (kind === "send") {
10843
- if (typeof raw.sendEpoch !== "number" || !Number.isInteger(raw.sendEpoch) || raw.sendEpoch < 0) return fail("identity-contradiction");
11860
+ const wholeEpoch = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
11861
+ if (kind === "send" || kind === "standing") {
11862
+ if (!wholeEpoch(raw.sendEpoch)) return fail("identity-contradiction");
10844
11863
  } else if (raw.sendEpoch !== void 0) return fail("identity-contradiction");
11864
+ if (kind === "standing" || isChild(raw)) {
11865
+ if (raw.channel !== "slack" || !wholeEpoch(raw.standingEpoch)) return fail("identity-contradiction");
11866
+ } else if (raw.standingEpoch !== void 0) return fail("identity-contradiction");
11867
+ if (isChild(raw)) {
11868
+ if (typeof raw.standingId !== "string" || !APPROVAL_ID_PATTERN.test(raw.standingId)) return fail("identity-contradiction");
11869
+ if (raw.standingId === raw.approvalId) return fail("identity-contradiction");
11870
+ if (standingUseProblem(raw.standingUse) !== null) return fail("identity-contradiction");
11871
+ } else if (raw.standingId !== void 0 || raw.standingUse !== void 0) return fail("identity-contradiction");
11872
+ if (kind === "standing" && record.standing.target.id !== raw.inboxId) return fail("identity-contradiction");
10845
11873
  if (kind === "download") {
10846
11874
  const download = record.download;
10847
11875
  if (download === void 0 || download.target.id !== raw.inboxId) return fail("identity-contradiction");
@@ -10851,13 +11879,31 @@ function checkIdentity(record, raw, kind) {
10851
11879
  }
10852
11880
  return null;
10853
11881
  }
10854
- /** The lifetime profile: what the route says, and nothing the record could have chosen for itself. */
10855
- function checkLifetime(raw, kind) {
11882
+ /**
11883
+ * The lifetime profile: what the route says, and nothing the record could have chosen for itself. A standing approval
11884
+ * waits as a `confirm` change does and lasts the whole days its limits name, from 1 to 30; its child lives
11885
+ * `standingChild`, pending and approved alike.
11886
+ */
11887
+ function checkLifetime(record, raw, kind) {
10856
11888
  if (kind === "download") {
10857
11889
  if (raw.pendingMs !== APPROVAL_LIFETIMES.download) return fail("lifetime-mismatch");
10858
11890
  if (raw.route !== void 0 || raw.approvedMs !== void 0) return fail("lifetime-mismatch");
10859
11891
  return null;
10860
11892
  }
11893
+ if (kind === "standing") {
11894
+ const days = record.standing.limits.expiresDays;
11895
+ const { min, max } = STANDING_LIMITS.expiresDays;
11896
+ if (raw.route !== "confirm" || raw.pendingMs !== APPROVAL_LIFETIMES.confirm) return fail("lifetime-mismatch");
11897
+ if (!Number.isSafeInteger(days) || days < min || days > max) return fail("lifetime-mismatch");
11898
+ if (raw.approvedMs !== days * 864e5) return fail("lifetime-mismatch");
11899
+ if (raw.policy !== "confirm" || raw.requiredPolicy !== "confirm") return fail("lifetime-mismatch");
11900
+ return null;
11901
+ }
11902
+ if (isChild(raw)) {
11903
+ const lifetime = APPROVAL_LIFETIMES.standingChild;
11904
+ if (raw.pendingMs !== lifetime || raw.approvedMs !== lifetime) return fail("lifetime-mismatch");
11905
+ return null;
11906
+ }
10861
11907
  if (raw.route !== "chat" && raw.route !== "confirm") return fail("lifetime-mismatch");
10862
11908
  if (raw.pendingMs !== pendingMsOf(raw.route)) return fail("lifetime-mismatch");
10863
11909
  if (raw.approvedMs !== APPROVAL_LIFETIMES.approved) return fail("lifetime-mismatch");
@@ -10907,6 +11953,10 @@ function checkTimestamps(raw, kind, state, times) {
10907
11953
  if (!(createdAt <= approvedAt && approvedAt < expiresAt)) return fail("timestamp-misordered");
10908
11954
  if (usableUntil - approvedAt !== raw.approvedMs) return fail("lifetime-mismatch");
10909
11955
  }
11956
+ if (isChild(raw)) {
11957
+ if (approvedAt === void 0) return fail("timestamp-missing");
11958
+ if (approvedAt !== createdAt) return fail("timestamp-misordered");
11959
+ }
10910
11960
  const deadline = usableUntil ?? expiresAt;
10911
11961
  const since = approvedAt ?? createdAt;
10912
11962
  const { sendingAt, sendingHeartbeatAt, usedAt, sentAt, failedAt, revokedAt, expiredAt } = times;
@@ -10934,7 +11984,9 @@ function checkTimestamps(raw, kind, state, times) {
10934
11984
  */
10935
11985
  function checkEvidence(record, raw, kind, state) {
10936
11986
  const via = raw.approvedVia;
10937
- if (via !== void 0 && via !== "terminal" && via !== "elicitation") return fail("evidence-invalid");
11987
+ if (via !== void 0 && via !== "terminal" && via !== "elicitation" && via !== "standing") return fail("evidence-invalid");
11988
+ if (via === "standing" !== (isChild(raw) && via !== void 0)) return fail("evidence-invalid");
11989
+ if (raw.approvedChangePolicy !== void 0 && (kind !== "standing" || raw.approvedAt === void 0)) return fail("evidence-contradictory");
10938
11990
  if (kind === "download") {
10939
11991
  const answer = record.download?.answer;
10940
11992
  const answered = via !== void 0 || raw.approvedDigest !== void 0 || answer !== void 0;
@@ -10952,7 +12004,9 @@ function checkEvidence(record, raw, kind, state) {
10952
12004
  if (raw.approvedDigest !== void 0) return fail("evidence-contradictory");
10953
12005
  if (!(raw.approvedAt !== void 0)) return via !== void 0 || raw.approvedBindingDigest !== void 0 ? fail("evidence-contradictory") : null;
10954
12006
  if (via === void 0 || raw.approvedBindingDigest === void 0) return fail("evidence-missing");
10955
- if (kind === "change" && via !== "terminal") return fail("evidence-invalid");
12007
+ if (kind === "standing" && raw.approvedChangePolicy !== "chat" && raw.approvedChangePolicy !== "confirm") return fail("evidence-missing");
12008
+ if ((kind === "change" || kind === "standing") && via !== "terminal") return fail("evidence-invalid");
12009
+ if (isChild(raw) && via !== "standing") return fail("evidence-invalid");
10956
12010
  if (raw.approvedBindingDigest !== record.bindingDigest) return fail("evidence-contradictory");
10957
12011
  return null;
10958
12012
  }
@@ -10972,9 +12026,11 @@ const KINDS = [
10972
12026
  "download",
10973
12027
  "disclosure"
10974
12028
  ];
12029
+ /** Version 2 alone has standing approvals (design 2026-10-19-standing-approvals): no earlier release wrote one. */
12030
+ const V2_KINDS = [...KINDS, "standing"];
10975
12031
  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);
12032
+ const isString$1 = (value) => typeof value === "string";
12033
+ const isStrings = (value) => Array.isArray(value) && value.every(isString$1);
10978
12034
  const optional = (value, check) => value === void 0 || check(value);
10979
12035
  function unreadable(approvalId, reason) {
10980
12036
  return {
@@ -10987,22 +12043,22 @@ function unreadable(approvalId, reason) {
10987
12043
  };
10988
12044
  }
10989
12045
  function expectationShaped(value) {
10990
- return isObject(value) && isStrings(value.to) && isStrings(value.cc) && isStrings(value.bcc) && isString(value.subject);
12046
+ return isObject(value) && isStrings(value.to) && isStrings(value.cc) && isStrings(value.bcc) && isString$1(value.subject);
10991
12047
  }
10992
12048
  /**
10993
12049
  * The types every record shares, version 1 or 2: anything of the wrong type cannot reach the classifier. Values —
10994
12050
  * a timestamp that is a string but not a time, a digest that is a string but not hex — are the validator's.
10995
12051
  */
10996
12052
  function commonShape(raw) {
10997
- if (!isString(raw.inboxId)) return "missing-ownership";
10998
- if (!isString(raw.state) || !STATES.includes(raw.state)) return "wrong-shape";
12053
+ if (!isString$1(raw.inboxId)) return "missing-ownership";
12054
+ if (!isString$1(raw.state) || !STATES.includes(raw.state)) return "wrong-shape";
10999
12055
  for (const field of [
11000
12056
  "createdAt",
11001
12057
  "expiresAt",
11002
12058
  "updatedAt",
11003
12059
  "draftId",
11004
12060
  "draftMessageId"
11005
- ]) if (!isString(raw[field])) return "wrong-shape";
12061
+ ]) if (!isString$1(raw[field])) return "wrong-shape";
11006
12062
  if (!expectationShaped(raw.expect)) return "wrong-shape";
11007
12063
  for (const field of [
11008
12064
  "inboxSub",
@@ -11013,17 +12069,23 @@ function commonShape(raw) {
11013
12069
  "reason",
11014
12070
  "policy",
11015
12071
  "requiredPolicy"
11016
- ]) if (!optional(raw[field], isString)) return "wrong-shape";
12072
+ ]) if (!optional(raw[field], isString$1)) return "wrong-shape";
11017
12073
  if (!optional(raw.riskFlags, isStrings)) return "wrong-shape";
11018
12074
  if (!optional(raw.challengeAttempts, (value) => typeof value === "number")) return "wrong-shape";
11019
12075
  const kind = raw.kind ?? "send";
11020
12076
  if (kind === "change") {
11021
12077
  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";
12078
+ if (!isObject(change) || !isString$1(change.summary) || !(change.target === null || isObject(change.target)) || !Array.isArray(change.loosened) || !isStrings(change.effects)) return "wrong-shape";
12079
+ }
12080
+ if (kind === "standing") {
12081
+ const standing = raw.standing;
12082
+ 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";
12083
+ const narrowed = raw.narrowed;
12084
+ if (!optional(narrowed, (value) => isObject(value) && optional(value.keep, isStrings) && optional(value.places, isStrings) && optional(value.limits, isObject))) return "wrong-shape";
11023
12085
  }
11024
12086
  if (kind === "download") {
11025
12087
  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";
12088
+ 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
12089
  }
11028
12090
  return null;
11029
12091
  }
@@ -11036,7 +12098,7 @@ function disclosureShape(raw) {
11036
12098
  "expiresAt",
11037
12099
  "updatedAt",
11038
12100
  "bindingDigest"
11039
- ]) if (!isString(raw[field])) return "wrong-shape";
12101
+ ]) if (!isString$1(raw[field])) return "wrong-shape";
11040
12102
  for (const field of [
11041
12103
  "pendingMs",
11042
12104
  "approvedMs",
@@ -11052,9 +12114,9 @@ function disclosureShape(raw) {
11052
12114
  "revokedAt",
11053
12115
  "expiredAt",
11054
12116
  "reason"
11055
- ]) if (!optional(raw[field], isString)) return "wrong-shape";
12117
+ ]) if (!optional(raw[field], isString$1)) return "wrong-shape";
11056
12118
  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";
12119
+ 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
12120
  for (const field of [
11059
12121
  "channel",
11060
12122
  "ownerScope",
@@ -11069,6 +12131,8 @@ function disclosureShape(raw) {
11069
12131
  "requiredPolicy",
11070
12132
  "riskFlags",
11071
12133
  "approvedBindingDigest",
12134
+ "approvedChangePolicy",
12135
+ "standingUse",
11072
12136
  "sendingAt",
11073
12137
  "sendingHeartbeatAt",
11074
12138
  "sentAt",
@@ -11086,16 +12150,20 @@ function v2Shape(raw) {
11086
12150
  "channel",
11087
12151
  "ownerScope",
11088
12152
  "contentDigest"
11089
- ]) if (!optional(raw[field], isString)) return "wrong-shape";
12153
+ ]) if (!optional(raw[field], isString$1)) return "wrong-shape";
11090
12154
  for (const field of [
11091
12155
  "pendingMs",
11092
12156
  "approvedMs",
11093
- "sendEpoch"
12157
+ "sendEpoch",
12158
+ "standingEpoch"
11094
12159
  ]) if (!optional(raw[field], (value) => typeof value === "number")) return "wrong-shape";
12160
+ if (!optional(raw.standingId, isString$1)) return "wrong-shape";
12161
+ if (!optional(raw.standingUse, isObject)) return "wrong-shape";
11095
12162
  for (const field of [
11096
12163
  "route",
11097
12164
  "bindingDigest",
11098
12165
  "approvedBindingDigest",
12166
+ "approvedChangePolicy",
11099
12167
  "approvedAt",
11100
12168
  "usableUntil",
11101
12169
  "sendingAt",
@@ -11105,7 +12173,7 @@ function v2Shape(raw) {
11105
12173
  "failedAt",
11106
12174
  "revokedAt",
11107
12175
  "expiredAt"
11108
- ]) if (!optional(raw[field], isString)) return "wrong-shape";
12176
+ ]) if (!optional(raw[field], isString$1)) return "wrong-shape";
11109
12177
  return null;
11110
12178
  }
11111
12179
  /**
@@ -11121,7 +12189,7 @@ function decodeStored(fileId, text, config, now) {
11121
12189
  return unreadable(fileId, trimmed === "" || trimmed.startsWith("{") && !trimmed.endsWith("}") ? "truncated" : "invalid-json");
11122
12190
  }
11123
12191
  if (!isObject(raw)) return unreadable(fileId, "wrong-shape");
11124
- if (!isString(raw.approvalId)) return unreadable(fileId, "wrong-shape");
12192
+ if (!isString$1(raw.approvalId)) return unreadable(fileId, "wrong-shape");
11125
12193
  if (raw.kind === "disclosure") {
11126
12194
  if (raw.digestVersion === 1) return unreadable(fileId, "wrong-shape");
11127
12195
  const shape = disclosureShape(raw);
@@ -11143,8 +12211,8 @@ function decodeStored(fileId, text, config, now) {
11143
12211
  const shape = commonShape(raw);
11144
12212
  if (shape !== null) return unreadable(fileId, shape);
11145
12213
  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");
12214
+ if (!optional(raw.kind, (kind) => isString$1(kind) && KINDS.includes(kind))) return unreadable(fileId, "wrong-shape");
12215
+ if (!isString$1(raw.digest)) return unreadable(fileId, "wrong-shape");
11148
12216
  if (raw.approvalId !== fileId) return unreadable(fileId, "file-name-mismatch");
11149
12217
  const record = raw;
11150
12218
  return {
@@ -11153,7 +12221,7 @@ function decodeStored(fileId, text, config, now) {
11153
12221
  record
11154
12222
  };
11155
12223
  }
11156
- if (!isString(raw.kind) || !KINDS.includes(raw.kind)) return unreadable(fileId, "missing-kind");
12224
+ if (!isString$1(raw.kind) || !V2_KINDS.includes(raw.kind)) return unreadable(fileId, "missing-kind");
11157
12225
  const v2 = v2Shape(raw);
11158
12226
  if (v2 !== null) return unreadable(fileId, v2);
11159
12227
  const record = raw;
@@ -11277,9 +12345,9 @@ const NEVER = Object.freeze({
11277
12345
  cursor: null
11278
12346
  });
11279
12347
  /** 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$/;
12348
+ const ISO$1 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
11281
12349
  function isoTime(value) {
11282
- if (typeof value !== "string" || !ISO.test(value)) return null;
12350
+ if (typeof value !== "string" || !ISO$1.test(value)) return null;
11283
12351
  const at = Date.parse(value);
11284
12352
  return Number.isFinite(at) && new Date(at).toISOString() === value ? at : null;
11285
12353
  }
@@ -11904,7 +12972,7 @@ function folding(platform) {
11904
12972
  return platform === "win32" ? (text) => text.toLowerCase() : (text) => text;
11905
12973
  }
11906
12974
  /** The package version, read from package.json at build time. */
11907
- const VERSION = "0.22.0";
12975
+ const VERSION = "0.23.0";
11908
12976
  /**
11909
12977
  * 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
12978
  *
@@ -12079,8 +13147,8 @@ function locateCliCommand(request, runtime = {
12079
13147
  const theirs = typeof manifest.version === "string" ? manifest.version : "of no version";
12080
13148
  return fail("version", `${caller.packageName} ${version} finds ${target.packageName} ${theirs}, at ${installed}`);
12081
13149
  }
12082
- const callerWorkspace = workspaceOf(callerRoot);
12083
- const targetWorkspace = workspaceOf(installed);
13150
+ const callerWorkspace = workspaceOf$1(callerRoot);
13151
+ const targetWorkspace = workspaceOf$1(installed);
12084
13152
  const sameCheckout = callerWorkspace !== null && callerWorkspace === targetWorkspace;
12085
13153
  const found = {
12086
13154
  root: installed,
@@ -12490,7 +13558,7 @@ function installedPackage(start, name) {
12490
13558
  * `pnpm-workspace.yaml` above it, and its `packages` globs. A package under the checkout but not one of them — an
12491
13559
  * installed copy in its `node_modules` — is not a workspace package of it.
12492
13560
  */
12493
- function workspaceOf(root) {
13561
+ function workspaceOf$1(root) {
12494
13562
  let dir = dirname(root);
12495
13563
  for (;;) {
12496
13564
  const globs = workspaceGlobs(dir);
@@ -12563,7 +13631,7 @@ Object.freeze({
12563
13631
  packageName: "@agentcomms/core"
12564
13632
  });
12565
13633
  /** A channel of this release, by its word; a programming error for a word that is none. */
12566
- function entryOf(channel) {
13634
+ function entryOf$1(channel) {
12567
13635
  const entry = CHANNEL_SNAPSHOT.find((each) => each.manifest.channel === channel);
12568
13636
  if (entry === void 0) throw new TypeError(`"${channel}" is not a channel of this release`);
12569
13637
  return entry;
@@ -12595,8 +13663,8 @@ function cliHandoffs(options) {
12595
13663
  caller,
12596
13664
  platform,
12597
13665
  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),
13666
+ core: (words, use) => locate(entryOf$1("core"), words, use),
13667
+ of: (channel, words, use) => locate(entryOf$1(channel), words, use),
12600
13668
  on: (other) => other === platform ? handoffs : cliHandoffs({
12601
13669
  ...options,
12602
13670
  platform: other
@@ -13490,7 +14558,7 @@ async function gatedChangeAtTerminal(core, change, options) {
13490
14558
  if ((await approveChangeAtTerminal(core, prepared.approvalId, options.env, options.output, streams)).state !== "approved") throw cancelled();
13491
14559
  } else if (options.answered !== true) {
13492
14560
  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") {
14561
+ if ((await askLine$1(streams, `Type ${paint(options.output.color, "bold", "yes")} to apply this change: `)).trim().toLowerCase() !== "yes") {
13494
14562
  await revokeChange(core, prepared.approvalId, "cancelled at the terminal", {
13495
14563
  surface: "cli",
13496
14564
  disposition: "person"
@@ -13555,7 +14623,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
13555
14623
  }
13556
14624
  const prompt = await beginChangeApproval(core, approvalId, { surface: "cli" });
13557
14625
  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: `);
14626
+ const answer = await askLine$1(streams, `Type ${paint(output.color, "bold", prompt.challenge)} to approve this change, or press Enter to cancel: `);
13559
14627
  if (!answer.trim()) {
13560
14628
  await revokeChange(core, approvalId, "cancelled at the terminal", {
13561
14629
  surface: "cli",
@@ -13575,7 +14643,7 @@ async function approveChangeAtTerminal(core, approvalId, env, output, streams =
13575
14643
  function cancelled() {
13576
14644
  return new CommsError("USAGE", "cancelled: nothing was changed");
13577
14645
  }
13578
- async function askLine(streams, question) {
14646
+ async function askLine$1(streams, question) {
13579
14647
  const { createInterface } = await import("node:readline/promises");
13580
14648
  const rl = createInterface({
13581
14649
  input: streams.stdin,
@@ -13910,6 +14978,186 @@ async function openSecretStore(kind, options) {
13910
14978
  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
14979
  return new KeychainSecretStore(keyring, options.namespace, KEYCHAIN_TIMEOUT_MS, told);
13912
14980
  }
14981
+ /** Exactly what `Date.prototype.toISOString` writes. */
14982
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
14983
+ const OUTCOMES = [
14984
+ "sent",
14985
+ "failed",
14986
+ "unknown"
14987
+ ];
14988
+ const isString = (value) => typeof value === "string";
14989
+ const isTime$2 = (value) => isString(value) && ISO.test(value) && !Number.isNaN(Date.parse(value));
14990
+ /** One line as an entry, or null when it is not one this release wrote. */
14991
+ function entryOf(line) {
14992
+ let raw;
14993
+ try {
14994
+ raw = JSON.parse(line);
14995
+ } catch {
14996
+ return null;
14997
+ }
14998
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
14999
+ const entry = raw;
15000
+ if (!isTime$2(entry.at)) return null;
15001
+ if (entry.kind === "use") {
15002
+ if (![
15003
+ "standingId",
15004
+ "childId",
15005
+ "contentDigest",
15006
+ "channel",
15007
+ "ts",
15008
+ "thread",
15009
+ "sender",
15010
+ "targetDigest"
15011
+ ].every((field) => isString(entry[field]) && entry[field] !== "")) return null;
15012
+ if (entry.act !== "reaction" && entry.act !== "reply") return null;
15013
+ if (typeof entry.edited !== "boolean") return null;
15014
+ return {
15015
+ kind: "use",
15016
+ at: entry.at,
15017
+ standingId: entry.standingId,
15018
+ childId: entry.childId,
15019
+ act: entry.act,
15020
+ contentDigest: entry.contentDigest,
15021
+ channel: entry.channel,
15022
+ ts: entry.ts,
15023
+ thread: entry.thread,
15024
+ sender: entry.sender,
15025
+ targetDigest: entry.targetDigest,
15026
+ edited: entry.edited
15027
+ };
15028
+ }
15029
+ if (entry.kind === "outcome") {
15030
+ if (!isString(entry.childId) || !isString(entry.outcome) || !OUTCOMES.includes(entry.outcome)) return null;
15031
+ if (!isString(entry.notice) || entry.link !== void 0 && !isString(entry.link)) return null;
15032
+ if (entry.actAt !== void 0 && !isTime$2(entry.actAt)) return null;
15033
+ return {
15034
+ kind: "outcome",
15035
+ at: entry.at,
15036
+ ...entry.actAt === void 0 ? {} : { actAt: entry.actAt },
15037
+ childId: entry.childId,
15038
+ outcome: entry.outcome,
15039
+ ...entry.link === void 0 ? {} : { link: entry.link },
15040
+ notice: entry.notice
15041
+ };
15042
+ }
15043
+ return null;
15044
+ }
15045
+ var AckLedger = class {
15046
+ directory;
15047
+ #now;
15048
+ constructor(stateDir, now = () => /* @__PURE__ */ new Date()) {
15049
+ this.directory = join(stateDir, "standing");
15050
+ this.#now = now;
15051
+ }
15052
+ /** The clock this ledger was opened with: what a use is timed by, and its counts' windows measured from. */
15053
+ now() {
15054
+ return this.#now();
15055
+ }
15056
+ /** The account's ledger file. Refuses anything but an account id, so no name can reach another path. */
15057
+ #path(accountId) {
15058
+ 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." });
15059
+ return join(this.directory, `${accountId}.jsonl`);
15060
+ }
15061
+ /** Every line, read: the entries, and the number — from 1 — of each line this release cannot read. */
15062
+ async #read(path) {
15063
+ let text = "";
15064
+ try {
15065
+ text = await readFile(path, "utf8");
15066
+ } catch (error) {
15067
+ if (error.code !== "ENOENT") throw error;
15068
+ }
15069
+ const entries = [];
15070
+ const unreadable = [];
15071
+ for (const [index, line] of text.split("\n").entries()) {
15072
+ if (line.trim() === "") continue;
15073
+ const entry = entryOf(line);
15074
+ if (entry === null) unreadable.push(index + 1);
15075
+ else entries.push(entry);
15076
+ }
15077
+ return {
15078
+ entries,
15079
+ unreadable
15080
+ };
15081
+ }
15082
+ /** Where an account's ledger is kept: `<state>/standing/<accountId>.jsonl`, for a person told to move it aside. */
15083
+ pathOf(accountId) {
15084
+ return this.#path(accountId);
15085
+ }
15086
+ /**
15087
+ * Every account's ledger on this machine that holds a line this release cannot read, with the number of each such
15088
+ * line, for the doctor (design 2026-10-19-standing-approvals, the lead's call after review): while one does, no
15089
+ * standing approval of that account can be used — what it counts cannot be known — and nothing else says so. Reads
15090
+ * without the lock: a look, and every line it reads was written whole.
15091
+ */
15092
+ async unreadable() {
15093
+ let names;
15094
+ try {
15095
+ names = await readdir(this.directory);
15096
+ } catch (error) {
15097
+ if (error.code === "ENOENT") return [];
15098
+ throw error;
15099
+ }
15100
+ const found = [];
15101
+ for (const name of names.sort()) {
15102
+ if (!name.endsWith(".jsonl")) continue;
15103
+ const accountId = name.slice(0, -6);
15104
+ if (!ACCOUNT_ID_PATTERN.test(accountId)) continue;
15105
+ const path = this.#path(accountId);
15106
+ const { unreadable } = await this.#read(path);
15107
+ if (unreadable.length > 0) found.push({
15108
+ accountId,
15109
+ path,
15110
+ lines: unreadable
15111
+ });
15112
+ }
15113
+ return found;
15114
+ }
15115
+ /**
15116
+ * Runs `fn` under the account's ledger lock, with every entry as it reads there and `append`, which writes a `use`
15117
+ * line under the lock already held. `fn` must never take this lock again.
15118
+ *
15119
+ * A line this release cannot read refuses the whole call, BAD_DATA, before `fn` runs: a count read without it could
15120
+ * be lower than the truth, and a count is never guessed downwards.
15121
+ */
15122
+ withLock(accountId, fn) {
15123
+ let path;
15124
+ try {
15125
+ path = this.#path(accountId);
15126
+ } catch (error) {
15127
+ return Promise.reject(error);
15128
+ }
15129
+ return withFileLock(`${path}.lock`, async () => {
15130
+ const { entries, unreadable } = await this.#read(path);
15131
+ 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", {
15132
+ 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.",
15133
+ details: {
15134
+ unreadable: unreadable.length,
15135
+ lines: unreadable
15136
+ }
15137
+ });
15138
+ const append = async (entry) => {
15139
+ 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." });
15140
+ await appendPrivateLine(path, JSON.stringify(entry), { durable: true });
15141
+ };
15142
+ return fn(entries, append);
15143
+ });
15144
+ }
15145
+ /** Records what became of a use's act, taking the lock itself. It never frees a count. */
15146
+ async recordOutcome(accountId, entry) {
15147
+ const path = this.#path(accountId);
15148
+ const written = {
15149
+ ...entry,
15150
+ kind: "outcome",
15151
+ at: entry.at ?? this.#now().toISOString()
15152
+ };
15153
+ 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." });
15154
+ await withFileLock(`${path}.lock`, () => appendPrivateLine(path, JSON.stringify(written), { durable: true }));
15155
+ }
15156
+ /** Every entry this release can read, oldest first, for a report: a line it cannot read is left out of it. */
15157
+ async entries(accountId) {
15158
+ return (await this.#read(this.#path(accountId))).entries;
15159
+ }
15160
+ };
13913
15161
  var InboxStateStore = class {
13914
15162
  directory;
13915
15163
  constructor(stateDir) {
@@ -14744,6 +15992,7 @@ function openCore(options = {}) {
14744
15992
  answered: (approvalId) => securityAnswered(paths.stateDir, approvalId)
14745
15993
  }),
14746
15994
  ledger: new SendLedger(paths.stateDir, now),
15995
+ acks: new AckLedger(paths.stateDir, now),
14747
15996
  plans: new PlanStore(paths.stateDir, now),
14748
15997
  taint: new TaintStore(paths.stateDir, now),
14749
15998
  audit,
@@ -14811,9 +16060,26 @@ function stillWaiting(stored, outcome) {
14811
16060
  if (outcome.state === "sending") return true;
14812
16061
  return outcome.state === "pending" && !outcome.claimable;
14813
16062
  }
16063
+ /**
16064
+ * A standing approval's own state, as `standing list` reads it: `never` on its account ends it at a look, before any
16065
+ * use writes that down. Undefined for every other kind.
16066
+ */
16067
+ function standingStateOf(outcome) {
16068
+ if (outcome.record?.kind !== "standing") return void 0;
16069
+ const never = outcome.reason === NEVER_NOTICE;
16070
+ if (outcome.state === "pending" && !never) return { state: "pending" };
16071
+ if (outcome.state === "approved" && !never) return { state: "active" };
16072
+ const reason = outcome.reason ?? outcome.record.reason;
16073
+ return {
16074
+ state: "ended",
16075
+ ...reason === void 0 ? {} : { reason }
16076
+ };
16077
+ }
14814
16078
  /** Why a wait that has stopped waiting ended, from the classification it stopped on. */
14815
- function endOf(outcome) {
16079
+ function endOf$1(outcome) {
14816
16080
  if (outcome.claimable) return "claimable";
16081
+ const standing = standingStateOf(outcome);
16082
+ if (standing !== void 0) return standing.state === "active" ? "active" : "final";
14817
16083
  if (outcome.record !== null && outcome.state === "approved") return "blocked";
14818
16084
  return "final";
14819
16085
  }
@@ -14868,6 +16134,7 @@ async function waitHeld(core, approvalId, waitSeconds, options) {
14868
16134
  ended,
14869
16135
  waitedSeconds
14870
16136
  };
16137
+ const standing = standingStateOf(outcome);
14871
16138
  return {
14872
16139
  approvalId,
14873
16140
  state: outcome.state,
@@ -14875,13 +16142,14 @@ async function waitHeld(core, approvalId, waitSeconds, options) {
14875
16142
  approval,
14876
16143
  ended,
14877
16144
  waitedSeconds,
14878
- ...ended === "timeout" ? { hint: waitAgainHint(outcome.state, options.channel) } : {}
16145
+ ...ended === "timeout" ? { hint: waitAgainHint(outcome.state, options.channel) } : {},
16146
+ ...standing === void 0 ? {} : { standing }
14879
16147
  };
14880
16148
  };
14881
16149
  if (waitSeconds === 0) return finish("now", seen.outcome);
14882
16150
  let lastProgress = started;
14883
16151
  for (;;) {
14884
- if (!stillWaiting(seen.stored, seen.outcome)) return finish(endOf(seen.outcome), seen.outcome);
16152
+ if (!stillWaiting(seen.stored, seen.outcome)) return finish(endOf$1(seen.outcome), seen.outcome);
14885
16153
  if (options.signal?.aborted) return cancelled();
14886
16154
  if (clock.now() >= deadline) return finish("timeout", seen.outcome);
14887
16155
  const next = lastLook + WAIT_LOOK_MS;
@@ -14909,7 +16177,7 @@ async function waitHeld(core, approvalId, waitSeconds, options) {
14909
16177
  lastLook = clock.now();
14910
16178
  seen = await core.approvals.inspect(approvalId, expect, { action: "wait" });
14911
16179
  }
14912
- if (!stillWaiting(seen.stored, seen.outcome)) return finish(endOf(seen.outcome), seen.outcome);
16180
+ if (!stillWaiting(seen.stored, seen.outcome)) return finish(endOf$1(seen.outcome), seen.outcome);
14913
16181
  return finish("cancelled", seen.outcome);
14914
16182
  }
14915
16183
  }
@@ -15891,7 +17159,7 @@ function serverPruneChange(core, env, request, own) {
15891
17159
  };
15892
17160
  const { packageName, version } = ownProduct(request.channel, own) ?? {
15893
17161
  packageName: serverFacts(request.channel).packageName,
15894
- version: "0.22.0"
17162
+ version: "0.23.0"
15895
17163
  };
15896
17164
  const product = {
15897
17165
  packageName,
@@ -16039,6 +17307,12 @@ function everyServerPruneChange(core, env, request) {
16039
17307
  }
16040
17308
  };
16041
17309
  }
17310
+ Object.freeze([
17311
+ "narrow",
17312
+ "revoke",
17313
+ "off"
17314
+ ]);
17315
+ Object.keys(STANDING_LIMITS);
16042
17316
  /**
16043
17317
  * Bringing a machine to the latest release, from a terminal (`agentcomms update`) or a chat (`comms_update`).
16044
17318
  *
@@ -17312,7 +18586,7 @@ function terminalUpdateHooks(core, env, options = {}) {
17312
18586
  } };
17313
18587
  }
17314
18588
  //#endregion
17315
- //#region ../core/dist/tool-arguments-H5H3FITT.mjs
18589
+ //#region ../core/dist/tool-arguments-C2vEASTA.mjs
17316
18590
  const APPLIED = Symbol.for("agentcomms.strictToolArguments");
17317
18591
  const TARGET = "draft-2020-12";
17318
18592
  /**
@@ -27628,6 +28902,27 @@ strictObject({
27628
28902
  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
28903
  }
27630
28904
  });
28905
+ /** The environment values the Windows script reads the title and the body from. */
28906
+ const TITLE_VARIABLE = "AGENTCOMMS_NOTICE_TITLE";
28907
+ const BODY_VARIABLE = "AGENTCOMMS_NOTICE_BODY";
28908
+ Object.freeze([
28909
+ "-e",
28910
+ "on run argv",
28911
+ "-e",
28912
+ "display notification (item 1 of argv) with title (item 2 of argv)",
28913
+ "-e",
28914
+ "end run"
28915
+ ]);
28916
+ [
28917
+ "$ErrorActionPreference = 'Stop'",
28918
+ "$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]",
28919
+ "$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)",
28920
+ "$text = $xml.GetElementsByTagName('text')",
28921
+ `$null = $text.Item(0).AppendChild($xml.CreateTextNode([string]$env:${TITLE_VARIABLE}))`,
28922
+ `$null = $text.Item(1).AppendChild($xml.CreateTextNode([string]$env:${BODY_VARIABLE}))`,
28923
+ "$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)",
28924
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show($toast)"
28925
+ ].join("; ");
27631
28926
  /**
27632
28927
  * Email HTML is attacker-controlled. Before any of it becomes text a model reads, this removes what a human reading
27633
28928
  * the message in a mail client would not see — the channel used by hidden-text prompt injection (e.g. zero-size or
@@ -29001,6 +30296,10 @@ async function fenceOrStop(store, approvalId, claimToken, progress) {
29001
30296
  })
29002
30297
  };
29003
30298
  }
30299
+ Object.freeze({
30300
+ min: 5e3,
30301
+ max: 3e4
30302
+ });
29004
30303
  Object.freeze({
29005
30304
  complete: "not sent with any approval in the last 90 days",
29006
30305
  window: "not sent with any of the 500 most recently changed approval records",