@coreplane/switchboard 1.236.0 → 1.237.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.
Files changed (121) hide show
  1. package/dist/assets/deploy/cloudflare-resident/worker.ts +175 -62
  2. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +36 -10
  3. package/dist/assets/package-lock.json +3 -3
  4. package/dist/assets/package.json +1 -1
  5. package/dist/assets/source.json +3 -3
  6. package/dist/assets/src/core/authz/authorize.ts +25 -6
  7. package/dist/assets/src/core/authz/policy.ts +9 -0
  8. package/dist/assets/src/core/authz/types.ts +15 -1
  9. package/dist/assets/src/core/coordinator/driver.ts +5 -1
  10. package/dist/assets/src/core/runEvents.ts +10 -6
  11. package/dist/assets/src/core/ship/coordinator.ts +23 -1
  12. package/dist/assets/src/execution/residentDepsStore.ts +14 -2
  13. package/dist/assets/src/execution/residentDiskBudget.ts +45 -11
  14. package/dist/assets/src/execution/residentHead.ts +34 -8
  15. package/dist/assets/src/execution/residentRebind.ts +88 -31
  16. package/dist/assets/src/execution/sandboxLifecycle.ts +33 -0
  17. package/dist/assets/web/dist/.vite/manifest.json +408 -408
  18. package/dist/assets/web/dist/assets/AppShell-MYHNxJks.js +1 -0
  19. package/dist/assets/web/dist/assets/{CostsPage-BfJsol9y.js → CostsPage-DKk9OjB0.js} +1 -1
  20. package/dist/assets/web/dist/assets/{DeliveryPage-CvInfu7y.js → DeliveryPage-Kz6js49i.js} +1 -1
  21. package/dist/assets/web/dist/assets/{NotFoundPage-d37EP8hS.js → NotFoundPage-BXLrQQzy.js} +1 -1
  22. package/dist/assets/web/dist/assets/{ResidentDetailPage-DQSD0SZD.js → ResidentDetailPage-Dc23v_8v.js} +1 -1
  23. package/dist/assets/web/dist/assets/ResidentsIndexPage-TR5D1TRR.js +1 -0
  24. package/dist/assets/web/dist/assets/{RunFoldRow-Bngn4GjF.js → RunFoldRow-mkUj7WEN.js} +1 -1
  25. package/dist/assets/web/dist/assets/{RunRoutePage-a-tMrirB.js → RunRoutePage-CQI17__g.js} +3 -3
  26. package/dist/assets/web/dist/assets/RunsIndexPage-yIP4PlKP.js +1 -0
  27. package/dist/assets/web/dist/assets/{RunsTabs-CyCdOhJ2.js → RunsTabs-XpCqeWu4.js} +1 -1
  28. package/dist/assets/web/dist/assets/{ScheduledPage-ByCmyrDs.js → ScheduledPage-CdCVyNbh.js} +1 -1
  29. package/dist/assets/web/dist/assets/SettingsPage-DuA8JpLU.js +1 -0
  30. package/dist/assets/web/dist/assets/{StatusDot-D-BPSPpw.js → StatusDot-DmNHX6am.js} +1 -1
  31. package/dist/assets/web/dist/assets/{Tooltip-DPDx5tXT.js → Tooltip-qVnJTgSt.js} +1 -1
  32. package/dist/assets/web/dist/assets/{UnitRoutePage-0eiekTHQ.js → UnitRoutePage-BcTeH7m2.js} +1 -1
  33. package/dist/assets/web/dist/assets/{angular-html-Ni1Xil2G.js → angular-html-DYtV9uIN.js} +1 -1
  34. package/dist/assets/web/dist/assets/{angular-ts-oCgo6p05.js → angular-ts-DF2P25Zp.js} +1 -1
  35. package/dist/assets/web/dist/assets/{apl-CiWYl0gh.js → apl-Ff96wxko.js} +1 -1
  36. package/dist/assets/web/dist/assets/{astro-B-VeKZ_J.js → astro-LbBnZna-.js} +1 -1
  37. package/dist/assets/web/dist/assets/{blade-BIWEISKd.js → blade-DltuxeBW.js} +1 -1
  38. package/dist/assets/web/dist/assets/{c-DHxA5Byp.js → c-DBg7yuPx.js} +1 -1
  39. package/dist/assets/web/dist/assets/{chapel-D4qGLw-Q.js → chapel-C83hUAds.js} +1 -1
  40. package/dist/assets/web/dist/assets/{cobol-BznX5Q-u.js → cobol-DB0yV_2D.js} +1 -1
  41. package/dist/assets/web/dist/assets/{coffee-CVr8Idlj.js → coffee-Bpih6Kzl.js} +1 -1
  42. package/dist/assets/web/dist/assets/{cpp-wrk6NfW4.js → cpp-CU00ZeRn.js} +1 -1
  43. package/dist/assets/web/dist/assets/{crystal-CGYWMeH1.js → crystal-f8ImJkux.js} +1 -1
  44. package/dist/assets/web/dist/assets/{css-BgGytMsy.js → css-4VrC4ISo.js} +1 -1
  45. package/dist/assets/web/dist/assets/{dist-DSkVB16z.js → dist-TAGawIfM.js} +2 -2
  46. package/dist/assets/web/dist/assets/{durationTone-DYogEPEk.js → durationTone-CYMux25J.js} +1 -1
  47. package/dist/assets/web/dist/assets/{edge-CdR6AU9v.js → edge-BwFDzfS_.js} +1 -1
  48. package/dist/assets/web/dist/assets/{elixir-CQRb3PZx.js → elixir-wwIYIpJm.js} +1 -1
  49. package/dist/assets/web/dist/assets/{elm-CpuA6W8B.js → elm-DyDuM3v3.js} +1 -1
  50. package/dist/assets/web/dist/assets/{erb-CvlrblqH.js → erb-DFJBEVrh.js} +1 -1
  51. package/dist/assets/web/dist/assets/{git-rebase-COnovCVc.js → git-rebase-CuDv-44X.js} +1 -1
  52. package/dist/assets/web/dist/assets/{glimmer-js-C-DS4g_J.js → glimmer-js-ZFnrm4J3.js} +1 -1
  53. package/dist/assets/web/dist/assets/{glimmer-ts-DTkorWRI.js → glimmer-ts-XY4Yqshp.js} +1 -1
  54. package/dist/assets/web/dist/assets/{glsl-CH51-JW7.js → glsl-xnR8efx0.js} +1 -1
  55. package/dist/assets/web/dist/assets/{graphql-BO-XymNv.js → graphql-D-gO5tnP.js} +1 -1
  56. package/dist/assets/web/dist/assets/{hack-C-N0oicf.js → hack-CSxbW4ax.js} +1 -1
  57. package/dist/assets/web/dist/assets/{haml-BfmdtWoP.js → haml-D4ar42QT.js} +1 -1
  58. package/dist/assets/web/dist/assets/{handlebars-BqhO_te-.js → handlebars-CMLbDE7o.js} +1 -1
  59. package/dist/assets/web/dist/assets/{html-DUEZlTKV.js → html-CVekU4Fs.js} +1 -1
  60. package/dist/assets/web/dist/assets/{html-derivative-DOUJTS8A.js → html-derivative-St3HHw_N.js} +1 -1
  61. package/dist/assets/web/dist/assets/{http-j7UH0Cqv.js → http-Mon0DWHS.js} +1 -1
  62. package/dist/assets/web/dist/assets/{hurl-D6ZaBLJk.js → hurl-Cbl5IvBs.js} +1 -1
  63. package/dist/assets/web/dist/assets/{indexRow-BjXBzI5Z.js → indexRow-FLJu5mR9.js} +1 -1
  64. package/dist/assets/web/dist/assets/{java-dugWx3wi.js → java-Bprck1l-.js} +1 -1
  65. package/dist/assets/web/dist/assets/{javascript-CK4GC4JO.js → javascript-C73G1-UR.js} +1 -1
  66. package/dist/assets/web/dist/assets/{jinja-DrLKIyKG.js → jinja-C4DuRxYJ.js} +1 -1
  67. package/dist/assets/web/dist/assets/{jison-CS1Th-TO.js → jison-B2U03GEC.js} +1 -1
  68. package/dist/assets/web/dist/assets/{json-CkBqdVys.js → json-CvTZUDzx.js} +1 -1
  69. package/dist/assets/web/dist/assets/{jsx-CCxdg7n3.js → jsx-DMWKCzwo.js} +1 -1
  70. package/dist/assets/web/dist/assets/{julia-BalKW2hA.js → julia-Czn7bV_3.js} +1 -1
  71. package/dist/assets/web/dist/assets/{just-BKw1G40D.js → just-BBxpNfvC.js} +1 -1
  72. package/dist/assets/web/dist/assets/{latex-Dfad6ZN-.js → latex-Bw9yg5mr.js} +1 -1
  73. package/dist/assets/web/dist/assets/{liquid-f_CMGBvd.js → liquid-BRCjaydE.js} +1 -1
  74. package/dist/assets/web/dist/assets/{lua-BcMK_uKM.js → lua-DUu2KIC0.js} +1 -1
  75. package/dist/assets/web/dist/assets/main-csihqEj5.css +1 -0
  76. package/dist/assets/web/dist/assets/{main-DR8iKpNt.js → main-mUd_TJjH.js} +2 -2
  77. package/dist/assets/web/dist/assets/{marko-B0oNLx2i.js → marko-CZJZIlSQ.js} +1 -1
  78. package/dist/assets/web/dist/assets/{mdc-Bc_MmIyq.js → mdc-BmClq6fd.js} +1 -1
  79. package/dist/assets/web/dist/assets/{nginx-Cmxv8RIt.js → nginx-Bmt3xSEm.js} +1 -1
  80. package/dist/assets/web/dist/assets/{nim-BtgRfaY0.js → nim-Da-i0utK.js} +1 -1
  81. package/dist/assets/web/dist/assets/{org-Dl9EzlHh.js → org-BvAre2qP.js} +1 -1
  82. package/dist/assets/web/dist/assets/{perl-BmF7x7BB.js → perl-v9WwFFtB.js} +1 -1
  83. package/dist/assets/web/dist/assets/{php-rH6CVptQ.js → php-DTU4Zyze.js} +1 -1
  84. package/dist/assets/web/dist/assets/{pug-CIN5Ccck.js → pug-DhTwEqTO.js} +1 -1
  85. package/dist/assets/web/dist/assets/{qml-BpLS3RFr.js → qml-BUD9AC_c.js} +1 -1
  86. package/dist/assets/web/dist/assets/{r-BUMF3B-H.js → r-CAnu56n7.js} +1 -1
  87. package/dist/assets/web/dist/assets/{razor-B7sTBPdT.js → razor-Dn0P4gAq.js} +1 -1
  88. package/dist/assets/web/dist/assets/{regexp-h2SvwrsJ.js → regexp-CZyBRaVW.js} +1 -1
  89. package/dist/assets/web/dist/assets/residentsModel-By1fThvk.js +1 -0
  90. package/dist/assets/web/dist/assets/{rst-DLOWEsD2.js → rst-Ckyd5UsY.js} +1 -1
  91. package/dist/assets/web/dist/assets/{ruby-Cl_-I4k3.js → ruby-BBQ62fsx.js} +1 -1
  92. package/dist/assets/web/dist/assets/{sas-Cm4M-BvV.js → sas-CW35hMxG.js} +1 -1
  93. package/dist/assets/web/dist/assets/{scss-CEFCEQUF.js → scss-CpV8pF4J.js} +1 -1
  94. package/dist/assets/web/dist/assets/{shellscript-CE6zb5eS.js → shellscript-CRJvsmE1.js} +1 -1
  95. package/dist/assets/web/dist/assets/{shellsession-B4fDVzrM.js → shellsession-CyriUZxf.js} +1 -1
  96. package/dist/assets/web/dist/assets/{soy-DwXCMpjE.js → soy-D5QzYUeg.js} +1 -1
  97. package/dist/assets/web/dist/assets/{sql-DrnAKnyD.js → sql-CUBf_Mse.js} +1 -1
  98. package/dist/assets/web/dist/assets/{stata-BzlwPWD6.js → stata-B-4n4ITz.js} +1 -1
  99. package/dist/assets/web/dist/assets/{surrealql-CeUieZXd.js → surrealql-RuWZBLtr.js} +1 -1
  100. package/dist/assets/web/dist/assets/{svelte-BERIpIKR.js → svelte-CO5YzCsK.js} +1 -1
  101. package/dist/assets/web/dist/assets/{templ-6FSuUDwg.js → templ-DbkWVaBF.js} +1 -1
  102. package/dist/assets/web/dist/assets/{tex-SWCyMwNX.js → tex-IuxoEcId.js} +1 -1
  103. package/dist/assets/web/dist/assets/{ts-tags-VWj7gWgY.js → ts-tags-g6dGQPjd.js} +1 -1
  104. package/dist/assets/web/dist/assets/{tsx-B6dzfL8L.js → tsx-WnXwiyH2.js} +1 -1
  105. package/dist/assets/web/dist/assets/{twig-BDXsr482.js → twig-Lx1G7PnY.js} +1 -1
  106. package/dist/assets/web/dist/assets/{typescript-s100Dt8y.js → typescript-BQd7biLF.js} +1 -1
  107. package/dist/assets/web/dist/assets/{typst-WfyEAUdt.js → typst-D2lXPHK7.js} +1 -1
  108. package/dist/assets/web/dist/assets/{vue-CkXOOJ-6.js → vue-4wVQD9y9.js} +1 -1
  109. package/dist/assets/web/dist/assets/{vue-html-CEpzdcau.js → vue-html-DZPaRte4.js} +1 -1
  110. package/dist/assets/web/dist/assets/{vue-vine-D9dsfiFA.js → vue-vine-DbmZCmu-.js} +1 -1
  111. package/dist/assets/web/dist/assets/{xml-iBDofbJq.js → xml-DV16szyo.js} +1 -1
  112. package/dist/assets/web/dist/assets/{xsl-CqbJF4hD.js → xsl-eo5-1sk0.js} +1 -1
  113. package/dist/assets/web/dist/assets/{yaml-D9i6OxYe.js → yaml-BXdX8HZf.js} +1 -1
  114. package/dist/cli.js +960 -520
  115. package/package.json +1 -1
  116. package/dist/assets/web/dist/assets/AppShell-DZdz_sl7.js +0 -1
  117. package/dist/assets/web/dist/assets/ResidentsIndexPage-CZUGLen6.js +0 -1
  118. package/dist/assets/web/dist/assets/RunsIndexPage-s0VKmRda.js +0 -1
  119. package/dist/assets/web/dist/assets/SettingsPage-DhKEokGK.js +0 -1
  120. package/dist/assets/web/dist/assets/main-B77vAZVg.css +0 -1
  121. package/dist/assets/web/dist/assets/residentsModel-VSJEepL1.js +0 -1
@@ -252,7 +252,13 @@ import {
252
252
  type DiskSample,
253
253
  type ThreadCostKind,
254
254
  } from "../../src/execution/residentDiskBudget.js";
255
- import { attachTarget, mirrorNeedsFetch, parseWantSha, wantShaForBinding } from "../../src/execution/residentHead.js";
255
+ import {
256
+ attachTarget,
257
+ type FetchReason,
258
+ mirrorFetchReason,
259
+ parseWantSha,
260
+ wantShaForBinding,
261
+ } from "../../src/execution/residentHead.js";
256
262
  import { residentText, sanitizeResidentBody } from "../../src/execution/residentText.js";
257
263
  import {
258
264
  abandonedWaitStepResult,
@@ -297,6 +303,9 @@ import {
297
303
  parseDepsStoreListing,
298
304
  planDepsEviction,
299
305
  planDepsMaterialization,
306
+ DEPS_STORE_MAX_UNREFERENCED_UNDER_PRESSURE,
307
+ type DepsEvictionPlan,
308
+ type DepsStoreListing,
300
309
  } from "../../src/execution/residentDepsStore.js";
301
310
  import { buildId, injectedBuildStamp } from "../../src/deploy/buildStamp.js";
302
311
  import { createRefreshInstance, createRefreshInstanceNow, type RefreshInstanceParams } from "./refresh";
@@ -1002,10 +1011,11 @@ interface ResidentRecord {
1002
1011
  interface ThreadBinding {
1003
1012
  threadKey: string;
1004
1013
  ref: string;
1005
- /** How `ref` was chosen when the binding was made: the repo default for want
1006
- * of a named branch (item 30), or a branch someone named. Absent on a
1007
- * binding made before the field: read as `default` iff `ref` is the default
1008
- * branch. Only a `default` binding may ever move. */
1014
+ /** How `ref` was chosen: the repo default for want of a named branch — when
1015
+ * the binding was made (item 30), or when a return put it there because the
1016
+ * branch it was on is gone (`returned`) — or a branch someone named. Absent
1017
+ * on a binding made before the field: read as `default` iff `ref` is the
1018
+ * default branch. Only a `default` binding may ever move onto a branch. */
1009
1019
  boundBy?: BoundBy;
1010
1020
  /** The move a binding may make (item 16): from the default it was bound to,
1011
1021
  * onto the branch the thread's own run opened a pull request on. Set, the
@@ -1013,6 +1023,12 @@ interface ThreadBinding {
1013
1023
  * branch was gone from the mirror and the binding went back to the
1014
1024
  * default), and then for the next own pull request. */
1015
1025
  rebound?: Rebound;
1026
+ /** The binding's last move back to the default (item 16's second movement):
1027
+ * the branch it was on — one a rebind moved it onto, or one this thread's
1028
+ * own runs pushed (`ownBranches`) and bound by name, a ship unit's — was
1029
+ * gone from the mirror at an attach. From where, to where, which pull
1030
+ * request's branch it was, when. */
1031
+ returned?: Returned;
1016
1032
  /** The branches the thread's own runs pushed and the pull requests they
1017
1033
  * head, as each run's release handed them over (`/detach` `pushed`, item
1018
1034
  * 16a) — written before any eviction decision, so the fact outlives the
@@ -1122,9 +1138,10 @@ interface AttachOk {
1122
1138
  * and why — so the bot can say where the follow-up runs and why. */
1123
1139
  rebindRefused?: RebindRefused;
1124
1140
  /** This attach moved the binding BACK to the default branch (item 16's
1125
- * second movement): the branch a rebind had moved it onto is gone from the
1126
- * mirror, so the tree was provisioned on the default instead of the attach
1127
- * failing `unknown-ref`. From where, to where, which PR's branch it was. */
1141
+ * second movement): the branch it was on — a rebind's, or one this thread
1142
+ * itself pushed — is gone from the mirror, so the tree was provisioned on
1143
+ * the default instead of the attach failing `unknown-ref`. From where, to
1144
+ * where, which PR's branch it was. */
1128
1145
  returned?: Returned;
1129
1146
  }
1130
1147
 
@@ -2094,13 +2111,20 @@ export class ResidentDO extends Sandbox<Env> {
2094
2111
  throw new StepError("cat-file", `git cat-file exited ${r.exitCode}: ${r.stderr.trim() || "(no output)"}`);
2095
2112
  }
2096
2113
 
2097
- /** Attach's fetch decision (item 51) over the mirror's actual state: the ref
2098
- * is missing, or `wantSha` names a commit the ref's tip is not at. The
2099
- * decision itself is the pure, tested `mirrorNeedsFetch`. */
2100
- private async mirrorNeedsFetchFor(ref: string, wantSha: string | null): Promise<boolean> {
2114
+ /** Attach's fetch decision (item 51) over the mirror's actual state — why
2115
+ * the mirror is fetched before the tree is cloned, or null: the ref is
2116
+ * missing, `wantSha` names a commit the ref's tip is not at, or the ref is
2117
+ * one the binding could return from (item 16's second movement) and is
2118
+ * verified against the origin before the mirror's word on it is trusted.
2119
+ * The decision itself is the pure, tested `mirrorFetchReason`. */
2120
+ private async mirrorFetchReasonFor(
2121
+ ref: string,
2122
+ wantSha: string | null,
2123
+ returnable: boolean,
2124
+ ): Promise<FetchReason | null> {
2101
2125
  const refExists = await this.refExists(ref);
2102
2126
  const mirrorSha = refExists && wantSha !== null ? await this.readMirrorSha(ref) : undefined;
2103
- return mirrorNeedsFetch({ refExists, mirrorSha, wantSha });
2127
+ return mirrorFetchReason({ refExists, mirrorSha, wantSha, returnable });
2104
2128
  }
2105
2129
 
2106
2130
  /** Dependency/build cache key: sha256 over the ls-tree lines (mode,
@@ -4067,12 +4091,36 @@ export class ResidentDO extends Sandbox<Env> {
4067
4091
  committedKiB: this.diskCommittedKiB,
4068
4092
  diskBudgetMb: input.record.diskBudgetMb,
4069
4093
  kind,
4094
+ // A deps-store entry backup stages its archive from the deps (item
4095
+ // 61); the reserve holds their share only while one runs.
4096
+ depsBackupInFlight: this.depsBackupsInFlight.size > 0,
4070
4097
  });
4071
4098
  let rawFree = df.freeKiB;
4072
4099
  let verdict = decide(rawFree);
4073
4100
  const evicted: Array<{ threadKey: string; freedKiB: number | null }> = [];
4074
4101
  // Item 62: keep decisions travel as tokens; the key stays for the log line.
4075
4102
  const kept: Array<{ threadKey: string; why: DiskKeepWhy }> = [];
4103
+ // The deps store's spares go first (item 55): a warm cache no live tree
4104
+ // references, worth minutes on a future attach, against a tree refused
4105
+ // now. Their bytes come back before any idle tree is considered.
4106
+ let spares: Array<{ key: string; freedKiB: number }> = [];
4107
+ if (!verdict.fits) {
4108
+ spares = await this.evictDepsSparesUnderPressure().catch((err) => {
4109
+ console.log(`disk-pressure: deps spare eviction failed: ${errMsg(err)}`);
4110
+ return [];
4111
+ });
4112
+ if (spares.length > 0) {
4113
+ console.log(
4114
+ `disk-pressure: evicted ${spares.length} deps-store spare(s) (${formatGiB(spares.reduce((a, e) => a + e.freedKiB, 0))} back) to make room for ${input.threadKey}`,
4115
+ );
4116
+ rawFree = rawFreeAfterEviction(
4117
+ rawFree,
4118
+ await this.dfSample(),
4119
+ spares.reduce((a, e) => a + e.freedKiB, 0),
4120
+ );
4121
+ verdict = decide(rawFree);
4122
+ }
4123
+ }
4076
4124
  if (!verdict.fits) {
4077
4125
  const live = await this.liveBindings();
4078
4126
  const candidates: DiskEvictionCandidate[] = live.map((b) => ({
@@ -4119,7 +4167,7 @@ export class ResidentDO extends Sandbox<Env> {
4119
4167
  // truthiness does not narrow a boolean-literal discriminant.
4120
4168
  const final = verdict;
4121
4169
  if (final.fits === false) {
4122
- const reason = diskPressureReason({ verdict: final, evicted, kept });
4170
+ const reason = diskPressureReason({ verdict: final, evicted, kept, spares });
4123
4171
  console.log(`attach ${input.threadKey}: ${reason}`);
4124
4172
  const s = await this.getStatus();
4125
4173
  return { error: reason, status: 503, state: s.state, reason: DISK_PRESSURE_REASON };
@@ -4127,7 +4175,7 @@ export class ResidentDO extends Sandbox<Env> {
4127
4175
  const m = final.math;
4128
4176
  console.log(
4129
4177
  `attach ${input.threadKey}: disk admitted — ${kind} ${formatGiB(m.projectedKiB)} projected, ${formatGiB(m.freeKiB)} free, ` +
4130
- `reserve ${formatGiB(m.reserve.totalKiB)}, headroom ${formatGiB(m.headroomKiB)}${evicted.length > 0 ? `, evicted ${evicted.map((e) => e.threadKey).join(", ")}` : ""}`,
4178
+ `reserve ${formatGiB(m.reserve.totalKiB)}, headroom ${formatGiB(m.headroomKiB)}${spares.length > 0 ? `, evicted ${spares.length} deps spare(s)` : ""}${evicted.length > 0 ? `, evicted ${evicted.map((e) => e.threadKey).join(", ")}` : ""}`,
4131
4179
  );
4132
4180
  const committedKiB = m.projectedKiB ?? 0;
4133
4181
  this.diskCommittedKiB += committedKiB;
@@ -4461,12 +4509,13 @@ export class ResidentDO extends Sandbox<Env> {
4461
4509
  lastAttachAt: now,
4462
4510
  evicted: false,
4463
4511
  // An evicted binding's own history: how its ref was chosen (absent on
4464
- // one made before the field — never overwritten by this attach's flag)
4465
- // and the one move it may have made.
4512
+ // one made before the field — never overwritten by this attach's flag),
4513
+ // the one move it may have made and its last move back.
4466
4514
  ...(existing
4467
4515
  ? {
4468
4516
  ...(existing.boundBy !== undefined ? { boundBy: existing.boundBy } : {}),
4469
4517
  ...(existing.rebound !== undefined ? { rebound: existing.rebound } : {}),
4518
+ ...(existing.returned !== undefined ? { returned: existing.returned } : {}),
4470
4519
  ...(existing.ownBranches !== undefined ? { ownBranches: existing.ownBranches } : {}),
4471
4520
  }
4472
4521
  : { boundBy }),
@@ -4819,27 +4868,27 @@ export class ResidentDO extends Sandbox<Env> {
4819
4868
 
4820
4869
  /** Item 16's second movement, decided where the fact is established — under
4821
4870
  * the mirror mutex, after the attach's fetch found the bound ref gone: a
4822
- * binding a rebind moved onto its own pull request's branch, that branch
4823
- * now deleted (its pull request merged), goes back to the default it was
4824
- * bound to, so the run starts clean there (item 17) instead of the thread
4825
- * failing `unknown-ref` for the rest of its life. The row as it stands is
4826
- * re-read and re-judged (`canReturnToDefault`: a ref a person named never
4827
- * returns — that branch is theirs to sort out), the move stamped returned
4828
- * so the thread may follow its next pull request, and the answer carries
4829
- * the move back for the card. A row that no longer qualifies — another
4830
- * attach moved it meanwhile — is answered as it stands, nothing written. */
4871
+ * binding on a branch that is now deleted (its pull request merged) goes
4872
+ * back to the default, so the run starts clean there (item 17) instead of
4873
+ * the thread failing `unknown-ref` for the rest of its life — when the
4874
+ * branch is one a rebind moved it onto, or one this thread's own runs
4875
+ * pushed as the binding remembers it (`ownBranches`): a ship unit's coding
4876
+ * child binds its unit branch by name and pushes it, and the merge deletes
4877
+ * it. The row as it stands is re-read and re-judged (the pure
4878
+ * `returnToDefault`, which writes nothing for a row `canReturnToDefault`
4879
+ * does not admit: a ref a person named that the thread never pushed never
4880
+ * returns — that branch is theirs to sort out), the move recorded so the
4881
+ * thread may follow its next pull request, and the answer carries the move
4882
+ * back for the card. A row that no longer qualifies — another attach moved
4883
+ * it meanwhile — is answered as it stands, nothing written. */
4831
4884
  private async returnBindingToDefault(
4832
4885
  binding: ThreadBinding,
4833
4886
  defaultRef: string,
4834
4887
  ): Promise<{ binding: ThreadBinding; returned?: Returned }> {
4835
4888
  const key = threadBindingKey(binding.threadKey);
4836
4889
  const current = (await this.ctx.storage.get<ThreadBinding>(key)) ?? binding;
4837
- if (current.rebound === undefined || !canReturnToDefault(current, defaultRef)) return { binding: current };
4838
- const back = returnToDefault(
4839
- { ...current, rebound: current.rebound },
4840
- defaultRef,
4841
- new Date(systemClock()).toISOString(),
4842
- );
4890
+ const back = returnToDefault(current, defaultRef, new Date(systemClock()).toISOString());
4891
+ if (back === undefined) return { binding: current };
4843
4892
  await this.ctx.storage.put(key, back.binding);
4844
4893
  console.log(
4845
4894
  `attach ${binding.threadKey}: ${back.returned.from} is gone from the mirror (the thread's own pull request #${back.returned.pr}) — returned to ${back.returned.to}; the tree is provisioned there`,
@@ -4921,9 +4970,22 @@ export class ResidentDO extends Sandbox<Env> {
4921
4970
  // The expected head applies to the ref it was resolved for; a sticky
4922
4971
  // binding on another branch drops it (item 51) rather than fetching on
4923
4972
  // every attach of a thread that can never be at that commit.
4973
+ // A ref the binding could return from — a branch a rebind moved it onto,
4974
+ // or one its own runs pushed (item 16's second movement) — is fetched for
4975
+ // whether or not the mirror holds it: the branch dies when its pull
4976
+ // request merges, the deletion reaches the mirror only through a
4977
+ // `fetch --prune`, and a mirror trusted for still holding the ref would
4978
+ // provision a tree at the deleted branch's stale tip until the refresh
4979
+ // cycle's prune caught up. One predicate, read once here, drives the mint
4980
+ // pre-check, the fetch under the lock and the return gate alike.
4924
4981
  let want = wantShaForBinding({ boundRef: binding.ref, refHint, wantSha });
4982
+ const returnable = canReturnToDefault(binding, facts.defaultRef);
4925
4983
  let fetchToken: string | null = token;
4926
- if (!fetchToken && githubAppConfigured(this.env) && (await this.mirrorNeedsFetchFor(binding.ref, want))) {
4984
+ if (
4985
+ !fetchToken &&
4986
+ githubAppConfigured(this.env) &&
4987
+ (await this.mirrorFetchReasonFor(binding.ref, want, returnable)) !== null
4988
+ ) {
4927
4989
  fetchToken = (await mintRepoScopedToken(this.env, slug).catch(() => null))?.token ?? null;
4928
4990
  }
4929
4991
 
@@ -4931,14 +4993,27 @@ export class ResidentDO extends Sandbox<Env> {
4931
4993
  try {
4932
4994
  locked = await this.withMirrorLock(async () => {
4933
4995
  await this.ensureGitSetup();
4934
- const fetched = await this.mirrorNeedsFetchFor(binding.ref, want);
4935
- if (fetched) {
4936
- await this.gitWithCred(
4937
- fetchToken,
4938
- ["-C", MIRROR_DIR, "fetch", "--prune", "origin"],
4939
- "fetch",
4940
- GIT_NETWORK_TIMEOUT_MS,
4941
- );
4996
+ const why = await this.mirrorFetchReasonFor(binding.ref, want, returnable);
4997
+ if (why !== null) {
4998
+ try {
4999
+ await this.gitWithCred(
5000
+ fetchToken,
5001
+ ["-C", MIRROR_DIR, "fetch", "--prune", "origin"],
5002
+ "fetch",
5003
+ GIT_NETWORK_TIMEOUT_MS,
5004
+ );
5005
+ } catch (err) {
5006
+ // A fetch for a missing or stale ref fails the attach as it always
5007
+ // did. One that only verifies a returnable ref the mirror holds is
5008
+ // not the attach's failure — the origin unreachable, no token for
5009
+ // a private repo — so the attach goes on with the mirror's ref, as
5010
+ // it did before the verification existed; the return is only
5011
+ // ever decided on a fetch that ran.
5012
+ if (why !== "returnable-ref") throw err;
5013
+ console.log(
5014
+ `attach ${threadKey}: the fetch verifying ${binding.ref} at the origin failed (${errMsg(err)}) — the attach goes on with the mirror's ref`,
5015
+ );
5016
+ }
4942
5017
  }
4943
5018
  // What to check out, now that the mirror is as fresh as it will get
4944
5019
  // (item 51, the pure `attachTarget`): the ref's tip when the ref is
@@ -4947,18 +5022,21 @@ export class ResidentDO extends Sandbox<Env> {
4947
5022
  // `refs/pull/N/head` still names its head, and the caller told us that
4948
5023
  // head — so a review of a merged PR stays on the warm resident instead
4949
5024
  // of falling back to a cold sandbox that clones the same commit itself.
4950
- // No fetch ran ⇒ the ref existed at the check: `mirrorNeedsFetch` fetches
4951
- // for every missing ref, so only a fetched mirror needs the re-read.
4952
- // (Under the mirror mutex — the mirror cannot change in between.)
4953
- const refExists = !fetched || (await this.refExists(binding.ref));
5025
+ // No fetch was due ⇒ the ref existed at the check: `mirrorFetchReason`
5026
+ // fetches for every missing ref, so only a fetched mirror needs the
5027
+ // re-read — and a returnable ref is re-read for real, the fact the
5028
+ // second movement is decided on. (Under the mirror mutex — the mirror
5029
+ // cannot change in between.)
5030
+ const refExists = why === null || (await this.refExists(binding.ref));
4954
5031
  let target = attachTarget({
4955
5032
  refExists,
4956
5033
  wantSha: want,
4957
5034
  commitInMirror: !refExists && want !== null && (await this.commitInMirror(want)),
4958
5035
  });
4959
- if (target.kind === "unknown-ref" && canReturnToDefault(binding, facts.defaultRef)) {
4960
- // The branch a rebind moved this thread onto is gone even after the
4961
- // fetch — deleted once its pull request merged. The binding goes
5036
+ if (target.kind === "unknown-ref" && returnable) {
5037
+ // The thread's branch — one a rebind moved it onto, or one its own
5038
+ // runs pushed and it was bound to by name — is gone even after the
5039
+ // fetch: deleted once its pull request merged. The binding goes
4962
5040
  // back to the default (item 16's second movement) and the attach
4963
5041
  // goes on there: the default is always in the mirror, and the
4964
5042
  // worktree step below provisions the tree at its tip.
@@ -5713,27 +5791,24 @@ export class ResidentDO extends Sandbox<Env> {
5713
5791
  * in flight, and remove what `planDepsEviction` names — debris first, then
5714
5792
  * the coldest spares beyond DEPS_STORE_MAX_UNREFERENCED. Housekeeping:
5715
5793
  * a failure is a log line, never a lifecycle flip. */
5716
- private async sweepDepsStore(): Promise<void> {
5717
- const listed = await this.run(["sh", "-c", depsStoreListScript()], { timeoutMs: DU_TIMEOUT_MS });
5718
- if (listed.exitCode !== 0) return;
5719
- const listing = parseDepsStoreListing(listed.stdout);
5720
- if (listing.entries.length === 0 && listing.leftovers.length === 0) return;
5794
+ /** The store entries no eviction may touch: the checkout's key, every live
5795
+ * binding's, every install and every entry backup in flight. */
5796
+ private async depsProtectedKeys(): Promise<Set<string>> {
5721
5797
  const facts = await this.ctx.storage.get<RepoFacts>(FACTS_KEY);
5722
5798
  const protectedKeys = new Set<string>([...this.depsInFlight.keys(), ...this.depsBackupsInFlight]);
5723
5799
  if (facts?.lockfileHash) protectedKeys.add(facts.lockfileHash);
5724
5800
  for (const b of await this.liveBindings()) if (b.depsKey) protectedKeys.add(b.depsKey);
5725
- // A scratch/staging dir of an install still running in this incarnation
5726
- // is live work, not debris; the listing cannot tell them apart, so
5727
- // leftovers wait for a sweep with nothing in flight.
5728
- const leftovers = this.depsInFlight.size > 0 ? [] : listing.leftovers;
5729
- const plan = planDepsEviction({ entries: listing.entries, leftovers, protectedKeys });
5730
- if (plan.remove.length === 0) return;
5801
+ return protectedKeys;
5802
+ }
5803
+
5804
+ /** Remove the store entries a plan names and drop their entry backups with
5805
+ * them (item 61 PR B: a spare nothing references on disk is a spare nothing
5806
+ * will wake into). Shared by the sweep and the attach's pressure path. */
5807
+ private async applyDepsEviction(plan: DepsEvictionPlan, listing: DepsStoreListing, who: string): Promise<void> {
5731
5808
  await this.runOk(["rm", "-rf", ...plan.remove], "deps-evict", { timeoutMs: DU_TIMEOUT_MS });
5732
5809
  console.log(
5733
- `deps: evicted ${plan.remove.length} path(s) — ${plan.remove.map((p) => p.slice(DEPS_STORE_DIR.length + 1, DEPS_STORE_DIR.length + 9)).join(", ")}; kept ${plan.keep.map((k) => k.slice(0, 8)).join(", ")}`,
5810
+ `deps: ${who} evicted ${plan.remove.length} path(s) — ${plan.remove.map((p) => p.slice(DEPS_STORE_DIR.length + 1, DEPS_STORE_DIR.length + 9)).join(", ")}; kept ${plan.keep.map((k) => k.slice(0, 8)).join(", ")}`,
5734
5811
  );
5735
- // The evicted entries' archives go with them (item 61 PR B): a spare
5736
- // nothing references on disk is a spare nothing will wake into.
5737
5812
  const kept = new Set(plan.keep);
5738
5813
  const evictedKeys = listing.entries.map((e) => e.key).filter((k) => !kept.has(k));
5739
5814
  const drop = depsBackupsToDrop({
@@ -5748,6 +5823,44 @@ export class ResidentDO extends Sandbox<Env> {
5748
5823
  }
5749
5824
  }
5750
5825
 
5826
+ /** Under disk pressure (item 55) the store keeps no spare: every complete
5827
+ * entry no live tree references goes, coldest first, before any idle tree
5828
+ * is considered — a spare is a cache worth minutes on a future attach, a
5829
+ * refused tree is a run falling to a cold sandbox now. Leftovers are the
5830
+ * sweep's business (they may be live installs' scratch). Answers what it
5831
+ * removed with the bytes each measured at; a listing that fails answers
5832
+ * nothing and the tree path decides as before. */
5833
+ private async evictDepsSparesUnderPressure(): Promise<Array<{ key: string; freedKiB: number }>> {
5834
+ const listed = await this.run(["sh", "-c", depsStoreListScript()], { timeoutMs: DU_TIMEOUT_MS });
5835
+ if (listed.exitCode !== 0) return [];
5836
+ const listing = parseDepsStoreListing(listed.stdout);
5837
+ if (listing.entries.length === 0) return [];
5838
+ const plan = planDepsEviction({
5839
+ entries: listing.entries,
5840
+ leftovers: [],
5841
+ protectedKeys: await this.depsProtectedKeys(),
5842
+ maxUnreferenced: DEPS_STORE_MAX_UNREFERENCED_UNDER_PRESSURE,
5843
+ });
5844
+ if (plan.remove.length === 0) return [];
5845
+ await this.applyDepsEviction(plan, listing, "disk-pressure");
5846
+ return plan.evicted.map((e) => ({ key: e.key, freedKiB: e.kib }));
5847
+ }
5848
+
5849
+ private async sweepDepsStore(): Promise<void> {
5850
+ const listed = await this.run(["sh", "-c", depsStoreListScript()], { timeoutMs: DU_TIMEOUT_MS });
5851
+ if (listed.exitCode !== 0) return;
5852
+ const listing = parseDepsStoreListing(listed.stdout);
5853
+ if (listing.entries.length === 0 && listing.leftovers.length === 0) return;
5854
+ const protectedKeys = await this.depsProtectedKeys();
5855
+ // A scratch/staging dir of an install still running in this incarnation
5856
+ // is live work, not debris; the listing cannot tell them apart, so
5857
+ // leftovers wait for a sweep with nothing in flight.
5858
+ const leftovers = this.depsInFlight.size > 0 ? [] : listing.leftovers;
5859
+ const plan = planDepsEviction({ entries: listing.entries, leftovers, protectedKeys });
5860
+ if (plan.remove.length === 0) return;
5861
+ await this.applyDepsEviction(plan, listing, "sweep");
5862
+ }
5863
+
5751
5864
  /** The per-user 700 staging dir (`install -d` is idempotent), memoized per
5752
5865
  * incarnation — the write/credential paths used to fork for it on every
5753
5866
  * call. Cleared with the other memos; a fresh disk simply re-creates it. */
@@ -41,6 +41,9 @@ import {
41
41
  SANDBOX_SLEEP_AFTER,
42
42
  isRecycleError,
43
43
  recycledMidCommandMessage,
44
+ DETACH_HINT,
45
+ OUTPUT_AFTER_EXIT_MS,
46
+ heldOutputNote,
44
47
  } from "../../src/execution/sandboxLifecycle.js";
45
48
  import { envFromRequest } from "../../src/execution/sandboxEnv.js";
46
49
  import { IdleGuard, type IdleGuardHost } from "../../src/execution/sandboxIdle.js";
@@ -88,10 +91,17 @@ const EXEC_TIMEOUT_SECS = 280;
88
91
  * the shell-level 124 is always the deadline a command meets first. */
89
92
  const SDK_BACKSTOP_MARGIN_MS = 40_000;
90
93
 
91
- /** How long the Durable Object waits for the process's exit past the SDK's
92
- * own limit before it kills the process itself and reports the timeout: the
93
- * runtime should have ended it at the limit; a starved container ends it late. */
94
- const OUTPUT_WAIT_MARGIN_MS = 30_000;
94
+ /** How long the Durable Object waits for the process's output to END past
95
+ * the command's own deadline before it asks the runtime what became of the
96
+ * process (docs/reference/specs/execution.md item 24). The runtime ends the
97
+ * output stream only when every holder of the command's stdout and stderr is
98
+ * gone; a process that exited while a detached child holds them is answered
99
+ * with its exit code and a note, a process still running is killed and
100
+ * answered as the shell-level timeout. The wait is `OUTPUT_AFTER_EXIT_MS`
101
+ * (20 s), inside the executor's per-send margin, so this answer always
102
+ * reaches the bot before it gives the command up — the SDK's own limit
103
+ * (`SDK_BACKSTOP_MARGIN_MS`) stays above it as the last resort. */
104
+ const OUTPUT_WAIT_AFTER_DEADLINE_MS = OUTPUT_AFTER_EXIT_MS;
95
105
 
96
106
  /** The interruption reasons that mean the container's runtime is not the one
97
107
  * the command started on: the process, if it started, is gone with its
@@ -303,7 +313,10 @@ export class SwitchboardSandbox extends Sandbox<Env> {
303
313
  return this.execFailure(err, startedAt);
304
314
  }
305
315
  try {
306
- const out = await proc.output({ encoding: "utf8", timeout: backstopMs + OUTPUT_WAIT_MARGIN_MS });
316
+ const out = await proc.output({
317
+ encoding: "utf8",
318
+ timeout: execTimeoutSecs * 1000 + OUTPUT_WAIT_AFTER_DEADLINE_MS,
319
+ });
307
320
  // coreutils `timeout` exits 124 when the deadline killed the command
308
321
  // (137 when the follow-up SIGKILL had to); the SDK's own limit, if it
309
322
  // ever wins, reports `timedOut`. All three are the one story.
@@ -321,9 +334,21 @@ export class SwitchboardSandbox extends Sandbox<Env> {
321
334
  };
322
335
  } catch (err) {
323
336
  if (err instanceof ProcessWaitTimeoutError) {
324
- // The runtime should have ended the process at the SDK's limit and
325
- // did not; abandoning a live process would leave it running in the
326
- // workspace. End it, then report the shell-level timeout it exceeded.
337
+ // The output never ended inside the command's deadline plus the
338
+ // margin. Two stories, told apart by asking the runtime (item 24):
339
+ // the process EXITED and a detached child holds its output open —
340
+ // the answer is its exit code and the note, the child runs on; or
341
+ // the process is still RUNNING past its own `timeout` — end it, and
342
+ // report the shell-level timeout it exceeded.
343
+ const status = await proc.status().catch(() => null);
344
+ if (status?.state === "exited") {
345
+ return {
346
+ stdout: "",
347
+ stderr: heldOutputNote(status.exit.code),
348
+ exitCode: status.exit.code,
349
+ durationMs: systemClock() - startedAt,
350
+ };
351
+ }
327
352
  await proc.kill(9).catch(() => {});
328
353
  return {
329
354
  stdout: "",
@@ -449,11 +474,12 @@ export class SwitchboardSandbox extends Sandbox<Env> {
449
474
  /** The stderr line an exit 124 carries: the limit, the knob, and the way to
450
475
  * outlive a command (`setsid -f`: every /exec runs under `timeout … bash -c`,
451
476
  * whose process group is reaped when the command returns, so a plain
452
- * background job dies with it). */
477
+ * background job dies with it — and with the wrapper's stdio redirected, so
478
+ * the command's output ends when the command does, item 24). */
453
479
  function timeoutNote(execTimeoutSecs: number): string {
454
480
  return (
455
481
  `command timed out in the sandbox after ${execTimeoutSecs}s (pass the bash tool's timeoutMs for longer commands, max ${BASH_TIMEOUT_MAX_MS} ms); ` +
456
- "re-run as smaller/faster steps, or start it detached with `setsid -f sh -c '<command> > /tmp/job.log 2>&1'` and poll the log on later calls"
482
+ `re-run as smaller/faster steps, or start it detached with \`${DETACH_HINT}\` and poll the log on later calls`
457
483
  );
458
484
  }
459
485
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.236.0",
3
+ "version": "1.237.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.236.0",
9
+ "version": "1.237.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20241,7 +20241,7 @@
20241
20241
  },
20242
20242
  "packages/switchboard": {
20243
20243
  "name": "@coreplane/switchboard",
20244
- "version": "1.236.0",
20244
+ "version": "1.237.0",
20245
20245
  "license": "Apache-2.0",
20246
20246
  "dependencies": {
20247
20247
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.236.0",
3
+ "version": "1.237.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.236.0",
3
- "commit": "cf9308dd5f28ac788804260c2978403ce5b59c81",
4
- "builtAt": "2026-09-16T20:27:06.883Z"
2
+ "version": "1.237.0",
3
+ "commit": "48c0666b37e01a43b1cfaafb6a9b80c398efa813",
4
+ "builtAt": "2026-09-16T22:10:57.396Z"
5
5
  }
@@ -63,6 +63,21 @@ export function principalOf(actor: Actor): Actor {
63
63
  return current;
64
64
  }
65
65
 
66
+ /** The ids that mean "me" for a decision (record 0042): the root principal's
67
+ * `self` when it carries one — its own id and the person a dashboard session
68
+ * is linked to — else its id alone. Never read by a grant check. */
69
+ export function selfIdsOf(actor: Actor): readonly string[] {
70
+ const principal = principalOf(actor);
71
+ return principal.self && principal.self.length > 0 ? principal.self : [principal.id];
72
+ }
73
+
74
+ /** Whether a self set names a chat identity — the `acts-as-person` condition:
75
+ * every chat actor, a dashboard session linked to its person; never an
76
+ * unlinked browser session or a credential. */
77
+ export function actsAsPerson(selfIds: readonly string[]): boolean {
78
+ return selfIds.some((id) => id.startsWith("slack:"));
79
+ }
80
+
66
81
  // ── decision ─────────────────────────────────────────────────────────────────
67
82
 
68
83
  const ALLOW: Decision = Object.freeze({ allow: true });
@@ -76,11 +91,13 @@ export type DenyReason =
76
91
  | "missing-grant"
77
92
  | "not-member"
78
93
  | "not-self"
94
+ | "not-person"
79
95
  | "not-owner"
80
96
  | "not-all-channels";
81
97
 
82
98
  const FAILURE_REASON: Readonly<Record<Condition["kind"], DenyReason>> = {
83
99
  "has-grant": "missing-grant",
100
+ "acts-as-person": "not-person",
84
101
  "member-of": "not-member",
85
102
  "is-self": "not-self",
86
103
  "owner-of": "not-owner",
@@ -98,7 +115,7 @@ export function isKnownActorKind(kind: string): boolean {
98
115
  export function evaluateCondition(
99
116
  condition: Condition,
100
117
  grants: Grants,
101
- selfId: string,
118
+ selfIds: readonly string[],
102
119
  attributes: ResourceAttributes,
103
120
  ): boolean {
104
121
  switch (condition.kind) {
@@ -115,7 +132,9 @@ export function evaluateCondition(
115
132
  attributes.channelVisibility === "public"
116
133
  );
117
134
  case "is-self":
118
- return attributes.userId !== undefined && attributes.userId === selfId;
135
+ return attributes.userId !== undefined && selfIds.includes(attributes.userId);
136
+ case "acts-as-person":
137
+ return actsAsPerson(selfIds);
119
138
  case "owner-of":
120
139
  return attributes.repo !== undefined && holds(grants.repos, attributes.repo);
121
140
  case "all-channels":
@@ -134,8 +153,8 @@ export function evaluateRule(rule: Rule, actor: Actor, resource: Resource): bool
134
153
  const attributes = attributesOf(resource);
135
154
  if (rule.originVisibility && !rule.originVisibility.includes(attributes.visibility)) return false;
136
155
  const grants = effectiveGrants(actor);
137
- const selfId = principalOf(actor).id;
138
- return rule.when.every((condition) => evaluateCondition(condition, grants, selfId, attributes));
156
+ const selfIds = selfIdsOf(actor);
157
+ return rule.when.every((condition) => evaluateCondition(condition, grants, selfIds, attributes));
139
158
  }
140
159
 
141
160
  /** `authorize` over an explicit (validated) table. Tests use it to drive
@@ -153,10 +172,10 @@ export function authorizeWith(rules: readonly Rule[], actor: Actor, action: Acti
153
172
  );
154
173
  if (forOrigin.length === 0) return deny("origin-visibility");
155
174
  const grants = effectiveGrants(actor);
156
- const selfId = principalOf(actor).id;
175
+ const selfIds = selfIdsOf(actor);
157
176
  let reason: DenyReason | undefined;
158
177
  for (const rule of forOrigin) {
159
- const failed = rule.when.find((condition) => !evaluateCondition(condition, grants, selfId, attributes));
178
+ const failed = rule.when.find((condition) => !evaluateCondition(condition, grants, selfIds, attributes));
160
179
  if (!failed) return ALLOW;
161
180
  reason ??= FAILURE_REASON[failed.kind];
162
181
  }
@@ -41,6 +41,7 @@ import type { ActorKind, Condition, Rule } from "./types.js";
41
41
  const grant = (g: string): Condition => ({ kind: "has-grant", grant: g });
42
42
  const MEMBER_OF: Condition = { kind: "member-of" };
43
43
  const IS_SELF: Condition = { kind: "is-self" };
44
+ const ACTS_AS_PERSON: Condition = { kind: "acts-as-person" };
44
45
  const OWNER_OF: Condition = { kind: "owner-of" };
45
46
  const ALL_CHANNELS: Condition = { kind: "all-channels" };
46
47
 
@@ -144,6 +145,13 @@ export const POLICY: readonly Rule[] = [
144
145
  // ── mcp (external MCP servers live in the three config tiers) ────────────
145
146
  { action: "mcp:read", resource: "command", when: [grant("mcp:read")] },
146
147
  { action: "mcp:write", resource: "command", when: [grant("mcp:write")] },
148
+ // A person always has their own tier to write (`mcp add|connect|remove`
149
+ // for themselves), as `config set me` already is theirs: the row admits an
150
+ // actor whose `self` names a chat identity — every chat user (who holds
151
+ // `mcp:write` by baseline anyway) and a dashboard session linked to its
152
+ // person (record 0042) — never an unlinked browser session or a credential.
153
+ // Which tier the write reaches stays the handler's question below.
154
+ { action: "mcp:write", resource: "command", actorKinds: ["user"], when: [ACTS_AS_PERSON] },
147
155
  // A CHANNEL's servers: the channel-config right for a person; a credential
148
156
  // an admin minted with `mcp:write` manages any tier it can name.
149
157
  { action: "mcp:write", resource: "config-scope", resourceKind: "channel", when: [grant("config:write")] },
@@ -214,6 +222,7 @@ export const CONDITION_KINDS: readonly Condition["kind"][] = [
214
222
  "has-grant",
215
223
  "member-of",
216
224
  "is-self",
225
+ "acts-as-person",
217
226
  "owner-of",
218
227
  "all-channels",
219
228
  ];