@wairon/cli 5.1.1-dev.92 → 5.1.1-dev.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -65,7 +65,7 @@ var init_defaults = __esm({
65
65
  copilot: ".github/prompts",
66
66
  codex: ".codex/agents"
67
67
  };
68
- WAIRON_VERSION = "5.1.1-dev.92";
68
+ WAIRON_VERSION = "5.1.1-dev.93";
69
69
  GITHUB_REPO = "SYW-Apps/Waffle-AIron";
70
70
  SUPPORTED_ALIASES = ["wai"];
71
71
  SCAN_EXCLUDE_DIRS = /* @__PURE__ */ new Set([
@@ -212,6 +212,7 @@ __export(fs_exports, {
212
212
  getProjectRootOverride: () => getProjectRootOverride,
213
213
  getRequestParentReach: () => getRequestParentReach,
214
214
  getRequestProjectRoot: () => getRequestProjectRoot,
215
+ getWriteReach: () => getWriteReach,
215
216
  isOutsideRoot: () => isOutsideRoot,
216
217
  listFiles: () => listFiles,
217
218
  listFilesRecursive: () => listFilesRecursive,
@@ -222,7 +223,9 @@ __export(fs_exports, {
222
223
  runWithProjectRoot: () => runWithProjectRoot,
223
224
  setProjectRoot: () => setProjectRoot,
224
225
  writeFile: () => writeFile,
225
- writeFileIfChanged: () => writeFileIfChanged
226
+ writeFileIfChanged: () => writeFileIfChanged,
227
+ writeReachDenials: () => writeReachDenials,
228
+ writeReachPermits: () => writeReachPermits
226
229
  });
227
230
  function ensureDir(dirPath) {
228
231
  fs3.mkdirSync(dirPath, { recursive: true });
@@ -277,6 +280,17 @@ function listFilesRecursive(dirPath, ext) {
277
280
  }
278
281
  return files;
279
282
  }
283
+ function writeReachPermits(reach2, root, subsystemId) {
284
+ const own = subsystemId === null ? void 0 : reach2.subsystems.find((s) => s.subsystemId === subsystemId && sameRoot(s.root, root));
285
+ if (own) return own.writable;
286
+ return !reach2.unwritableRoots.some((r) => sameRoot(r, root));
287
+ }
288
+ function writeReachDenials(reach2, root) {
289
+ return reach2.subsystems.filter((s) => !s.writable && sameRoot(s.root, root));
290
+ }
291
+ function getWriteReach() {
292
+ return requestRootStore.getStore()?.writeReach ?? null;
293
+ }
280
294
  function currentRootBinding() {
281
295
  return requestRootStore.getStore();
282
296
  }
@@ -291,7 +305,8 @@ function runWithProjectBinding(dir, reach2, fn) {
291
305
  parentReach: reach2.parentReach,
292
306
  ...reach2.narrowed ? { narrowed: true } : {},
293
307
  ...reach2.unwritableRoots ? { unwritableRoots: reach2.unwritableRoots.map((r) => path4.resolve(r)) } : {},
294
- ...reach2.hostedLookup ? { hostedLookup: reach2.hostedLookup } : {}
308
+ ...reach2.hostedLookup ? { hostedLookup: reach2.hostedLookup } : {},
309
+ ...reach2.writeReach ? { writeReach: reach2.writeReach } : {}
295
310
  },
296
311
  fn
297
312
  );
@@ -369,7 +384,7 @@ function aiDirAt(root, ...segments) {
369
384
  const base = !fs3.existsSync(waiPath) && fs3.existsSync(waironPath) ? waironPath : waiPath;
370
385
  return path4.join(base, ...segments);
371
386
  }
372
- var fs3, path4, import_node_async_hooks, projectRootOverride, requestRootStore;
387
+ var fs3, path4, import_node_async_hooks, projectRootOverride, sameRoot, requestRootStore;
373
388
  var init_fs = __esm({
374
389
  "src/utils/fs.ts"() {
375
390
  "use strict";
@@ -377,6 +392,7 @@ var init_fs = __esm({
377
392
  path4 = __toESM(require("path"));
378
393
  import_node_async_hooks = require("async_hooks");
379
394
  projectRootOverride = null;
395
+ sameRoot = (a, b) => path4.resolve(a) === path4.resolve(b);
380
396
  requestRootStore = new import_node_async_hooks.AsyncLocalStorage();
381
397
  }
382
398
  });
@@ -24970,6 +24986,28 @@ var init_exports2 = __esm({
24970
24986
  });
24971
24987
 
24972
24988
  // src/core/specs.ts
24989
+ function deniedOwners(reach2, owners) {
24990
+ const seen = /* @__PURE__ */ new Set();
24991
+ const denied = [];
24992
+ for (const o of owners) {
24993
+ const key = `${path23.resolve(o.root)}|${o.subsystemId ?? ""}`;
24994
+ if (seen.has(key)) continue;
24995
+ seen.add(key);
24996
+ if (writeReachPermits(reach2, o.root, o.subsystemId)) continue;
24997
+ const rule = o.subsystemId === null ? void 0 : reach2.subsystems.find((s) => s.subsystemId === o.subsystemId && path23.resolve(s.root) === path23.resolve(o.root));
24998
+ denied.push(o.subsystemId === null ? `project "${o.project}" itself (a spec of no subsystem, decided at the project rung)` : `subsystem "${o.subsystemId}" of project "${o.project}" (decided by ${rule?.decidedBy ?? "the project rung"})`);
24999
+ }
25000
+ return denied;
25001
+ }
25002
+ function assertSpecWritesPermitted(owners, what) {
25003
+ const reach2 = getWriteReach();
25004
+ if (!reach2) return;
25005
+ const denied = deniedOwners(reach2, owners);
25006
+ if (denied.length === 0) return;
25007
+ throw new SubsystemWriteDenied(
25008
+ `SubsystemWriteDenied: ${what} would change ${denied.length === 1 ? "a spec" : "specs"} this request may not change \u2014 ${denied.join("; ")}. Nothing was written.`
25009
+ );
25010
+ }
24973
25011
  function unknownKeysIn(raw, parsed, at, out) {
24974
25012
  if (Array.isArray(raw)) {
24975
25013
  if (!Array.isArray(parsed) || parsed.length !== raw.length) return;
@@ -26686,10 +26724,18 @@ function normalizeReferences(kind, id) {
26686
26724
  function rewriteReferences(kind, id, edits) {
26687
26725
  return current().respellReferences(kind, id, edits);
26688
26726
  }
26727
+ function assertSpecsInReach(targets, what, projectRung = false) {
26728
+ if (!getWriteReach()) return;
26729
+ const ws = current();
26730
+ const writable = ["system", "subsystem", "component", "interface", "implementation", "type"];
26731
+ const owners = targets.map((t) => ws.specOwner(t.kind, t.kind === "system" ? null : (writable.includes(t.kind) ? ws.load(t.kind, t.id) : null) ?? { id: t.id }));
26732
+ if (projectRung) owners.push(ws.specOwner("system", null));
26733
+ assertSpecWritesPermitted(owners, what);
26734
+ }
26689
26735
  function moveMethods(from, to, methods, hooks, dryRun) {
26690
26736
  return current().moveMethods(from, to, methods, hooks, dryRun);
26691
26737
  }
26692
- var fs16, path23, crypto5, PartReadOnly, PART_FORBIDDEN_FIELDS, IMPORTABLE_POSITIONS, SIGNATURE_TTL_MS, COMPONENT_AUTH_SOURCE, STEP_JUMP_FIELDS, STEP_JUMP_LISTS, DELTA_VERBS, JUMP_LABEL_TWINS, LABEL_TWIN_CONTAINERS, SpecWorkspace, DEFAULT_DEPENDENCY_LIMIT, PROSE_FIELDS, DELTA_JUMP_PINS, workspaces, lastServedBinding;
26738
+ var fs16, path23, crypto5, PartReadOnly, SubsystemWriteDenied, PART_FORBIDDEN_FIELDS, IMPORTABLE_POSITIONS, SIGNATURE_TTL_MS, COMPONENT_AUTH_SOURCE, STEP_JUMP_FIELDS, STEP_JUMP_LISTS, DELTA_VERBS, JUMP_LABEL_TWINS, LABEL_TWIN_CONTAINERS, SpecWorkspace, DEFAULT_DEPENDENCY_LIMIT, PROSE_FIELDS, DELTA_JUMP_PINS, workspaces, lastServedBinding;
26693
26739
  var init_specs2 = __esm({
26694
26740
  "src/core/specs.ts"() {
26695
26741
  "use strict";
@@ -26722,6 +26768,12 @@ var init_specs2 = __esm({
26722
26768
  this.name = "PartReadOnly";
26723
26769
  }
26724
26770
  };
26771
+ SubsystemWriteDenied = class extends WaironError {
26772
+ constructor(message) {
26773
+ super(message);
26774
+ this.name = "SubsystemWriteDenied";
26775
+ }
26776
+ };
26725
26777
  PART_FORBIDDEN_FIELDS = ["members", "externals", "rules", "extensions", "composition", "targets"];
26726
26778
  IMPORTABLE_POSITIONS = /* @__PURE__ */ new Set(["dependsOn", "dispatch", "mounts", "narrative"]);
26727
26779
  SIGNATURE_TTL_MS = 2e3;
@@ -27397,6 +27449,92 @@ var init_specs2 = __esm({
27397
27449
  `The spec file ${file} belongs to the part "${held.part.alias}", fetched from git (${held.source?.git ?? "its repository"} at ${held.part.commit ?? "its pinned commit"}): a git part is read-only here. Edit it in its own repository, commit it there, and take it in with \`wairon member update ${held.part.alias}\`. Nothing was written.`
27398
27450
  );
27399
27451
  }
27452
+ /**
27453
+ * spec_registry.save step 5 / delete step 2: inside a hosted request, may the
27454
+ * request change this spec? Judged against the request's write reach with the
27455
+ * home's project root and the spec's owning subsystem — the document written
27456
+ * and, when a stored document is replaced or removed, the one stored (a
27457
+ * component moved out of a subsystem changes that subsystem too). Outside a
27458
+ * hosted request nothing is judged.
27459
+ */
27460
+ assertHomeInReach(file, kind, document) {
27461
+ if (!getWriteReach()) return;
27462
+ const raw = this.homeRootOf(file);
27463
+ const owners = [];
27464
+ if (document !== null) owners.push(this.documentOwner(raw, kind, document));
27465
+ if (pathExists(file)) {
27466
+ try {
27467
+ owners.push(this.documentOwner(raw, kind, readSpecFile(file)));
27468
+ } catch {
27469
+ }
27470
+ }
27471
+ assertSpecWritesPermitted(owners, `writing ${kind} spec ${path23.basename(path23.dirname(file))}/${path23.basename(file)}`);
27472
+ }
27473
+ /** The project root a spec file is homed under: the root whose specs folder, or one of whose parts' folders, holds it (deepest wins). */
27474
+ homeRootOf(file) {
27475
+ this.scanAll();
27476
+ let best;
27477
+ let depth = -1;
27478
+ for (const raw of this.cachedRawRoots) {
27479
+ const homes = [aiPathsAt(raw.dir).specsDir(), ...raw.record.parts.map((p) => p.directory).filter((d) => d !== void 0)];
27480
+ for (const home of homes) {
27481
+ if (isWithin(home, file) && path23.resolve(home).length > depth) {
27482
+ best = raw;
27483
+ depth = path23.resolve(home).length;
27484
+ }
27485
+ }
27486
+ }
27487
+ if (!best) return { dir: this.rootDir, namespace: "", project: path23.basename(this.rootDir) };
27488
+ return { dir: best.dir, namespace: best.record.namespace, project: idOf(best) ?? (best.record.namespace || path23.basename(best.dir)) };
27489
+ }
27490
+ /**
27491
+ * The owner of one spec DOCUMENT as stored in its root (its references local
27492
+ * to that root): a subsystem is its own, a component or type names its
27493
+ * subsystem, an interface or implementation follows its component; the L0, a
27494
+ * system-level type and a type group belong to no subsystem.
27495
+ */
27496
+ documentOwner(root, kind, document) {
27497
+ const doc = document ?? {};
27498
+ const local = (v) => typeof v === "string" && v ? splitNamespace(v).localId : null;
27499
+ const key = (v) => typeof v === "string" && v ? keyIn(root.namespace, splitNamespace(v).localId) : null;
27500
+ let subsystemId = null;
27501
+ if (kind === "subsystem") subsystemId = local(doc.id);
27502
+ else if (kind === "component" || kind === "type") subsystemId = local(doc.subsystem);
27503
+ else if (kind === "interface") subsystemId = this.componentSubsystem(key(doc.component));
27504
+ else if (kind === "implementation") {
27505
+ const contract = key(doc.contract);
27506
+ subsystemId = this.componentSubsystem(contract ? this.loadInterfaceSpec(contract)?.component ?? null : null);
27507
+ }
27508
+ return { root: root.dir, project: root.project, subsystemId };
27509
+ }
27510
+ /** The local id of a component's subsystem, by the component's key; null when no such component is stored. */
27511
+ componentSubsystem(componentKey) {
27512
+ if (!componentKey) return null;
27513
+ const subsystem = this.loadComponentSpec(componentKey)?.subsystem;
27514
+ return subsystem ? splitNamespace(subsystem).localId : null;
27515
+ }
27516
+ /**
27517
+ * The owner of one IN-MEMORY spec (its references keys, as the loader binds
27518
+ * them) — what a multi-spec write (a rename, a method move) judges before its
27519
+ * first write. `staged` answers a spec the write itself creates.
27520
+ */
27521
+ specOwner(kind, spec, staged) {
27522
+ this.scanAll();
27523
+ const id = kind === "system" ? "" : String(spec?.id ?? "");
27524
+ const namespace = (kind === "system" ? "" : this.ownerKeyOf(id)) ?? "";
27525
+ const raw = this.cachedRawRoots.find((r) => r.record.namespace === namespace);
27526
+ const root = raw ? { dir: raw.dir, project: idOf(raw) ?? (namespace || path23.basename(raw.dir)) } : { dir: this.rootDir, project: path23.basename(this.rootDir) };
27527
+ const localOfKey = (v) => v ? splitNamespace(v).localId : null;
27528
+ let subsystemId = null;
27529
+ if (kind === "subsystem") subsystemId = localOfKey(id);
27530
+ else if (kind === "component" || kind === "type") subsystemId = localOfKey(spec?.subsystem);
27531
+ else if (kind === "interface") subsystemId = this.componentSubsystem(spec?.component ?? null);
27532
+ else if (kind === "implementation" && spec?.contract) {
27533
+ const component = this.loadInterfaceSpec(spec.contract)?.component ?? staged?.("interface", spec.contract)?.component ?? null;
27534
+ subsystemId = this.componentSubsystem(component);
27535
+ }
27536
+ return { root: root.dir, project: root.project, subsystemId };
27537
+ }
27400
27538
  /**
27401
27539
  * The key one spec file is approved under (stage 8): its path from the bound
27402
27540
  * root, POSIX — or, for a part's file, `members/<alias>/<path inside the
@@ -27407,13 +27545,15 @@ var init_specs2 = __esm({
27407
27545
  return approvalKeyIn(file, this.rootDir, this.cachedRoots[0]?.parts ?? []);
27408
27546
  }
27409
27547
  /** The file store's write, at a home the writer resolved — a part's folder included, a git part's refused. */
27410
- writeHome(file, document) {
27548
+ writeHome(file, document, kind) {
27411
27549
  this.assertHomeWritable(file);
27550
+ this.assertHomeInReach(file, kind, document);
27412
27551
  writeSpecFile(file, document);
27413
27552
  }
27414
27553
  /** The file store's delete, pruning up to the specs folder the file lives in (its part's, for a part's file). */
27415
- removeHome(file) {
27554
+ removeHome(file, kind) {
27416
27555
  this.assertHomeWritable(file);
27556
+ this.assertHomeInReach(file, kind, null);
27417
27557
  const part = this.partHolding(file)?.part;
27418
27558
  return removeSpecFile(file, part?.directory ? aiPathsAt(part.directory).specsDir() : this.paths.specsDir());
27419
27559
  }
@@ -27764,6 +27904,7 @@ var init_specs2 = __esm({
27764
27904
  }
27765
27905
  saveSystemSpec(spec) {
27766
27906
  const p = this.paths.specsSystem();
27907
+ this.assertHomeInReach(p, "system", spec);
27767
27908
  ensureDir(path23.dirname(p));
27768
27909
  writeSpecFile(p, parseOrThrow(SystemSpecSchema, spec, "system", spec.name));
27769
27910
  invalidateSpecCache();
@@ -27914,12 +28055,12 @@ var init_specs2 = __esm({
27914
28055
  }
27915
28056
  }
27916
28057
  specToWrite.updatedAt = opts?.preserveUpdatedAt && existing?.updatedAt ? existing.updatedAt : (/* @__PURE__ */ new Date()).toISOString();
27917
- this.writeHome(p, parseOrThrow(SubsystemSpecSchema, specToWrite, "subsystem", spec.id));
28058
+ this.writeHome(p, parseOrThrow(SubsystemSpecSchema, specToWrite, "subsystem", spec.id), "subsystem");
27918
28059
  invalidateSpecCache();
27919
28060
  }
27920
28061
  deleteSubsystemSpec(id) {
27921
28062
  const p = this.getSubsystemPath(id);
27922
- if (!this.removeHome(p)) return false;
28063
+ if (!this.removeHome(p, "subsystem")) return false;
27923
28064
  invalidateSpecCache();
27924
28065
  return true;
27925
28066
  }
@@ -27974,14 +28115,14 @@ var init_specs2 = __esm({
27974
28115
  );
27975
28116
  }
27976
28117
  specToWrite.updatedAt = opts?.preserveUpdatedAt && existing?.updatedAt ? existing.updatedAt : (/* @__PURE__ */ new Date()).toISOString();
27977
- this.writeHome(p, parseOrThrow(ComponentSpecSchema, specToWrite, "component", spec.id));
28118
+ this.writeHome(p, parseOrThrow(ComponentSpecSchema, specToWrite, "component", spec.id), "component");
27978
28119
  invalidateSpecCache();
27979
28120
  this.normalizeComponentLayout();
27980
28121
  return notices;
27981
28122
  }
27982
28123
  deleteComponentSpec(id) {
27983
28124
  const p = this.getComponentPath(id);
27984
- if (!this.removeHome(p)) return false;
28125
+ if (!this.removeHome(p, "component")) return false;
27985
28126
  invalidateSpecCache();
27986
28127
  return true;
27987
28128
  }
@@ -28103,13 +28244,13 @@ var init_specs2 = __esm({
28103
28244
  }
28104
28245
  }
28105
28246
  specToWrite.updatedAt = opts?.preserveUpdatedAt && existing?.updatedAt ? existing.updatedAt : (/* @__PURE__ */ new Date()).toISOString();
28106
- this.writeHome(p, parseOrThrow(InterfaceSpecSchema, specToWrite, "interface", spec.id));
28247
+ this.writeHome(p, parseOrThrow(InterfaceSpecSchema, specToWrite, "interface", spec.id), "interface");
28107
28248
  invalidateSpecCache();
28108
28249
  return notices;
28109
28250
  }
28110
28251
  deleteInterfaceSpec(id) {
28111
28252
  const p = this.getInterfacePath(id);
28112
- if (!this.removeHome(p)) return false;
28253
+ if (!this.removeHome(p, "interface")) return false;
28113
28254
  invalidateSpecCache();
28114
28255
  return true;
28115
28256
  }
@@ -28159,13 +28300,13 @@ var init_specs2 = __esm({
28159
28300
  }
28160
28301
  }
28161
28302
  specToWrite.updatedAt = opts?.preserveUpdatedAt && existing?.updatedAt ? existing.updatedAt : (/* @__PURE__ */ new Date()).toISOString();
28162
- this.writeHome(p, parseOrThrow(ImplementationSpecSchema, specToWrite, "implementation", spec.id));
28303
+ this.writeHome(p, parseOrThrow(ImplementationSpecSchema, specToWrite, "implementation", spec.id), "implementation");
28163
28304
  invalidateSpecCache();
28164
28305
  return notices;
28165
28306
  }
28166
28307
  deleteImplementationSpec(id) {
28167
28308
  const p = this.getImplementationPath(id);
28168
- if (!this.removeHome(p)) return false;
28309
+ if (!this.removeHome(p, "implementation")) return false;
28169
28310
  invalidateSpecCache();
28170
28311
  return true;
28171
28312
  }
@@ -28211,14 +28352,14 @@ var init_specs2 = __esm({
28211
28352
  }
28212
28353
  }
28213
28354
  specToWrite.updatedAt = opts?.preserveUpdatedAt && existing?.updatedAt ? existing.updatedAt : (/* @__PURE__ */ new Date()).toISOString();
28214
- this.writeHome(p, parseOrThrow(TypeSpecSchema, specToWrite, "type", spec.id));
28355
+ this.writeHome(p, parseOrThrow(TypeSpecSchema, specToWrite, "type", spec.id), "type");
28215
28356
  invalidateSpecCache();
28216
28357
  return notices;
28217
28358
  }
28218
28359
  deleteTypeSpec(id) {
28219
28360
  const spec = this.loadTypeSpec(id);
28220
28361
  const p = this.getTypePath(id, spec?.subsystem, spec?.group);
28221
- if (!this.removeHome(p)) return false;
28362
+ if (!this.removeHome(p, "type")) return false;
28222
28363
  invalidateSpecCache();
28223
28364
  return true;
28224
28365
  }
@@ -28241,12 +28382,12 @@ var init_specs2 = __esm({
28241
28382
  specToWrite.createdAt = existing.createdAt;
28242
28383
  }
28243
28384
  specToWrite.updatedAt = opts?.preserveUpdatedAt && existing?.updatedAt ? existing.updatedAt : (/* @__PURE__ */ new Date()).toISOString();
28244
- this.writeHome(p, parseOrThrow(GroupSpecSchema, specToWrite, "group", spec.id));
28385
+ this.writeHome(p, parseOrThrow(GroupSpecSchema, specToWrite, "group", spec.id), "group");
28245
28386
  invalidateSpecCache();
28246
28387
  }
28247
28388
  deleteGroupSpec(id) {
28248
28389
  const p = this.getGroupPath(id);
28249
- if (!this.removeHome(p)) return false;
28390
+ if (!this.removeHome(p, "group")) return false;
28250
28391
  invalidateSpecCache();
28251
28392
  return true;
28252
28393
  }
@@ -28444,7 +28585,7 @@ var init_specs2 = __esm({
28444
28585
  );
28445
28586
  }
28446
28587
  if (JSON.stringify(document) === JSON.stringify(stored)) return false;
28447
- this.writeHome(file, document);
28588
+ this.writeHome(file, document, kind);
28448
28589
  invalidateSpecCache();
28449
28590
  return true;
28450
28591
  }
@@ -29358,6 +29499,7 @@ var init_specs2 = __esm({
29358
29499
  }
29359
29500
  }
29360
29501
  const carried = projectConfigRepository.rekeyCarried(rename, true);
29502
+ this.assertMoveInReach(from, to, plan7, carried.length > 0);
29361
29503
  if (dryRun) {
29362
29504
  const edits2 = moveChangeReports([...plan7.edits.values()], true);
29363
29505
  return {
@@ -29397,6 +29539,14 @@ var init_specs2 = __esm({
29397
29539
  carried
29398
29540
  };
29399
29541
  }
29542
+ /** Refuse a method move, naming every subsystem it may not change, before anything is written. */
29543
+ assertMoveInReach(from, to, plan7, rekeysRegister) {
29544
+ if (!getWriteReach()) return;
29545
+ const staged = (kind, id) => plan7.edits.get(`${kind}:${id}`)?.after;
29546
+ const owners = [...plan7.edits.values()].flatMap((e) => [e.before, e.after].filter((s) => s !== null && s !== void 0).map((s) => this.specOwner(e.kind, s, staged)));
29547
+ if (rekeysRegister) owners.push(this.specOwner("system", null));
29548
+ assertSpecWritesPermitted(owners, `moving methods from "${from}" to "${to}"`);
29549
+ }
29400
29550
  /**
29401
29551
  * Rekey every lint allow keyed on a moved method, staging each spec it
29402
29552
  * changes like any other reference the move follows. An allow anchored at a
@@ -31346,7 +31496,7 @@ function restateReExports(from, to) {
31346
31496
  publicInterfaces: entries.map((e) => e.from === from ? { ...e, from: to } : e)
31347
31497
  });
31348
31498
  }
31349
- function rewriteRefFields(specsDir, remap, excludeDir) {
31499
+ function rewriteRefFields(specsDir, remap, excludeDir, dryRun = false) {
31350
31500
  const rewritten = [];
31351
31501
  for (const file of listFilesRecursive(specsDir, ".yaml")) {
31352
31502
  if (excludeDir && isWithinDir2(excludeDir, file)) continue;
@@ -31359,12 +31509,12 @@ function rewriteRefFields(specsDir, remap, excludeDir) {
31359
31509
  if (!raw || typeof raw !== "object") continue;
31360
31510
  const kind = specKind(raw);
31361
31511
  if (!rewriteSpecRefs(raw, remap) || !kind) continue;
31362
- writeYamlFile(file, raw);
31512
+ if (!dryRun) writeYamlFile(file, raw);
31363
31513
  rewritten.push({ kind, id: kind === "system" ? "system" : String(raw.id) });
31364
31514
  }
31365
31515
  return rewritten;
31366
31516
  }
31367
- function rekeyLintAllows(specsDir, rename) {
31517
+ function rekeyLintAllows(specsDir, rename, dryRun = false) {
31368
31518
  const rewritten = [];
31369
31519
  for (const file of listFilesRecursive(specsDir, ".yaml")) {
31370
31520
  let raw;
@@ -31395,7 +31545,7 @@ function rekeyLintAllows(specsDir, rename) {
31395
31545
  }
31396
31546
  }
31397
31547
  if (!changed) continue;
31398
- writeYamlFile(file, raw);
31548
+ if (!dryRun) writeYamlFile(file, raw);
31399
31549
  rewritten.push({ kind, id: specId });
31400
31550
  }
31401
31551
  return rewritten;
@@ -31473,15 +31623,22 @@ function renameComponent(componentId, newId) {
31473
31623
  specs: renamed.map(({ from, to }) => ({ from, to })),
31474
31624
  components: [{ from: componentId, to: newId }]
31475
31625
  };
31476
- projectConfigRepository.rekeyCarried(rename, true);
31477
- const rewrittenRefs = rewriteRefFields(aiPathsAt(getProjectRoot()).specsDir(), (ref, position) => {
31626
+ const registerEdits = projectConfigRepository.rekeyCarried(rename, true);
31627
+ const remap = (ref, position) => {
31478
31628
  if (position === "component" || position === "entity-class" || position === "auth-source") {
31479
31629
  return ref === componentId ? newId : ref;
31480
31630
  }
31481
31631
  if (position === "interface" && ref === movingInterface?.id) return interfaceId;
31482
31632
  return ref;
31483
- });
31484
- const allowsRekeyed = rekeyLintAllows(aiPathsAt(getProjectRoot()).specsDir(), rename);
31633
+ };
31634
+ const specsDir = aiPathsAt(getProjectRoot()).specsDir();
31635
+ assertSpecsInReach(
31636
+ [...renamed.map(({ kind, from }) => ({ kind, id: from })), ...rewriteRefFields(specsDir, remap, void 0, true), ...rekeyLintAllows(specsDir, rename, true)],
31637
+ `renaming component "${componentId}"`,
31638
+ Array.isArray(registerEdits) && registerEdits.length > 0
31639
+ );
31640
+ const rewrittenRefs = rewriteRefFields(specsDir, remap);
31641
+ const allowsRekeyed = rekeyLintAllows(specsDir, rename);
31485
31642
  const rewritten = [...new Set([...rewrittenRefs, ...allowsRekeyed].filter((spec) => !renamed.some((moved) => moved.kind === spec.kind && moved.from === spec.id)).map((spec) => spec.id))];
31486
31643
  invalidateSpecCache();
31487
31644
  moveRenamedSpecs(componentId, newId, component, movingInterface, movingImplementation);
@@ -31559,7 +31716,18 @@ function renameMethod(componentId, methodName, newName, pinSymbol) {
31559
31716
  ]
31560
31717
  }]
31561
31718
  };
31562
- projectConfigRepository.rekeyCarried(rename, true);
31719
+ const registerEdits = projectConfigRepository.rekeyCarried(rename, true);
31720
+ const methodRemap = (ref, position, owner) => position === "method" && owner === componentId && ref === methodName ? newName : ref;
31721
+ const reachDir = aiPathsAt(getProjectRoot()).specsDir();
31722
+ assertSpecsInReach(
31723
+ [
31724
+ ...(rename.methods?.[0]?.specs ?? []).map((id) => ({ kind: moving.some((i) => i.id === id) ? "interface" : "implementation", id })),
31725
+ ...rewriteRefFields(reachDir, methodRemap, void 0, true),
31726
+ ...rekeyLintAllows(reachDir, rename, true)
31727
+ ],
31728
+ `renaming method "${componentId}.${methodName}"`,
31729
+ Array.isArray(registerEdits) && registerEdits.length > 0
31730
+ );
31563
31731
  for (const contract of moving) {
31564
31732
  saveInterfaceSpec({
31565
31733
  ...contract,
@@ -31582,7 +31750,7 @@ function renameMethod(componentId, methodName, newName, pinSymbol) {
31582
31750
  }
31583
31751
  invalidateSpecCache();
31584
31752
  const specsDir = aiPathsAt(getProjectRoot()).specsDir();
31585
- const retargeted = rewriteRefFields(specsDir, (ref, position, owner) => position === "method" && owner === componentId && ref === methodName ? newName : ref);
31753
+ const retargeted = rewriteRefFields(specsDir, methodRemap);
31586
31754
  const allowsRekeyed = rekeyLintAllows(specsDir, rename);
31587
31755
  const rewritten = [...new Set([...retargeted, ...allowsRekeyed].map((spec) => spec.id))];
31588
31756
  const mentions = collectMentions(specsDir, methodName, moving);
@@ -41386,10 +41554,13 @@ function rehearse2(plan7) {
41386
41554
  return { ...plan7, refusals: [...plan7.refusals, { code: `${plan7.request.verb}-refused`, project: "", detail }] };
41387
41555
  }
41388
41556
  const changes = diff(rehearsal);
41389
- const outside = unwritableOwners(changes);
41390
- if (outside.length > 0) {
41557
+ const refused2 = [
41558
+ ...unwritableOwners(changes).map((o) => ({ code: "family-partial", project: o, detail: `the plan must write ${o}, which this request may not write` })),
41559
+ ...deniedSubsystems(changes)
41560
+ ];
41561
+ if (refused2.length > 0) {
41391
41562
  discard(rehearsal);
41392
- return { ...plan7, refusals: [...plan7.refusals, ...outside.map((o) => ({ code: "family-partial", project: o, detail: `the plan must write ${o}, which this request may not write` }))] };
41563
+ return { ...plan7, refusals: [...plan7.refusals, ...refused2] };
41393
41564
  }
41394
41565
  return { ...plan7, rehearsal, changes, relock: relockOf(plan7, changes) };
41395
41566
  }
@@ -41433,6 +41604,16 @@ function unwritableOwners(changes) {
41433
41604
  const owners = [...new Set(changes.map((c) => path48.resolve(c.project)))];
41434
41605
  return owners.filter((o) => denied.includes(o));
41435
41606
  }
41607
+ function deniedSubsystems(changes) {
41608
+ const reach2 = getWriteReach();
41609
+ if (!reach2) return [];
41610
+ const owners = [...new Set(changes.map((c) => path48.resolve(c.project)))];
41611
+ return owners.flatMap((o) => writeReachDenials(reach2, o).map((s) => ({
41612
+ code: "subsystem-denied",
41613
+ project: o,
41614
+ detail: `the plan must change ${o}, which holds subsystem "${s.subsystemId}" this request may not change (decided by ${s.decidedBy}); a family migration reshapes its projects whole, so it needs write on every subsystem of every project it changes`
41615
+ })));
41616
+ }
41436
41617
  function pathProducers(plan7, family) {
41437
41618
  const inside = (dir) => {
41438
41619
  const rel2 = path48.relative(plan7.familyRoot, dir);
@@ -48465,6 +48646,27 @@ function existingProjectRoot(dataDir, id) {
48465
48646
  const rec = records.find((r) => r.id === id);
48466
48647
  return rec ? derivedRoot(records, rec) : null;
48467
48648
  }
48649
+ function declaredSubsystemIds(dataDir, id) {
48650
+ const root = rootOnDisk(dataDir, id);
48651
+ return root ? subsystemIdsAt(root) : null;
48652
+ }
48653
+ function rootOnDisk(dataDir, id) {
48654
+ if (!isValidProjectId(id)) return null;
48655
+ const records = load5(dataDir);
48656
+ const rec = records.find((r) => r.id === id);
48657
+ const root = rec ? derivedRoot(records, rec) : null;
48658
+ return root && fs39.existsSync(root) ? root : null;
48659
+ }
48660
+ function subsystemIdsAt(dir) {
48661
+ return runWithProjectRoot(dir, () => loadSubsystemSpecs()).map((s) => s.id).filter((key) => !key.includes("::"));
48662
+ }
48663
+ function partSubsystemIds(dataDir, id, alias) {
48664
+ const root = rootOnDisk(dataDir, id);
48665
+ const decl = root ? memberDeclarationsOn(root).find((d) => d.alias === alias && d.path && !d.hosted && !d.refused) : void 0;
48666
+ if (!root || !decl?.path) return null;
48667
+ const dir = assertContainedProjectPath(root, decl.path);
48668
+ return fs39.existsSync(dir) ? subsystemIdsAt(dir) : null;
48669
+ }
48468
48670
  function createProjectRecord(dataDir, id) {
48469
48671
  if (!isValidProjectId(id)) {
48470
48672
  throw new Error(`Invalid project id "${id}" (allowed: lowercase letters, digits, hyphen).`);
@@ -49382,6 +49584,23 @@ var fs42 = __toESM(require("fs"));
49382
49584
  var path64 = __toESM(require("path"));
49383
49585
 
49384
49586
  // src/server/types.ts
49587
+ var SUBSYSTEM_SCOPE_SEPARATOR = "/";
49588
+ var SUBSYSTEM_NOT_FOUND = "subsystem not found";
49589
+ function parseSubsystemScope(scopeId) {
49590
+ const at = scopeId.indexOf(SUBSYSTEM_SCOPE_SEPARATOR);
49591
+ if (at <= 0 || at === scopeId.length - 1) return null;
49592
+ return { projectId: scopeId.slice(0, at), subsystemId: scopeId.slice(at + 1) };
49593
+ }
49594
+ function describeDecision(p) {
49595
+ const setting = { "instance-admin": "the instance-admin bypass", user: "a user setting", role: "a role", "everyone-default": "the everyone-default", "instance-default": "the instance default" }[p.source];
49596
+ if (p.source === "instance-admin" || p.source === "instance-default") return setting;
49597
+ if (p.decidedScopeKind === "subsystem" && p.decidedScopeId) {
49598
+ const parsed = parseSubsystemScope(p.decidedScopeId);
49599
+ return `${setting} at subsystem ${parsed?.subsystemId ?? p.decidedScopeId} of project ${parsed?.projectId ?? ""}`.trim();
49600
+ }
49601
+ if (p.decidedScopeKind && p.decidedScopeKind !== "instance") return `${setting} at ${p.decidedScopeKind} ${p.decidedScopeId ?? ""}`.trim();
49602
+ return `${setting} at the instance`;
49603
+ }
49385
49604
  var SSO_ADMIN_ROLE_ID = "sso-admin";
49386
49605
  var UNAUTHENTICATED = {
49387
49606
  tokenId: "",
@@ -49491,6 +49710,7 @@ function listRoles(dataDir) {
49491
49710
  }
49492
49711
 
49493
49712
  // src/server/permission-rules.ts
49713
+ var SUBSYSTEM_CAPABILITY = "project:write";
49494
49714
  var INSTANCE_ROOT = { kind: "instance" };
49495
49715
  var scopeKey = (kind, id) => `${kind}:${id ?? ""}`;
49496
49716
  function isDefinitive(value) {
@@ -49528,7 +49748,13 @@ function familyRootUnits(projectId, world) {
49528
49748
  const placement = world.placements.find((p) => p.projectId === familyRoot);
49529
49749
  return placement ? unitChain(placement.unitId, world) : [];
49530
49750
  }
49531
- function ancestorChain(targetScopeKind, targetScopeId, world) {
49751
+ function ancestorChain(capability, targetScopeKind, targetScopeId, world) {
49752
+ if (targetScopeKind === "subsystem") {
49753
+ const parsed = parseSubsystemScope(targetScopeId);
49754
+ if (!parsed) return [INSTANCE_ROOT];
49755
+ const own = capability === SUBSYSTEM_CAPABILITY ? [{ kind: "subsystem", id: targetScopeId }] : [];
49756
+ return [...own, ...ancestorChain(capability, "project", parsed.projectId, world)];
49757
+ }
49532
49758
  if (targetScopeKind === "project") {
49533
49759
  const projects = projectChain(targetScopeId, world).map((id) => ({ kind: "project", id }));
49534
49760
  return [...projects, ...familyRootUnits(targetScopeId, world), INSTANCE_ROOT];
@@ -49570,7 +49796,8 @@ function subjectIdMatches(subject, assignmentSubjectId) {
49570
49796
  if (assignmentSubjectId === subject.subjectId) return true;
49571
49797
  return (subject.aliasSubjectIds ?? []).includes(assignmentSubjectId);
49572
49798
  }
49573
- function decideScope(settings) {
49799
+ function decideScope(settings, kind) {
49800
+ if (kind === "subsystem") return decideSubsystemScope(settings);
49574
49801
  if (isDefinitive(settings.user)) return { value: settings.user, source: "user" };
49575
49802
  const best = settings.roleValues.reduce(
49576
49803
  (acc, v) => acc === void 0 || grantRank(v) > grantRank(acc) ? v : acc,
@@ -49580,14 +49807,21 @@ function decideScope(settings) {
49580
49807
  if (isDefinitive(settings.everyone)) return { value: settings.everyone, source: "everyone-default" };
49581
49808
  return null;
49582
49809
  }
49810
+ function decideSubsystemScope(settings) {
49811
+ const yesOrNo = (v) => v === "yes" || v === "no";
49812
+ if (yesOrNo(settings.user)) return { value: settings.user, source: "user" };
49813
+ if (settings.roleValues.includes("yes")) return { value: "yes", source: "role" };
49814
+ if (yesOrNo(settings.everyone)) return { value: settings.everyone, source: "everyone-default" };
49815
+ return null;
49816
+ }
49583
49817
  function resolvePermission(subject, capability, targetScopeKind, targetScopeId, world) {
49584
49818
  if (subject.instanceAdmin) {
49585
49819
  return { value: "yes", source: "instance-admin" };
49586
49820
  }
49587
- const chain = ancestorChain(targetScopeKind, targetScopeId, world);
49821
+ const chain = ancestorChain(capability, targetScopeKind, targetScopeId, world);
49588
49822
  for (const scope of chain) {
49589
49823
  const settings = settingsAtScope(subject, capability, scope, world);
49590
- const decision = decideScope(settings);
49824
+ const decision = decideScope(settings, scope.kind);
49591
49825
  if (decision) {
49592
49826
  return {
49593
49827
  value: decision.value,
@@ -49606,7 +49840,21 @@ function allScopes(world) {
49606
49840
  ...projectIds.map((id) => ({ kind: "project", id }))
49607
49841
  ];
49608
49842
  }
49843
+ function namedSubsystemScopes(subject, world) {
49844
+ const ids = [
49845
+ ...world.assignments.filter((a) => a.scopeKind === "subsystem" && a.capability === SUBSYSTEM_CAPABILITY && a.scopeId).map((a) => a.scopeId),
49846
+ ...subject.roleBindings.filter((b) => b.scopeKind === "subsystem" && b.scopeId).map((b) => b.scopeId)
49847
+ ];
49848
+ return [...new Set(ids)].filter((id) => parseSubsystemScope(id) !== null);
49849
+ }
49850
+ function judgedScopes(subject, capability, world) {
49851
+ const subsystems = capability === SUBSYSTEM_CAPABILITY ? namedSubsystemScopes(subject, world).map((id) => ({ kind: "subsystem", id })) : [];
49852
+ return [...allScopes(world), ...subsystems];
49853
+ }
49609
49854
  function ancestorUnitsOf(scope, world) {
49855
+ if (scope.kind === "subsystem") {
49856
+ return familyRootUnits(parseSubsystemScope(scope.id)?.projectId ?? "", world).map((s) => s.id);
49857
+ }
49610
49858
  if (scope.kind === "project") return familyRootUnits(scope.id, world).map((s) => s.id);
49611
49859
  return unitChain(scope.id, world).map((s) => s.id).filter((id) => id !== scope.id);
49612
49860
  }
@@ -49621,7 +49869,7 @@ function resolveVisibleScopes(subject, capability, world) {
49621
49869
  }
49622
49870
  const actionable = /* @__PURE__ */ new Map();
49623
49871
  const context = /* @__PURE__ */ new Map();
49624
- for (const scope of allScopes(world)) {
49872
+ for (const scope of judgedScopes(subject, capability, world)) {
49625
49873
  const effective = resolvePermission(subject, capability, scope.kind, scope.id, world);
49626
49874
  if (effective.value === "yes" || effective.value === "approval") {
49627
49875
  actionable.set(scopeKey(scope.kind, scope.id), {
@@ -49630,6 +49878,15 @@ function resolveVisibleScopes(subject, capability, world) {
49630
49878
  value: effective.value,
49631
49879
  context: false
49632
49880
  });
49881
+ const project2 = scope.kind === "subsystem" ? parseSubsystemScope(scope.id)?.projectId : void 0;
49882
+ if (project2 !== void 0 && !context.has(scopeKey("project", project2))) {
49883
+ context.set(scopeKey("project", project2), {
49884
+ scopeKind: "project",
49885
+ scopeId: project2,
49886
+ value: resolvePermission(subject, capability, "project", project2, world).value,
49887
+ context: true
49888
+ });
49889
+ }
49633
49890
  for (const unitId of ancestorUnitsOf(scope, world)) {
49634
49891
  const key = scopeKey("unit", unitId);
49635
49892
  if (context.has(key)) continue;
@@ -49645,6 +49902,26 @@ function resolveVisibleScopes(subject, capability, world) {
49645
49902
  const contextOnly = [...context.entries()].filter(([key]) => !actionable.has(key)).map(([, scope]) => scope);
49646
49903
  return [...actionable.values(), ...contextOnly];
49647
49904
  }
49905
+ function resolveSubsystemReach(subject, projectId, world) {
49906
+ if (subject.instanceAdmin) return [];
49907
+ const prefix = `${projectId}${SUBSYSTEM_SCOPE_SEPARATOR}`;
49908
+ const applies = (a) => a.subjectKind === "everyone" || a.subjectKind === "user" && subjectIdMatches(subject, a.subjectId);
49909
+ const scopeIds = [
49910
+ ...world.assignments.filter((a) => a.scopeKind === "subsystem" && a.scopeId?.startsWith(prefix) && applies(a)).map((a) => a.scopeId),
49911
+ ...subject.roleBindings.filter((b) => b.scopeKind === "subsystem" && b.scopeId?.startsWith(prefix)).map((b) => b.scopeId)
49912
+ ];
49913
+ const reach2 = [];
49914
+ for (const scopeId of new Set(scopeIds)) {
49915
+ const parsed = parseSubsystemScope(scopeId);
49916
+ if (!parsed || parsed.projectId !== projectId) continue;
49917
+ reach2.push({
49918
+ projectId,
49919
+ subsystemId: parsed.subsystemId,
49920
+ permission: resolvePermission(subject, SUBSYSTEM_CAPABILITY, "subsystem", scopeId, world)
49921
+ });
49922
+ }
49923
+ return reach2;
49924
+ }
49648
49925
 
49649
49926
  // src/server/authorization.ts
49650
49927
  function mergeRoles(stored) {
@@ -49665,15 +49942,20 @@ function gatherPermissionWorld(dataDir) {
49665
49942
  return gatherWorld(dataDir);
49666
49943
  }
49667
49944
  function resolveFamilyReach(dataDir, principal, projectIds) {
49668
- const reach2 = { readable: [], writable: [] };
49945
+ const reach2 = { readable: [], writable: [], subsystems: [] };
49669
49946
  if (!principal.authenticated || !principal.permissionSubject) return reach2;
49670
49947
  const world = gatherPermissionWorld(dataDir);
49671
49948
  for (const id of projectIds) {
49672
49949
  if (resolvePermission(principal.permissionSubject, "project:read", "project", id, world).value === "yes") reach2.readable.push(id);
49673
49950
  if (resolvePermission(principal.permissionSubject, "project:write", "project", id, world).value === "yes") reach2.writable.push(id);
49951
+ reach2.subsystems.push(...resolveSubsystemReach(principal.permissionSubject, id, world));
49674
49952
  }
49675
49953
  return reach2;
49676
49954
  }
49955
+ function explain2(dataDir, subject, capability, scopeKind, scopeId) {
49956
+ const world = gatherPermissionWorld(dataDir);
49957
+ return resolvePermission(subject, capability, scopeKind, scopeId, world);
49958
+ }
49677
49959
  function authorize(dataDir, principal, capability, scopeKind, scopeId) {
49678
49960
  if (!principal.authenticated || !principal.permissionSubject) {
49679
49961
  return { value: "no", source: "instance-default" };
@@ -50159,6 +50441,40 @@ function compare5(users, before, beforeId, after, afterId) {
50159
50441
  }
50160
50442
  return rows;
50161
50443
  }
50444
+ var subjectOfSetting = (subjectKind, subjectId) => subjectKind === "everyone" ? EVERYONE_ELSE : subjectId ?? "";
50445
+ function carryPlan(users, world, parentId, memberId, moved) {
50446
+ const scopes = new Set(moved.map((s) => `${parentId}/${s}`));
50447
+ const bySubject = /* @__PURE__ */ new Map();
50448
+ for (const a of world.assignments) {
50449
+ if (a.scopeKind !== "subsystem" || !scopes.has(a.scopeId ?? "") || a.capability !== "project:write") continue;
50450
+ if (a.value !== "yes" && a.value !== "no") continue;
50451
+ const subject = subjectOfSetting(a.subjectKind, a.subjectId);
50452
+ const held = bySubject.get(subject) ?? { scopes: [], values: [] };
50453
+ held.scopes.push(a.scopeId);
50454
+ held.values.push(a.value);
50455
+ bySubject.set(subject, held);
50456
+ }
50457
+ const own = new Set(world.assignments.filter((a) => a.scopeKind === "project" && a.scopeId === memberId && a.capability === "project:write").map((a) => subjectOfSetting(a.subjectKind, a.subjectId)));
50458
+ const rules = [];
50459
+ for (const [subject, held] of bySubject) {
50460
+ if (own.has(subject)) continue;
50461
+ rules.push({
50462
+ from: [...held.scopes].sort(),
50463
+ to: memberId,
50464
+ subject,
50465
+ kind: "assignment",
50466
+ value: held.values.includes("no") ? "no" : "yes",
50467
+ note: held.scopes.length > 1 ? "most restrictive wins" : "carried"
50468
+ });
50469
+ }
50470
+ for (const user of users) {
50471
+ for (const b of user.roleBindings ?? []) {
50472
+ if (b.scopeKind !== "subsystem" || !scopes.has(b.scopeId ?? "")) continue;
50473
+ rules.push({ from: [b.scopeId], to: memberId, subject: user.subject.userId || user.id, kind: "role", value: b.roleId, note: "not carried (can only narrow)" });
50474
+ }
50475
+ }
50476
+ return rules;
50477
+ }
50162
50478
  function narrowing(key, members) {
50163
50479
  if (!key.projects.some((e) => e.includes("::"))) {
50164
50480
  return null;
@@ -50341,6 +50657,7 @@ function configureSync(periodicSyncMinutes, skipIfClean) {
50341
50657
  // src/server/members.ts
50342
50658
  init_fs();
50343
50659
  init_project();
50660
+ var EVERYONE = "everyone";
50344
50661
  var UPGRADE_AREAS = ["projects.json", "auth/credentials.json"];
50345
50662
  var UPGRADE_VERB = "member-upgrade";
50346
50663
  var OPERATOR = { userId: "operator:host-doctor", kind: "service", issuer: "local" };
@@ -50536,7 +50853,7 @@ function recoverData(dataDir, fix) {
50536
50853
  return found;
50537
50854
  }
50538
50855
  var ROOT_RENAME_REFUSED = "renaming a hosted family root's id is not supported; a member can be renamed, or the root recreated";
50539
- function screen(dataDir, binding, tool, alias, path93) {
50856
+ function screen(dataDir, binding, tool, alias, path93, newId) {
50540
50857
  if (path93 !== void 0 && (tool === "sdd_add_member" || tool === "sdd_attach_member")) {
50541
50858
  const storage = parseMemberSource(path93).storage;
50542
50859
  if (storage === "path" || storage === "git") {
@@ -50554,7 +50871,10 @@ function screen(dataDir, binding, tool, alias, path93) {
50554
50871
  const users = listUsers(dataDir);
50555
50872
  const incoming = standIn(binding.projectId, alias, path93);
50556
50873
  const after = relink(world, [{ projectId: incoming, parentProjectId: binding.projectId }], [], [], []);
50557
- return { reachChanges: compare5(users, world, null, after, incoming).filter((r) => !r.equal) };
50874
+ const reachChanges = compare5(users, world, null, after, incoming).filter((r) => !r.equal);
50875
+ if (tool !== "sdd_promote_member" && tool !== "sdd_externalize_subsystem") return { reachChanges };
50876
+ const moved = tool === "sdd_externalize_subsystem" ? [alias] : partSubsystemIds(dataDir, binding.projectId, alias) ?? [];
50877
+ return { reachChanges, carried: carryPlan(users, world, binding.projectId, newId || alias, moved) };
50558
50878
  }
50559
50879
  function screenDemote(dataDir, binding, alias) {
50560
50880
  const declared = planMemberRecords(dataDir, binding.projectId).find((d) => d.record.parentProjectId === binding.projectId && d.qualifier.endsWith(`${SUBPROJECT_SEPARATOR}${alias}`) && d.action !== "unreadable");
@@ -50589,7 +50909,8 @@ function reconcile(dataDir, principal, projectId, touched) {
50589
50909
  setProjectRecordStatus(dataDir, r.id, "disabled");
50590
50910
  changes.push({ kind: "departed", id: r.id, detail: { parent: r.parentProjectId, memberPath: r.memberPath } });
50591
50911
  }
50592
- const reachChanges = reachOf2(users, world, changes);
50912
+ const carried = carrySubsystemRules(dataDir, listChangedLinks(changes.filter((c) => c.kind === "registered" || c.kind === "returned")));
50913
+ const reachChanges = [...reachOf2(users, world, changes, listAssignments(dataDir)), ...carried];
50593
50914
  const ids = [.../* @__PURE__ */ new Set([...touched, ...changes.map((c) => c.id)])];
50594
50915
  for (const id of ids) auditTouched(dataDir, principal, projectId, id, changes.find((c) => c.id === id), reachChanges);
50595
50916
  const commit3 = publishFamily(dataDir, ids, `wairon: reconcile the family of ${projectId} (${ids.join(", ")})`);
@@ -50612,16 +50933,28 @@ function rekey(dataDir, world, users, m) {
50612
50933
  if (refused2) {
50613
50934
  return { kind: "registered", id: newId, detail: { parent: m.record.parentProjectId, memberPath: m.record.memberPath, rekeyRefused: oldId } };
50614
50935
  }
50615
- const moved = { assignments: world.assignments.filter((a) => a.scopeKind === "project" && a.scopeId === oldId).length };
50616
- remapScope(dataDir, [{ oldId, newId }]);
50617
- remapUnitReferences(dataDir, [{ oldId, newId }], []);
50936
+ const subsystemRemap = subsystemScopesOf(world, users, oldId).map((scopeId) => ({ oldId: scopeId, newId: `${newId}${scopeId.slice(oldId.length)}` }));
50937
+ const moved = {
50938
+ assignments: world.assignments.filter((a) => a.scopeKind === "project" && a.scopeId === oldId).length,
50939
+ subsystemRules: subsystemRemap.map((r) => `${r.oldId} -> ${r.newId}`)
50940
+ };
50941
+ remapScope(dataDir, [{ oldId, newId }, ...subsystemRemap]);
50942
+ remapUnitReferences(dataDir, [{ oldId, newId }, ...subsystemRemap], []);
50618
50943
  const keys = listCredentials(dataDir, oldId).filter((k) => k.projects.includes(oldId) && !k.revokedAt);
50619
50944
  for (const k of keys) renarrowCredential(dataDir, k.id, [...new Set(k.projects.map((e) => e === oldId ? newId : e))]);
50620
50945
  removeProjectRecord(dataDir, oldId);
50621
50946
  return { kind: "renamed", id: newId, previousId: oldId, detail: { ...moved, keys: keys.map((k) => k.id) } };
50622
50947
  }
50948
+ function subsystemScopesOf(world, users, id) {
50949
+ const prefix = `${id}/`;
50950
+ const ids = [
50951
+ ...world.assignments.filter((a) => a.scopeKind === "subsystem" && a.scopeId?.startsWith(prefix)).map((a) => a.scopeId),
50952
+ ...users.flatMap((u) => (u.roleBindings ?? []).filter((b) => b.scopeKind === "subsystem" && b.scopeId?.startsWith(prefix)).map((b) => b.scopeId))
50953
+ ];
50954
+ return [...new Set(ids)];
50955
+ }
50623
50956
  function hasOwnSettings(world, users, id) {
50624
- return world.assignments.some((a) => a.scopeKind === "project" && a.scopeId === id) || users.some((u) => (u.roleBindings ?? []).some((b) => b.scopeKind === "project" && b.scopeId === id));
50957
+ return world.assignments.some((a) => a.scopeKind === "project" && a.scopeId === id) || users.some((u) => (u.roleBindings ?? []).some((b) => b.scopeKind === "project" && b.scopeId === id)) || subsystemScopesOf(world, users, id).length > 0;
50625
50958
  }
50626
50959
  function departedRecords(family, declared, changes) {
50627
50960
  const live = new Set(declared.filter((d) => d.action !== "unreadable").map((d) => d.record.id));
@@ -50629,16 +50962,52 @@ function departedRecords(family, declared, changes) {
50629
50962
  const rekeyed = new Set(changes.filter((c) => c.kind === "renamed").map((c) => c.previousId));
50630
50963
  return family.filter((r) => r.parentProjectId !== void 0 && r.status === "active" && !live.has(r.id) && !rekeyed.has(r.id) && !held.includes(`${r.parentProjectId}/${r.memberPath}`));
50631
50964
  }
50632
- function reachOf2(users, world, changes) {
50965
+ function reachOf2(users, world, changes, assignmentsAfter) {
50633
50966
  const links = listChangedLinks(changes);
50634
50967
  const departed = changes.filter((c) => c.kind === "departed" || c.kind === "retired").map((c) => c.id);
50635
- const after = relink(world, links, departed, [], []);
50968
+ const after = { ...relink(world, links, departed, [], []), assignments: assignmentsAfter };
50636
50969
  return changes.filter((c) => c.kind !== "renamed").flatMap((c) => {
50637
50970
  const before = c.kind === "registered" || c.kind === "returned" ? null : c.id;
50638
50971
  const now = c.kind === "departed" ? null : c.kind === "retired" ? String(c.detail.parent ?? "") || null : c.id;
50639
50972
  return compare5(users, world, before, after, now).filter((r) => !r.equal).map((r) => ({ ...r, memberId: c.id }));
50640
50973
  });
50641
50974
  }
50975
+ function carrySubsystemRules(dataDir, members) {
50976
+ const rows = [];
50977
+ for (const { projectId: memberId, parentProjectId: parentId } of members) {
50978
+ const parentHolds = declaredSubsystemIds(dataDir, parentId) ?? [];
50979
+ const moved = (declaredSubsystemIds(dataDir, memberId) ?? []).filter((s) => !parentHolds.includes(s));
50980
+ if (moved.length === 0) continue;
50981
+ const plan7 = carryPlan(listUsers(dataDir), gatherPermissionWorld(dataDir), parentId, memberId, moved);
50982
+ for (const rule of plan7.filter((r) => r.kind === "assignment")) {
50983
+ const everyone = rule.subject === EVERYONE;
50984
+ setAssignment(dataDir, {
50985
+ id: "",
50986
+ subjectKind: everyone ? "everyone" : "user",
50987
+ ...everyone ? {} : { subjectId: rule.subject },
50988
+ scopeKind: "project",
50989
+ scopeId: memberId,
50990
+ capability: "project:write",
50991
+ value: rule.value,
50992
+ createdAt: ""
50993
+ });
50994
+ rows.push(carriedRow(rule));
50995
+ }
50996
+ }
50997
+ return rows;
50998
+ }
50999
+ function carriedRow(rule) {
51000
+ const source = rule.subject === EVERYONE ? "everyone-default" : "user";
51001
+ const value = rule.value;
51002
+ return {
51003
+ memberId: rule.to,
51004
+ subjectId: rule.subject,
51005
+ capability: "project:write",
51006
+ before: { value, source, decidedScopeKind: "subsystem", decidedScopeId: rule.from.join(", ") },
51007
+ after: { value, source, decidedScopeKind: "project", decidedScopeId: rule.to },
51008
+ equal: true
51009
+ };
51010
+ }
50642
51011
  function listChangedLinks(changes) {
50643
51012
  return changes.filter((c) => c.kind === "registered" || c.kind === "returned" || c.kind === "relocated").map((c) => ({ projectId: c.id, parentProjectId: String(c.detail.parent) }));
50644
51013
  }
@@ -52207,7 +52576,17 @@ function generateDiagram(cfg, credential, project2, format) {
52207
52576
  requireAdmin(credential);
52208
52577
  const root = existingProjectRoot(cfg.dataDir, project2);
52209
52578
  if (!root) throw new Error(`Unknown project "${project2}".`);
52210
- return runWithProjectRoot(root, () => renderDiagram(format));
52579
+ const relations = format === "canvas" ? instanceRelations(cfg, project2, root) : void 0;
52580
+ return runWithProjectRoot(root, () => renderDiagram(format, relations));
52581
+ }
52582
+ function instanceRelations(cfg, project2, root) {
52583
+ const familyRoot = listFamilyRecords(cfg.dataDir, project2)[0];
52584
+ const reach2 = {
52585
+ topRoot: familyRoot?.rootPath || root,
52586
+ parentReach: true,
52587
+ hostedLookup: (id) => existingProjectRoot(cfg.dataDir, id)
52588
+ };
52589
+ return runWithProjectBinding(root, reach2, () => familyRelations2());
52211
52590
  }
52212
52591
  function downloadDiagram(cfg, credential, project2, format) {
52213
52592
  return generateDiagram(cfg, credential, project2, format);
@@ -52756,6 +53135,7 @@ function setAssignment2(cfg, credential, assignment) {
52756
53135
  "the instance-admin marker ('*'@instance) is reserved to the built-in admin account"
52757
53136
  );
52758
53137
  }
53138
+ refuseUnfitSubsystemAssignment(assignment);
52759
53139
  requireAdminOverScope(
52760
53140
  cfg,
52761
53141
  principal,
@@ -52763,8 +53143,9 @@ function setAssignment2(cfg, credential, assignment) {
52763
53143
  assignment.scopeId ?? "",
52764
53144
  "setting an assignment requires project:admin over its scope"
52765
53145
  );
53146
+ const { scopeNote: _label, ...unlabelled } = assignment;
52766
53147
  const stored = setAssignment(cfg.dataDir, {
52767
- ...assignment,
53148
+ ...unlabelled,
52768
53149
  // Canonical grid key: user-kind subjects store the subject's userId, so
52769
53150
  // permission rules' primary match (not the legacy-alias path) serves them.
52770
53151
  ...assignment.subjectKind === "user" && assignment.subjectId ? { subjectId: canonicalSubjectId(cfg, assignment.subjectId) } : {},
@@ -52773,6 +53154,27 @@ function setAssignment2(cfg, credential, assignment) {
52773
53154
  tryAppendAudit(cfg, buildAuditEvent(principal, "permission.set", stored.id));
52774
53155
  return stored;
52775
53156
  }
53157
+ var SUBSYSTEM_VALUES = /* @__PURE__ */ new Set(["yes", "no", "inherit"]);
53158
+ function refuseUnfitSubsystemAssignment(assignment) {
53159
+ if (assignment.scopeKind !== "subsystem") return;
53160
+ const fits = String(assignment.capability) === "project:write" && SUBSYSTEM_VALUES.has(String(assignment.value)) && parseSubsystemScope(assignment.scopeId ?? "") !== null;
53161
+ if (!fits) {
53162
+ throw new ForbiddenError(
53163
+ `a subsystem scope carries only project:write, yes or no (or inherit), at <projectId>/<subsystemId> \u2014 got ${assignment.capability}=${assignment.value} at "${assignment.scopeId ?? ""}"`
53164
+ );
53165
+ }
53166
+ }
53167
+ function refuseUnfitSubsystemBinding(scopeKind, scopeId) {
53168
+ if (scopeKind === "subsystem" && parseSubsystemScope(scopeId ?? "") === null) {
53169
+ throw new ForbiddenError(`a subsystem scope id is <projectId>/<subsystemId> \u2014 got "${scopeId ?? ""}"`);
53170
+ }
53171
+ }
53172
+ function subsystemMissing(cfg, scopeId, cache2) {
53173
+ const parsed = parseSubsystemScope(scopeId);
53174
+ if (!parsed) return true;
53175
+ if (!cache2.has(parsed.projectId)) cache2.set(parsed.projectId, declaredSubsystemIds(cfg.dataDir, parsed.projectId));
53176
+ return !(cache2.get(parsed.projectId) ?? []).includes(parsed.subsystemId);
53177
+ }
52776
53178
  function removeAssignment2(cfg, credential, assignmentId) {
52777
53179
  const principal = requirePrincipal3(cfg, credential);
52778
53180
  const existing = getAssignment(cfg.dataDir, assignmentId);
@@ -52801,13 +53203,15 @@ function listAssignments2(cfg, credential, scopeKind, scopeId, subjectKind, subj
52801
53203
  const subjectIds = subjectKind === "user" && subjectId ? [.../* @__PURE__ */ new Set([subjectId, canonicalSubjectId(cfg, subjectId)])] : void 0;
52802
53204
  const assignments = subjectIds ? subjectIds.flatMap((id) => listAssignments(cfg.dataDir, void 0, subjectKind, id)) : listAssignments(cfg.dataDir, void 0, subjectKind, subjectId);
52803
53205
  const seen = /* @__PURE__ */ new Set();
52804
- return assignments.filter((a) => {
53206
+ const listed = assignments.filter((a) => {
52805
53207
  if (seen.has(a.id)) return false;
52806
53208
  seen.add(a.id);
52807
53209
  if (scopeKind !== void 0 && a.scopeKind !== scopeKind) return false;
52808
53210
  if (scopeId !== void 0 && (a.scopeId ?? "") !== scopeId) return false;
52809
53211
  return true;
52810
53212
  });
53213
+ const trees = /* @__PURE__ */ new Map();
53214
+ return listed.map((a) => a.scopeKind === "subsystem" && subsystemMissing(cfg, a.scopeId ?? "", trees) ? { ...a, scopeNote: SUBSYSTEM_NOT_FOUND } : a);
52811
53215
  }
52812
53216
  function bindingScope(scopeKind, scopeId) {
52813
53217
  return { kind: scopeKind ?? "instance", id: scopeId ?? "" };
@@ -52817,6 +53221,7 @@ function sameBinding(a, b) {
52817
53221
  }
52818
53222
  function bindRole(cfg, credential, userId, roleId, scopeKind, scopeId) {
52819
53223
  const principal = requirePrincipal3(cfg, credential);
53224
+ refuseUnfitSubsystemBinding(scopeKind, scopeId);
52820
53225
  const scope = bindingScope(scopeKind, scopeId);
52821
53226
  requireAdminOverScope(cfg, principal, scope.kind, scope.id, "binding a role requires project:admin over the scope");
52822
53227
  const user = getUserById(cfg.dataDir, userId);
@@ -52852,6 +53257,27 @@ function unbindRole(cfg, credential, userId, roleId, scopeKind, scopeId) {
52852
53257
  tryAppendAudit(cfg, buildAuditEvent(principal, "role.unbind", `${userId}:${roleId}`));
52853
53258
  return stored;
52854
53259
  }
53260
+ function subjectOfUser(user) {
53261
+ const subjectId = user.subject.userId || user.id;
53262
+ const aliases = [user.id, user.subject.userId].filter((id) => !!id && id !== subjectId);
53263
+ return {
53264
+ subjectId,
53265
+ ...aliases.length ? { aliasSubjectIds: [...new Set(aliases)] } : {},
53266
+ roleBindings: user.roleBindings ?? [],
53267
+ instanceAdmin: false
53268
+ };
53269
+ }
53270
+ function explain3(cfg, credential, userId, capability, scopeKind, scopeId) {
53271
+ const principal = requirePrincipal3(cfg, credential);
53272
+ requireAdminOverScope(cfg, principal, scopeKind, scopeId, "explaining a permission requires project:admin over its scope");
53273
+ const user = findUserByRecordOrSubjectId(cfg.dataDir, userId);
53274
+ if (!user) throw new Error(`User "${userId}" not found.`);
53275
+ const explained = explain2(cfg.dataDir, subjectOfUser(user), capability, scopeKind, scopeId);
53276
+ if (scopeKind === "subsystem" && subsystemMissing(cfg, scopeId, /* @__PURE__ */ new Map())) {
53277
+ return { ...explained, scopeNote: SUBSYSTEM_NOT_FOUND };
53278
+ }
53279
+ return explained;
53280
+ }
52855
53281
 
52856
53282
  // src/server/identity.ts
52857
53283
  var crypto19 = __toESM(require("crypto"));
@@ -56118,6 +56544,7 @@ function lockTargetOf(cfg, req) {
56118
56544
  function lifecycleAction(cfg, credential, projectId, opts) {
56119
56545
  const principal = requirePrincipal7(cfg, credential);
56120
56546
  const effective = authorize(cfg.dataDir, principal, PROJECT_WRITE_CAPABILITY4, "project", projectId);
56547
+ if (effective.value !== "no") refuseDeniedSubsystems(cfg, principal, projectId);
56121
56548
  switch (effective.value) {
56122
56549
  case "yes": {
56123
56550
  const outcome = opts.execute(hostedApprover(principal.subject));
@@ -56149,6 +56576,13 @@ function lifecycleAction(cfg, credential, projectId, opts) {
56149
56576
  }
56150
56577
  }
56151
56578
  }
56579
+ function refuseDeniedSubsystems(cfg, principal, projectId) {
56580
+ const denied = resolveFamilyReach(cfg.dataDir, principal, [projectId]).subsystems.filter((s) => s.projectId === projectId && s.permission.value === "no");
56581
+ if (denied.length === 0) return;
56582
+ throw new ForbiddenError(
56583
+ "a lock signs the whole tree, and the caller may not change these subsystems: " + denied.map((s) => `${s.subsystemId} (decided by ${describeDecision(s.permission)})`).join(", ")
56584
+ );
56585
+ }
56152
56586
  function getApprovalStatus(cfg, credential, requestId) {
56153
56587
  const principal = requirePrincipal7(cfg, credential);
56154
56588
  expirePendingApprovals(cfg.dataDir, (/* @__PURE__ */ new Date()).toISOString());
@@ -57431,6 +57865,9 @@ function bindRole2(cfg, sessionId, userId, roleId, scopeKind, scopeId) {
57431
57865
  function unbindRole2(cfg, sessionId, userId, roleId, scopeKind, scopeId) {
57432
57866
  return unbindRole(cfg, sessionId, userId, roleId, scopeKind, scopeId);
57433
57867
  }
57868
+ function explainPermission(cfg, sessionId, userId, capability, scopeKind, scopeId) {
57869
+ return explain3(cfg, sessionId, userId, capability, scopeKind, scopeId);
57870
+ }
57434
57871
 
57435
57872
  // src/server/webproject.ts
57436
57873
  function listProjects2(cfg, sessionId) {
@@ -58137,7 +58574,9 @@ function getWebProjectCanvas(cfg, sessionId, projectId) {
58137
58574
  if (!root) {
58138
58575
  throw new ForbiddenError("project not authorized or unknown");
58139
58576
  }
58140
- return runWithProjectRoot(root, () => renderDiagram("canvas"));
58577
+ const listed = listProjects2(cfg, sessionId);
58578
+ const relations = runWithProjectBinding(root, readReach(cfg, projectId, root, listed), () => familyRelations2());
58579
+ return runWithProjectRoot(root, () => renderDiagram("canvas", relations));
58141
58580
  }
58142
58581
  function getWebProjectCanvasModel(cfg, sessionId, projectId) {
58143
58582
  const principal = authenticateSession(cfg.dataDir, sessionId);
@@ -60323,6 +60762,9 @@ function adminUnbindRole(cfg, sessionId, userId, roleId, scopeKind, scopeId) {
60323
60762
  function adminListAssignments(cfg, sessionId, scopeKind, scopeId, subjectKind, subjectId) {
60324
60763
  return listAssignments3(cfg, sessionId, scopeKind, scopeId, subjectKind, subjectId);
60325
60764
  }
60765
+ function adminExplainPermission(cfg, sessionId, userId, capability, scopeKind, scopeId) {
60766
+ return explainPermission(cfg, sessionId, userId, capability, scopeKind, scopeId ?? "");
60767
+ }
60326
60768
  function adminSetAssignment(cfg, sessionId, assignment) {
60327
60769
  return setAssignment3(cfg, sessionId, assignment);
60328
60770
  }
@@ -60824,6 +61266,10 @@ async function handleWebRequest(cfg, req, res, body, url, ctx) {
60824
61266
  )
60825
61267
  });
60826
61268
  }
61269
+ if (req.method === "GET" && parts.length === 4 && parts[2] === "permissions" && parts[3] === "explain") {
61270
+ const q2 = (name) => url.searchParams.get(name) ?? "";
61271
+ return sendJson(res, 200, adminExplainPermission(cfg, sessionId, q2("userId"), q2("capability"), q2("scopeKind") || "instance", q2("scopeId") || void 0));
61272
+ }
60827
61273
  if (req.method === "POST" && parts.length === 3 && parts[2] === "permissions") {
60828
61274
  return sendJson(res, 200, adminSetAssignment(cfg, sessionId, body));
60829
61275
  }
@@ -61209,6 +61655,23 @@ var WRITE_TOOL_PREFIXES = [
61209
61655
  "sdd_promote_",
61210
61656
  "sdd_demote_"
61211
61657
  ];
61658
+ var SUBSYSTEM_SCOPED_WRITE_TOOLS = /* @__PURE__ */ new Set([
61659
+ "sdd_add_subsystem",
61660
+ "sdd_add_component",
61661
+ "sdd_add_type",
61662
+ "sdd_define_interface",
61663
+ "sdd_write_narrative",
61664
+ "sdd_set_endpoints",
61665
+ "sdd_set_public_interfaces",
61666
+ "sdd_update_spec",
61667
+ "sdd_delete_spec",
61668
+ "sdd_rename_component",
61669
+ "sdd_rename_method",
61670
+ "sdd_move_methods"
61671
+ ]);
61672
+ function isSubsystemScopedWrite(toolName) {
61673
+ return SUBSYSTEM_SCOPED_WRITE_TOOLS.has(toolName);
61674
+ }
61212
61675
  var READ_TOOL_PREFIXES = ["sdd_get_", "sdd_validate_"];
61213
61676
  var READ_TOOL_NAMES = /* @__PURE__ */ new Set([
61214
61677
  "listAgents",
@@ -61244,18 +61707,22 @@ function mcpChangeChannels(body, projectId, response) {
61244
61707
  if (MUTATING_HOST_TOOLS.has(name)) return [`project:${projectId}`, "projects"];
61245
61708
  return [];
61246
61709
  }
61247
- function dataPlanePermissionError(cfg, principal, projectId, body) {
61710
+ function dataPlanePermissionError(cfg, principal, projectId, body, reach2) {
61248
61711
  const msg = jsonRpcRequest(body);
61249
61712
  if (!msg || msg.method !== "tools/call") return void 0;
61250
61713
  const name = msg.params?.name;
61251
61714
  if (typeof name !== "string" || name.length === 0) return void 0;
61252
61715
  const needed = requiredDataPlaneCapability(name);
61253
- if (authorize(cfg.dataDir, principal, needed, "project", projectId).value === "yes") return void 0;
61716
+ const decided = authorize(cfg.dataDir, principal, needed, "project", projectId);
61717
+ if (decided.value === "yes") return void 0;
61718
+ if (needed === "project:write" && isSubsystemScopedWrite(name) && reach2.subsystems.some((s) => s.projectId === projectId && s.permission.value === "yes")) {
61719
+ return void 0;
61720
+ }
61254
61721
  return {
61255
61722
  jsonrpc: "2.0",
61256
61723
  id: msg.id ?? null,
61257
61724
  result: {
61258
- content: [{ type: "text", text: `Forbidden \u2014 permission ${needed} required for ${name}` }],
61725
+ content: [{ type: "text", text: `Forbidden \u2014 permission ${needed} required for ${name}, decided at ${describeDecision(decided)}` }],
61259
61726
  isError: true
61260
61727
  }
61261
61728
  };
@@ -61431,7 +61898,7 @@ async function handleMcpRequest(cfg, req, res, body, credential) {
61431
61898
  for (const ch of mcpChangeChannels(body, projectId, dispatchedResponse)) publishChange(ch);
61432
61899
  return;
61433
61900
  }
61434
- const permissionError = dataPlanePermissionError(cfg, principal, projectId, body);
61901
+ const permissionError = dataPlanePermissionError(cfg, principal, projectId, body, reach2.family);
61435
61902
  if (permissionError !== void 0) {
61436
61903
  sendJson(res, 200, permissionError);
61437
61904
  auditToolCall(cfg.dataDir, principal, binding, body, deriveMcpOutcome(permissionError));
@@ -61471,7 +61938,11 @@ async function handleMcpRequest(cfg, req, res, body, credential) {
61471
61938
  for (const ch of mcpChangeChannels(body, projectId, response)) publishChange(ch);
61472
61939
  });
61473
61940
  }
61474
- var MEMBERSHIP_TOOLS = /* @__PURE__ */ new Set(["sdd_attach_member", "sdd_adopt_member", "sdd_detach_member", "sdd_rename_project", "sdd_promote_member", "sdd_demote_member", "sdd_add_member"]);
61941
+ var MEMBERSHIP_TOOLS = /* @__PURE__ */ new Set(["sdd_attach_member", "sdd_adopt_member", "sdd_detach_member", "sdd_rename_project", "sdd_promote_member", "sdd_demote_member", "sdd_add_member", "sdd_externalize_subsystem"]);
61942
+ function isMembershipCall(call) {
61943
+ if (call.name === "sdd_externalize_subsystem") return call.args.as === "project";
61944
+ return MEMBERSHIP_TOOLS.has(call.name);
61945
+ }
61475
61946
  var FAMILY_SHAPE_TOOLS = /* @__PURE__ */ new Set([
61476
61947
  "sdd_add_member",
61477
61948
  "sdd_move_member",
@@ -61493,7 +61964,7 @@ function reachLines(rows) {
61493
61964
  }
61494
61965
  function serveMembershipTool(cfg, principal, binding, body) {
61495
61966
  const call = toolCall(body);
61496
- if (!call || !MEMBERSHIP_TOOLS.has(call.name)) return {};
61967
+ if (!call || !isMembershipCall(call)) return {};
61497
61968
  const apply4 = call.args.dryRun !== true;
61498
61969
  const answer = (result) => ({ response: { jsonrpc: "2.0", id: call.id, result } });
61499
61970
  try {
@@ -61503,9 +61974,10 @@ function serveMembershipTool(cfg, principal, binding, body) {
61503
61974
  case "sdd_adopt_member":
61504
61975
  return answer(relocationResult("adopt", adopt(cfg.dataDir, principal, binding, String(call.args.alias ?? ""), String(call.args.path ?? ""), apply4), apply4));
61505
61976
  default: {
61506
- const named2 = call.name === "sdd_rename_project" ? String(call.args.project ?? "") : String(call.args.alias ?? "");
61977
+ const named2 = call.name === "sdd_rename_project" ? String(call.args.project ?? "") : call.name === "sdd_externalize_subsystem" ? String(call.args.subsystem ?? "") : String(call.args.alias ?? "");
61507
61978
  const located = typeof call.args.source === "string" ? call.args.source : typeof call.args.path === "string" ? call.args.path : void 0;
61508
- const screened = screen(cfg.dataDir, binding, call.name, named2, located);
61979
+ const newId = call.name === "sdd_promote_member" && typeof call.args.id === "string" && call.args.id ? call.args.id : void 0;
61980
+ const screened = screen(cfg.dataDir, binding, call.name, named2, located, newId);
61509
61981
  if (screened.refusal !== void 0) return answer({ content: [{ type: "text", text: screened.refusal }], isError: true });
61510
61982
  return { screen: screened };
61511
61983
  }
@@ -61549,6 +62021,10 @@ function withReachListing(result, screened, body) {
61549
62021
  const lines = reachLines(screened.reachChanges);
61550
62022
  r.content.push({ type: "text", text: lines.length > 0 ? `Reach changes \u2014 who gains access through the new parent:
61551
62023
  ${lines.join("\n")}` : "Reach changes: none \u2014 nobody gains or loses access." });
62024
+ if (!screened.carried) return;
62025
+ const carried = screened.carried.map((c) => `${c.from.join(", ")} -> project ${c.to}: ${c.subject} ` + (c.kind === "role" ? `role ${c.value} \u2014 ${c.note}` : `project:write ${c.value} (${c.note})`));
62026
+ r.content.push({ type: "text", text: carried.length > 0 ? `Subsystem rules carried onto the new project:
62027
+ ${carried.join("\n")}` : "Subsystem rules carried onto the new project: none." });
61552
62028
  }
61553
62029
  function reconcileAfter(cfg, principal, binding, body, response) {
61554
62030
  const call = toolCall(body);
@@ -61591,7 +62067,14 @@ function familyReachOf(cfg, principal, binding) {
61591
62067
  const topRoot = existingProjectRoot(cfg.dataDir, binding.familyRootId) ?? binding.rootPath;
61592
62068
  const parentReach = reach2.readable.includes(binding.familyRootId) && covered(binding.familyRootId);
61593
62069
  const unwritableRoots = family.filter((r) => r.rootPath && !isRetiredPart(r) && !(reach2.writable.includes(r.id) && covered(r.id))).map((r) => r.rootPath);
61594
- return { topRoot, parentReach, unwritableRoots, hostedLookup: hostedRecordLookup(cfg, principal) };
62070
+ const roots = new Map(family.filter((r) => r.rootPath && !isRetiredPart(r)).map((r) => [r.id, r.rootPath]));
62071
+ const subsystems = reach2.subsystems.flatMap((s) => {
62072
+ const root = roots.get(s.projectId);
62073
+ if (!root) return [];
62074
+ return [{ root, subsystemId: s.subsystemId, writable: s.permission.value === "yes" && covered(s.projectId), decidedBy: describeDecision(s.permission) }];
62075
+ });
62076
+ const writeReach = { unwritableRoots, subsystems };
62077
+ return { topRoot, parentReach, unwritableRoots, hostedLookup: hostedRecordLookup(cfg, principal), writeReach, family: reach2 };
61595
62078
  }
61596
62079
  function hostedRecordLookup(cfg, principal) {
61597
62080
  return (recordId) => {
@@ -63133,6 +63616,10 @@ function printMemberUpgrade(upgrade, fix) {
63133
63616
  var CAPABILITIES = ["project:read", "project:create", "project:write", "project:admin", "approval:decide", "share:create"];
63134
63617
  var PERMISSION_VALUES = ["yes", "approval", "no", "inherit"];
63135
63618
  function resolveScopeOptions(options) {
63619
+ if (options.subsystem) {
63620
+ if (!options.project) throw new WaironError("`--subsystem <id>` needs `--project <id>`: a subsystem scope is `<project>/<subsystem>`.");
63621
+ return { kind: "subsystem", id: `${options.project}/${options.subsystem}` };
63622
+ }
63136
63623
  if (options.project) return { kind: "project", id: options.project };
63137
63624
  if (options.unit) return { kind: "unit", id: options.unit };
63138
63625
  return { kind: "instance" };
@@ -63172,7 +63659,7 @@ async function runHostPermission(action, options = {}) {
63172
63659
  const list2 = listAssignments2(
63173
63660
  cfg,
63174
63661
  cred,
63175
- options.project || options.unit || options.instance ? scope.kind : void 0,
63662
+ options.project || options.unit || options.instance || options.subsystem ? scope.kind : void 0,
63176
63663
  scope.id,
63177
63664
  options.user ? "user" : void 0,
63178
63665
  options.user
@@ -63182,7 +63669,7 @@ async function runHostPermission(action, options = {}) {
63182
63669
  } else {
63183
63670
  for (const a of list2) {
63184
63671
  const subject = a.subjectKind === "everyone" ? "everyone" : a.subjectId;
63185
- logger.info(` ${a.id} ${String(subject).padEnd(20)} ${a.capability.padEnd(16)} ${a.value.padEnd(9)} ${a.scopeKind}${a.scopeId ? ` ${a.scopeId}` : ""}`);
63672
+ logger.info(` ${a.id} ${String(subject).padEnd(20)} ${a.capability.padEnd(16)} ${a.value.padEnd(9)} ${a.scopeKind}${a.scopeId ? ` ${a.scopeId}` : ""}${a.scopeNote ? ` (${a.scopeNote})` : ""}`);
63186
63673
  }
63187
63674
  }
63188
63675
  break;
@@ -64720,12 +65207,13 @@ hostCmd.command("unit <action>").description("create an organization unit (proje
64720
65207
  hostCmd.command("doctor").description("migrate a hosted data dir to the permission model (grants \u2192 assignments, owners, unit ids, placements); dry-run without --fix").option("--fix", "apply the migration (REQUIRED at rollout \u2014 legacy users/tokens otherwise resolve to zero permissions)").option("--data-dir <path>", "data root").action(async (opts) => {
64721
65208
  await runHostDoctor({ fix: opts.fix, dataDir: opts.dataDir });
64722
65209
  });
64723
- hostCmd.command("permission <action>").description("set | list | remove a permission assignment (the subject\xD7scope\xD7capability grid)").option("--user <userId>", "the subject user id (for set; optional filter for list)").option("--capability <capability>", "project:read | project:create | project:write | project:admin | approval:decide").option("--value <value>", "yes | approval | no | inherit", "yes").option("--project <id>", "anchor the assignment at a project scope").option("--unit <unitId>", "anchor the assignment at an organization-unit scope").option("--instance", "anchor the assignment at the instance scope (the default)").option("--id <assignmentId>", "assignment id (for remove)").option("--data-dir <path>", "data root").action(async (action, opts) => {
65210
+ hostCmd.command("permission <action>").description("set | list | remove a permission assignment (the subject\xD7scope\xD7capability grid)").option("--user <userId>", "the subject user id (for set; optional filter for list)").option("--capability <capability>", "project:read | project:create | project:write | project:admin | approval:decide").option("--value <value>", "yes | approval | no | inherit", "yes").option("--project <id>", "anchor the assignment at a project scope").option("--subsystem <id>", "with --project: anchor at a subsystem of that project (project:write only, yes | no | inherit)").option("--unit <unitId>", "anchor the assignment at an organization-unit scope").option("--instance", "anchor the assignment at the instance scope (the default)").option("--id <assignmentId>", "assignment id (for remove)").option("--data-dir <path>", "data root").action(async (action, opts) => {
64724
65211
  await runHostPermission(action, {
64725
65212
  user: opts.user,
64726
65213
  capability: opts.capability,
64727
65214
  value: opts.value,
64728
65215
  project: opts.project,
65216
+ subsystem: opts.subsystem,
64729
65217
  unit: opts.unit,
64730
65218
  instance: opts.instance,
64731
65219
  id: opts.id,