@awebai/oats 0.37.0 → 0.38.1

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/bin/oats.mjs CHANGED
@@ -36,7 +36,7 @@ import {
36
36
  writeFileAtomic, LOCK_FILE, readLock, readLockIfPresent, writeLock, resolvePackages, memoizedRemote,
37
37
  classifyPackageValue, parsePackageRequest } from "../lib/packages.mjs";
38
38
  import { loadLocal, validateWorkspace, validateLocal, discoverPackageSouls, workspaceWarnings, memberRowByKey } from "../lib/workspace.mjs";
39
- import { migrationProblems, recordedTeams, reportRows, soulKeyOf, soulTeams, teamModel } from "../lib/teams.mjs";
39
+ import { discoveredTeamKeys, isTeamRefusal, localTeamsClosedProblem, recordedTeams, reportRows, soulKeyOf, soulTeams, teamKeyOf, teamModel } from "../lib/teams.mjs";
40
40
  import { launchLayers } from "../lib/launch-preference.mjs";
41
41
  import { parseConfigData } from "../lib/config-data.mjs";
42
42
  import * as remoteModule from "../lib/remote.mjs";
@@ -221,17 +221,18 @@ async function doctorComposition(ctx, soulName, ws, bail) {
221
221
  } finally { for (const c of cleanups) { try { c(); } catch { /* best effort: temporary copies only */ } } }
222
222
  }
223
223
 
224
- /** team-model-3-migration in doctor (0.36.x), OFFLINE like the rest of doctor: oats-local.yaml, and for its
225
- * local teams the workspace file this machine's parsed cache holds (cachedWorkspace: no git process, no
226
- * network). Without that file, whether local teams need `localTeams: true` is said to be unchecked
227
- * (information), never guessed. The standalone view has no workspace rules. → { problems, information } */
228
- function doctorTeamMigration(local) {
224
+ /** Local teams the workspace does not allow (team model 3: E_WORKSPACE_SCHEMA local-teams-closed), in
225
+ * doctor, OFFLINE like the rest of doctor: oats-local.yaml, and the workspace file this machine's parsed
226
+ * cache holds (cachedWorkspace: no git process, no network). Without that file, whether the local teams are
227
+ * allowed is said to be unchecked (information), never guessed. The standalone view has no workspace
228
+ * rules. → { problems, information } */
229
+ function doctorLocalTeams(local) {
229
230
  const standalone = typeof local.standalone === "string" && local.standalone !== "";
230
231
  const file = standalone ? null : cachedWorkspace(local.workspace)?.file ?? null;
231
232
  const model = teamModel(file, local);
232
- const unchecked = !standalone && file === null && model.migration.teamKeys.length > 0;
233
- return { problems: migrationProblems(model),
234
- information: unchecked ? ["team-model-3-migration: whether oats-local.yaml teams/defaultTeam need localTeams: true couldn't be checked: this deployment hasn't observed its workspace yet; run oats sync"] : [] };
233
+ const declared = ["teams", "defaultTeam"].some((k) => Object.hasOwn(local, k));
234
+ return { problems: model.closedKeys.length ? [localTeamsClosedProblem(model.closedKeys)] : [],
235
+ information: !standalone && file === null && declared ? ["local-teams-closed: whether oats-workspace.yaml allows oats-local.yaml teams/defaultTeam (localTeams: true) couldn't be checked: this deployment hasn't observed its workspace yet; run oats sync"] : [] };
235
236
  }
236
237
 
237
238
  /** Workspace-model v2 doctor data, OFFLINE: the deployment declaration found
@@ -411,7 +412,9 @@ async function workspaceTarget(bail, { command, liveTeams = true }) {
411
412
  if (!existsSync(join(deployment, "oats-local.yaml"))) return bail("E_HOME_MISMATCH", `${homeFlag} is not at <deployment>/agents/<soul>/instances/<name>: ${deployment} has no oats-local.yaml`, { home: homeFlag, expected: join(deployment, "oats-local.yaml") });
412
413
  if (flag("dir") !== undefined) { let given = null; try { given = dirname(loadLocal(dirFlag()).path); } catch { given = dirFlag(); } if (realOrResolved(given) !== realOrResolved(deployment)) return bail("E_HOME_MISMATCH", `--dir ${dirFlag()} is not the deployment of ${homeFlag} (${deployment}); omit --dir for a home`); }
413
414
  if (rootFlag && realOrResolved(rootFlag) !== realOrResolved(join(deployment, "agents"))) return bail("E_HOME_MISMATCH", `--agents-root ${rootFlag} is not the agents root of ${homeFlag}`);
414
- return homeTarget(homeFlag, meta, { remoteOptions, discover: command === "readiness", live: liveTeams });
415
+ // A typed refusal (the deployment's removed team keys, team model 3) answers as one, as for --soul.
416
+ try { return await homeTarget(homeFlag, meta, { remoteOptions, discover: command === "readiness", live: liveTeams }); }
417
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
415
418
  }
416
419
  try { if (!isWorkspaceContext(dirFlag())) return bail("E_LOCAL_MISSING", `${command} reads a workspace deployment, and none is in reach of ${dirFlag()} (no oats-local.yaml walking up; \`oats onboard\` creates one) — or pass --home <abs> of a workspace instance`); }
417
420
  catch (e) { return bail(e?.code || "E_WORKSPACE_SCHEMA", e?.message || String(e), e?.details); }
@@ -597,13 +600,13 @@ function legacyLayoutProblems(root) {
597
600
  async function doctorWorkspaceJson(ctx, soulName, ws) {
598
601
  const composition = await doctorComposition(ctx, soulName, ws, (code, msg, details) => jsonFail(code, msg, details));
599
602
  const agentsRoot = join(dirname(ws.local.path), "agents");
600
- const migration = doctorTeamMigration(ws.local.value);
601
- const problems = [...legacyLayoutProblems(agentsRoot), readableInstanceHomes(agentsRoot), ...migration.problems].filter(Boolean);
603
+ const localTeams = doctorLocalTeams(ws.local.value);
604
+ const problems = [...legacyLayoutProblems(agentsRoot), readableInstanceHomes(agentsRoot), ...localTeams.problems].filter(Boolean);
602
605
  return {
603
606
  schemaVersion: 1, workspaceApi: 2, context: ctx,
604
607
  workspace: { file: ws.local.path, ref: ws.local.workspace },
605
608
  workspaceError: ws.localError, lockFile: ws.lockFile, packages: ws.packages, lockError: ws.lockError,
606
- information: [...(operationalKnowledgeNote(composition, soulName) ? [operationalKnowledgeNote(composition, soulName)] : []), ...migration.information],
609
+ information: [...(operationalKnowledgeNote(composition, soulName) ? [operationalKnowledgeNote(composition, soulName)] : []), ...localTeams.information],
607
610
  composedInstructions: composition?.text, instructionBlocks: composition?.blocks,
608
611
  ...(problems.length ? { problems } : {}),
609
612
  };
@@ -638,10 +641,10 @@ async function doctor(dir) {
638
641
  const composition = await doctorComposition(ctx, soulName, ws, (code, msg) => die(`${msg} [${code}]`));
639
642
  printDoctorWorkspace(ws);
640
643
  const agentsRoot = join(dirname(ws.local.path), "agents");
641
- const migration = doctorTeamMigration(ws.local.value);
644
+ const localTeams = doctorLocalTeams(ws.local.value);
642
645
  for (const p of [...legacyLayoutProblems(agentsRoot), readableInstanceHomes(agentsRoot)].filter(Boolean)) console.log(`\n! ${p.code}: ${p.message}`);
643
- for (const p of migration.problems) console.log(`\n! ${p.code}: ${p.message} — ${p.fix}`);
644
- for (const line of migration.information) console.log(`\nINFO: ${line}`);
646
+ for (const p of localTeams.problems) console.log(`\n! ${p.code} (${p.condition}): ${p.message}`);
647
+ for (const line of localTeams.information) console.log(`\nINFO: ${line}`);
645
648
  if (soulName) {
646
649
  const information = operationalKnowledgeNote(composition, soulName);
647
650
  if (information) console.log(`\nINFO: ${information}`);
@@ -1083,11 +1086,8 @@ function maxAgeRefusal(command, head) {
1083
1086
  case "spawn": return head.includes("--preview") ? null : refuse("spawn");
1084
1087
  case "workspace": return word(1) === "status" ? null : refuse(["workspace", word(1)].filter(Boolean).join(" "));
1085
1088
  case "teams": return word(1) === undefined ? null : refuse(`teams ${word(1)}`);
1086
- case "soul": {
1087
- if (word(1) !== "teams") return refuse(["soul", word(1)].filter(Boolean).join(" "));
1088
- const edit = ["--add", "--remove", "--default", "--clear-default"].find((f) => head.includes(f));
1089
- return edit ? refuse(`soul teams ${edit}`) : null;
1090
- }
1089
+ // `soul teams` only reads (its edit flags were removed in 0.38.0, and refuse as such).
1090
+ case "soul": return word(1) === "teams" ? null : refuse(["soul", word(1)].filter(Boolean).join(" "));
1091
1091
  default: {
1092
1092
  const sub = ["package", "schedule", "session", "trigger", "automations", "launch-config", "server", "instance", "operation", "pane"].includes(command) ? word(1) : undefined;
1093
1093
  return refuse([command, sub].filter(Boolean).join(" "));
@@ -1175,8 +1175,8 @@ function soulLaunchFacts(entry, local, { launchConfigs, contextDir }) {
1175
1175
  /** Rows of every soul and capability of confirmed members (+ external souls) + locked package
1176
1176
  * capabilities. Souls have no private mode (0.26.0); a private member capability is listed with
1177
1177
  * `private: true` — repo-owned: usable only by its own repo's souls (E_CAPABILITY_PRIVATE).
1178
- * Each soul row carries its teams HERE (team model v2: `teams`, `defaultTeam`), from the committed
1179
- * shared teams and `local` (oats-local.yaml); both null when its teams do not resolve (E_TEAM_*). */
1178
+ * Each soul row carries its teams HERE (team model 3: `teams`, `defaultTeam`), from the committed
1179
+ * workspace and the local teams it allows; both null when its teams do not resolve (isTeamRefusal). */
1180
1180
  function workspaceItems(discovery, lock, local, deploymentDir) {
1181
1181
  const souls = [];
1182
1182
  let launchConfigs = {};
@@ -1185,8 +1185,8 @@ function workspaceItems(discovery, lock, local, deploymentDir) {
1185
1185
  const capabilities = [];
1186
1186
  const model = teamModel(discovery.standalone === true ? null : discovery.workspace, local, { workspaceKey: discovery.key ?? null });
1187
1187
  const teamsHere = (entry) => {
1188
- try { const t = soulTeams(model, soulKeyOf(entry)); return { teams: reportRows(t.teams), defaultTeam: t.defaultTeam }; }
1189
- catch (e) { if (String(e?.code).startsWith("E_TEAM_")) return { teams: null, defaultTeam: null }; throw e; }
1188
+ try { const t = soulTeams(model, teamKeyOf(entry)); return { teams: reportRows(t.teams), defaultTeam: t.defaultTeam }; }
1189
+ catch (e) { if (isTeamRefusal(e)) return { teams: null, defaultTeam: null }; throw e; }
1190
1190
  };
1191
1191
  for (const m of discovery.members) {
1192
1192
  if (!m.confirmed && !(discovery.standalone === true && m.key === discovery.key)) continue;
@@ -1589,22 +1589,23 @@ async function workspaceStatusFacts(result, discovery, lock, ctx) {
1589
1589
  }
1590
1590
  }
1591
1591
 
1592
- /** The deployment's team facts for the team verbs: oats-local.yaml and the committed shared teams
1593
- * (the workspace host read now; none in a standalone view). */
1592
+ /** The deployment's team facts for the team verbs: oats-local.yaml, the committed workspace file and the
1593
+ * souls it offers (the workspace discovered now; none in a standalone view). */
1594
1594
  async function teamsContext(bail) {
1595
1595
  const ctx = workspaceContext(bail);
1596
- let workspace = null, workspaceKey = null;
1597
- if (!(typeof ctx.local.standalone === "string" && ctx.local.standalone)) {
1598
- try { const { observeWorkspace } = await import("../lib/workspace.mjs"); const obs = await observeWorkspace(ctx.local.workspace, { remoteOptions: ctx.remoteOptions }); workspace = obs.workspace; workspaceKey = obs.key; }
1599
- catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance); throw e; }
1600
- }
1601
- return { deployment: ctx.deploymentDir, localPath: ctx.localPath, local: ctx.local, workspace, workspaceKey, remoteOptions: ctx.remoteOptions };
1596
+ let discovery;
1597
+ try {
1598
+ const { discoverOrStandalone } = await import("../lib/instance-resolution.mjs");
1599
+ discovery = await discoverOrStandalone(ctx.local, { lock: readLockIfPresent(ctx.deploymentDir), deployment: ctx.deploymentDir, remoteOptions: ctx.remoteOptions });
1600
+ } catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance); throw e; }
1601
+ const standalone = discovery.standalone === true;
1602
+ return { deployment: ctx.deploymentDir, localPath: ctx.localPath, local: ctx.local, workspace: standalone ? null : discovery.workspace, workspaceKey: standalone ? null : discovery.key ?? null,
1603
+ soulKeys: discoveredTeamKeys(discovery), remoteOptions: ctx.remoteOptions };
1602
1604
  }
1603
- const labelsFlag = (name) => { const v = valueFlag(name); return v === undefined ? [] : String(v).split(",").map((l) => l.trim()).filter(Boolean); };
1604
1605
  const positional = (i) => (args[i] !== undefined && !args[i].startsWith("--") ? args[i] : undefined);
1605
1606
 
1606
1607
  /** `oats teams [--json] | add <label> --team <id> [--description <d>] | remove <label> | default <label>` —
1607
- * this deployment's teams (team model v2). Config only: never a provider call. */
1608
+ * this deployment's teams (team model 3). Config only: never a provider call. */
1608
1609
  async function teamsCmd() {
1609
1610
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1610
1611
  const usage = "usage: oats teams [--json] | oats teams add <label> --team <id> [--description <d>] | oats teams remove <label> | oats teams default <label> [--dir <deployment>] [--json]";
@@ -1623,25 +1624,29 @@ async function teamsCmd() {
1623
1624
  const doc = V.teamsDocument(result ? { ...ctx, local: result.local } : ctx);
1624
1625
  if (JSON_MODE) { jsonOk(result ? { ...doc, changed: result.changed } : withObservation(doc)); return; }
1625
1626
  if (result) console.log(result.changed ? `${sub === "add" ? `Declared team ${label}` : sub === "remove" ? `Removed team ${label}` : `The default team is now ${label}`} in ${shortPath(ctx.localPath)}` : "Nothing to change");
1626
- console.log(`default ${doc.defaultTeam ?? "(none)"}`);
1627
+ console.log(`default ${doc.defaultTeam ? `${doc.defaultTeam.label} (${doc.defaultTeam.from})` : "(none)"}`);
1628
+ console.log(`local ${doc.localTeams === null ? "allowed (standalone)" : doc.localTeams ? "allowed (localTeams: true)" : "not allowed"}`);
1627
1629
  if (!doc.teams.length) console.log("teams (none: `oats aweb setup` creates them, or `oats teams add <label> --team <id>`)");
1628
1630
  else printTable(["team", "id", "from", ""], doc.teams.map((t) => [t.label, t.team ?? "(no id yet)", t.from, t.default ? "default" : ""]));
1629
- const souls = Object.entries(doc.souls.teams).map(([k, l]) => `${k}: ${l.join(",")}`);
1631
+ const souls = Object.entries(doc.souls).map(([k, e]) => `${k}: ${[e.default ? `default ${e.default}` : null, e.teams === "any" ? "any" : Array.isArray(e.teams) ? `[${e.teams.join(",")}]` : null].filter(Boolean).join(" ") || "default only"}`);
1630
1632
  if (souls.length) console.log(`souls ${souls.join(" · ")}`);
1631
- const defaults = Object.entries(doc.souls.default).map(([k, l]) => `${k}: ${l}`);
1632
- if (defaults.length) console.log(`defaults ${defaults.join(" · ")}`);
1633
1633
  for (const p of doc.problems) console.log(`${p.severity === "failure" ? "problem" : "warning"} ${p.code} ${p.message} — ${p.fix}`);
1634
1634
  }
1635
1635
 
1636
- /** `oats soul teams <soul>|'*' [--add a,b] [--remove a,b] [--default <label> | --clear-default] [--json]` —
1637
- * which teams a soul belongs to here, and why (team model v2). Config only. */
1636
+ /** The `oats soul teams` edit flags team model 3 removed (0.38.0): a soul's teams are the workspace's `souls:`. */
1637
+ const SOUL_TEAMS_REMOVED_FLAGS = ["--add", "--remove", "--default", "--clear-default"];
1638
+ const SOUL_TEAMS_REPLACEMENT = "souls: in oats-workspace.yaml (a PR to the workspace file)";
1639
+
1640
+ /** `oats soul teams <soul>|'*' [--json]` — which teams a soul may join here, its default, and why (team
1641
+ * model 3: the workspace's `souls:`, and the local teams it allows). Config only, read only. */
1638
1642
  async function soulCmd() {
1639
1643
  const bail = (code, msg, details) => (JSON_MODE ? jsonFail(code, msg, details) : die(msg));
1640
- const usage = "usage: oats soul teams <soul>|'*' [--add <label>[,…]] [--remove <label>[,…]] [--default <label> | --clear-default] [--dir <deployment>] [--json]";
1644
+ const usage = "usage: oats soul teams <soul>|'*' [--dir <deployment>] [--max-age <s>] [--json]";
1645
+ const removed = SOUL_TEAMS_REMOVED_FLAGS.find((f) => args.some((a) => a === f || a.startsWith(`${f}=`)));
1646
+ if (removed) bail("E_BAD_ARGS", `oats soul teams ${removed} was removed in 0.38.0 (team model 3): which teams a soul may join, and its default, are ${SOUL_TEAMS_REPLACEMENT}`, { flag: removed, replacement: SOUL_TEAMS_REPLACEMENT });
1641
1647
  if (positional(1) !== "teams") bail("E_USAGE", usage);
1642
1648
  const name = positional(2);
1643
- if (name === undefined) bail("E_BAD_ARGS", `oats soul teams needs a soul (or '*' for every soul) — ${usage}`);
1644
- const edit = { add: labelsFlag("add"), remove: labelsFlag("remove"), setDefault: valueFlag("default") ?? null, clearDefault: args.includes("--clear-default") };
1649
+ if (name === undefined) bail("E_BAD_ARGS", `oats soul teams needs a soul (or '*') — ${usage}`);
1645
1650
  const ctx = workspaceContext(bail);
1646
1651
  let teamsCtx, soul = "*", key = "*";
1647
1652
  if (name === "*") teamsCtx = await teamsContext(bail);
@@ -1655,19 +1660,16 @@ async function soulCmd() {
1655
1660
  discovery = await discoverOrStandalone(ctx.local, { lock, deployment: ctx.deploymentDir, remoteOptions: ctx.remoteOptions });
1656
1661
  entry = findSoulEntry(discovery, name);
1657
1662
  } catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details ?? e.provenance); throw e; }
1658
- soul = entry.name; key = soulKeyOf(entry);
1663
+ soul = entry.name; key = teamKeyOf(entry);
1659
1664
  teamsCtx = { deployment: ctx.deploymentDir, localPath: ctx.localPath, local: ctx.local, workspace: discovery.standalone === true ? null : discovery.workspace, workspaceKey: discovery.key ?? null };
1660
1665
  }
1661
1666
  const V = await import("../lib/teams-verbs.mjs");
1662
- const mutating = edit.add.length || edit.remove.length || edit.setDefault !== null || edit.clearDefault;
1663
- let result = null, doc;
1664
- try {
1665
- if (mutating) result = V.soulTeamsEdit(teamsCtx, key, edit);
1666
- doc = V.soulTeamsDocument(result ? { ...teamsCtx, local: result.local } : teamsCtx, { soul, key });
1667
- } catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
1668
- if (JSON_MODE) { jsonOk(result ? { ...doc, changed: result.changed } : withObservation(doc)); return; }
1669
- if (result) console.log(result.changed ? `Updated the teams of ${key === "*" ? "every soul" : key} in ${shortPath(teamsCtx.localPath)}` : "Nothing to change");
1670
- console.log(`${key === "*" ? "every soul" : key} on this computer: default ${doc.defaultTeam ? `${doc.defaultTeam.label} (${doc.defaultTeam.from})` : "(none)"}`);
1667
+ let doc;
1668
+ try { doc = V.soulTeamsDocument(teamsCtx, { soul, key }); }
1669
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
1670
+ if (JSON_MODE) { jsonOk(withObservation(doc)); return; }
1671
+ console.log(`${key === "*" ? "unlisted souls (\"*\")" : key} here: default ${doc.defaultTeam ? `${doc.defaultTeam.label} (${doc.defaultTeam.from})` : "(none)"}`);
1672
+ console.log(`souls: ${doc.match === null ? "no entry matches" : `teams from ${JSON.stringify(doc.match)}`}${doc.defaultMatch !== null ? `, default from ${JSON.stringify(doc.defaultMatch)}` : ""}`);
1671
1673
  if (doc.teams.length) printTable(["team", "id", "from", "why"], doc.teams.map((t) => [t.default ? `${t.label} (default)` : t.label, t.team ?? "(no id yet)", t.from, t.via.join(",")]));
1672
1674
  }
1673
1675
 
@@ -1904,8 +1906,8 @@ async function statusDrift(data) {
1904
1906
  * machine's parsed cache knows: `ref` itself when the cache holds its workspace file, or the host its
1905
1907
  * cached oats-membership.yaml names when `ref` is a member ("workspace"). A member whose host is not
1906
1908
  * known keys as itself ("member"); a ref nothing is known of is taken as the host, as the schema
1907
- * defines `workspace:` ("workspace"). The team model resolves as teamModel resolves it (the default
1908
- * label is local; a committed team wins a collision) over the shared teams of, in order, the workspace
1909
+ * defines `workspace:` ("workspace"). The team model resolves as teamModel resolves it (the default is
1910
+ * the local one the workspace allows, else the workspace's; a committed team wins a collision) over the shared teams of, in order, the workspace
1909
1911
  * file this run observed (`teamsFrom: "observed"`), the cached file ("cache": no git process), or none
1910
1912
  * ("local"). `standalone` is the CONFIGURED standalone view only (oats-local.yaml `standalone:`): it
1911
1913
  * reads no workspace file, so its local teams are the whole team model. A run that fell back to the
@@ -1928,10 +1930,12 @@ function workspaceIdentity(local, discovery) {
1928
1930
  else if (cached?.file) [shared, teamsFrom] = [cached.file, "cache"];
1929
1931
  }
1930
1932
  const model = teamModel(shared, local);
1933
+ // A soul with no souls: default of its own lives here (team model 3: the local default the workspace allows, else the workspace's).
1934
+ const deploymentDefault = model.localDefault ?? model.workspaceDefault;
1931
1935
  const labels = [...model.labels.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1932
1936
  return {
1933
1937
  key, ref, keyFrom, standalone,
1934
- defaultTeam: model.defaultTeam === null ? null : { label: model.defaultTeam, team: model.labels.get(model.defaultTeam)?.team ?? null },
1938
+ defaultTeam: deploymentDefault === null ? null : { label: deploymentDefault, team: model.labels.get(deploymentDefault)?.team ?? null },
1935
1939
  teams: Object.fromEntries(labels.map((l) => [l, model.labels.get(l).team])),
1936
1940
  teamsFrom,
1937
1941
  };
@@ -2010,8 +2014,8 @@ async function status() {
2010
2014
  const verbose = args.includes("--verbose");
2011
2015
  const problems = legacyLayoutProblems(root);
2012
2016
  if (args.includes("--json")) {
2013
- // The soul key (feature launch-preference): what souls.teams / souls.default / souls.launch and
2014
- // `oats soul teams <key>` use — from the instances' records, so it needs no remote.
2017
+ // The soul key (feature launch-preference): what souls.launch and `oats soul teams <key>` use —
2018
+ // from the instances' records, so it needs no remote.
2015
2019
  for (const a of data) a.key = agentSoulKey(a);
2016
2020
  if (ws) for (const a of data) {
2017
2021
  const stamp = ws.souls.get(a.name);
@@ -3002,7 +3006,9 @@ async function capabilityCommand() {
3002
3006
  if (!trust.trusted) bail("E_CAPABILITY_BLOCKED", `${m.capability} executable command is blocked: ${trust.reason}`);
3003
3007
  if (homeMeta && m.capability === homeMeta.messaging) {
3004
3008
  const { liveTeams } = await import("../lib/instance-resolution.mjs");
3005
- const live = await liveTeams(instanceHome, homeMeta.meta, { remoteOptions: remoteOptionsFromEnv() });
3009
+ let live;
3010
+ try { live = await liveTeams(instanceHome, homeMeta.meta, { remoteOptions: remoteOptionsFromEnv() }); }
3011
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) return bail(e.code, e.message, e.details); throw e; }
3006
3012
  teamCtx = homeTeamCtx(live);
3007
3013
  }
3008
3014
  return runManifestCommand(m, { settings: capSettings[m.capability] || {}, origins: capOrigins[m.capability] }, teamCtx, () => m._dir, () => ({ dir: soulDir, cleanup: () => {} }));
@@ -3136,7 +3142,7 @@ function versionCmd() {
3136
3142
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
3137
3143
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
3138
3144
  // never listed.
3139
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default", "capability-show", "capture-file", "workspace-identity"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2, capabilityShowApi: 1 }));
3145
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-3", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default", "capability-show", "capture-file", "workspace-identity"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2, capabilityShowApi: 1 }));
3140
3146
  return;
3141
3147
  }
3142
3148
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -3810,10 +3816,10 @@ Usage:
3810
3816
  teams on this deployment
3811
3817
  oats teams [--json] | add <label> --team <id> [--description <d>] | remove <label>
3812
3818
  | default <label> [--dir <d>] this deployment's teams (shared + local), the
3813
- [--max-age <s>] (the read form only) default; add/remove/default edit oats-local.yaml
3814
- oats soul teams <soul>|'*' [--add a,b] [--remove a,b] [--default <l> | --clear-default]
3815
- [--dir <d>] [--json] which teams a soul (or every soul) belongs to here
3816
- [--max-age <s>] (without an edit)
3819
+ [--max-age <s>] (the read form only) default, the workspace's souls:; add/remove/default
3820
+ edit oats-local.yaml (only with localTeams: true)
3821
+ oats soul teams <soul>|'*' [--dir <d>] which teams a soul may join here, its default, and
3822
+ [--max-age <s>] [--json] why (the workspace's souls: and local teams)
3817
3823
  oats instance git <instance> [--home <abs>] [--dir <d>] [--json]
3818
3824
  read-only Git observation of the instance's work
3819
3825
  tree: branch, status (renames kept), ahead/behind
@@ -193,23 +193,27 @@ per-deployment activation or exclusion maps.
193
193
 
194
194
  ### Teams in the provider environment
195
195
 
196
- A soul's teams here (the committed shared teams, and the deployment's `oats-local.yaml` `teams`, `defaultTeam`, `souls.teams`,
197
- `souls.default` — see [workspaces.md](workspaces.md#teams)) travel **beside** a
196
+ A soul's teams here (the workspace's shared teams, `defaultTeam` and `souls:`, and
197
+ the deployment's `oats-local.yaml` `teams` and `defaultTeam` where the workspace
198
+ allows them — see [workspaces.md](workspaces.md#teams)) travel **beside** a
198
199
  provider's settings, never inside them, in the environment of every hook, home
199
200
  command and provider check:
200
201
 
201
202
  - `OATS_DEFAULT_TEAM` — the soul's default label; `OATS_DEFAULT_TEAM_ID` — its
202
- provider id; `OATS_DEFAULT_TEAM_FROM` — `deployment` (`defaultTeam`) or `soul`
203
- (`souls.default`).
203
+ provider id; `OATS_DEFAULT_TEAM_FROM` — `soul` (the soul's `souls:` default in
204
+ the workspace file), `deployment` (the local `defaultTeam`) or `workspace` (the
205
+ workspace's `defaultTeam`).
204
206
  - No default configured: none of the three is set. An **unmapped** default (a
205
207
  shared team declared without an id): `OATS_DEFAULT_TEAM` and
206
208
  `OATS_DEFAULT_TEAM_FROM` are set and `OATS_DEFAULT_TEAM_ID` is not. What a
207
209
  provider does then is its own contract; a messaging provider typically
208
210
  refuses the spawn, naming the unmapped label, or saying no team is
209
211
  configured.
210
- - `OATS_TEAMS` — JSON `[{label, team, default, from: "shared"|"local"}]`: every
211
- mapped team the soul may be in here, the default included (`default: true`),
212
- default first, then by label. Eligible to join = the rows with `default: false`.
212
+ - `OATS_TEAMS` — JSON `[{label, team, default, from: "shared"|"local", via}]`:
213
+ every mapped team the soul may be in here, the default included
214
+ (`default: true`), default first, then by label; `via` says why
215
+ (`default`, `workspace`, `local`). Eligible to join = the rows with
216
+ `default: false`, and no other team: a provider refuses a join outside them.
213
217
  Unset when a home's teams are unknown (none recorded, and unreadable now).
214
218
  - `OATS_TEAMS_SOURCE` — `live` (the workspace and `oats-local.yaml` read now, or
215
219
  a fresh resolution) or `recorded` (the spawn-time record). **A provider leaves
@@ -21,15 +21,10 @@ settings: # host-owned values per capability
21
21
  oats.okf:
22
22
  bindings-file: /Users/ana/.oats/okf-bindings.json
23
23
 
24
- teams: # LOCAL teams: only this deployment uses them
24
+ teams: # LOCAL teams (only where the workspace says localTeams: true)
25
25
  ana-research: { team: "ana-research:acme.aweb.ai", description: Ana's research }
26
- defaultTeam: ana-research # the team every instance lives in
26
+ defaultTeam: ana-research # this deployment's default team (same condition)
27
27
  souls:
28
- teams:
29
- "*": [ana-research] # every soul is in these teams here
30
- data-analyst: [platform] # and this one also joins a shared team
31
- default:
32
- data-analyst: platform # per-soul override of defaultTeam
33
28
  disabled: [legacy-bot] # souls not run on this machine
34
29
 
35
30
  host:
@@ -66,10 +61,8 @@ refused (`E_WORKSPACE_SCHEMA`).
66
61
  | `standalone` | A repo ref to realize on its own: its souls and `from: here` capabilities plus `oats.core`, with no workspace lookup. For a repository whose workspace this machine cannot read ([workspaces.md](workspaces.md#the-standalone-case)). |
67
62
  | `clones` | `<repo key>: <absolute path>` for a member clone that is not at `<deployment>/<member name>/`. Only a soul whose work target needs a clone (`work: worktree \| checkout`) uses it. Lookup order: `spawn --repo`, then this map, then `<deployment>/<member name>` (a member named `agents` → `<deployment>/agents-repo`). None → `E_CLONE_MISSING`; a directory whose `origin` is another repository → `E_CLONE_MISMATCH`. |
68
63
  | `settings.<cap>.<key>` | Host-owned values a capability's manifest asks for: absolute paths, state roots, delivery modes. The workspace file refuses absolute paths; they go here. Merged into the capability's provider payload after the soul's own and before any `--provider` flag ([three homes](workspaces.md#provider-payloads-have-three-homes)). |
69
- | `teams.<label>` | A **local** team: `{ team: <provider team id>, description? }`. Shared teams are committed in `oats-workspace.yaml`; a label in both is refused. Written by `oats teams add <label> --team <id>` and `oats teams remove <label>`. |
70
- | `defaultTeam` | The team every instance of this deployment lives in: a label of a local or shared team. The first `oats teams add` sets it; `oats teams default <label>` changes it. |
71
- | `souls.teams` | Which teams each soul joins here: `"*"` applies to every soul; a soul's own entry (its name, or `<package>/<soul>`) adds to it. Every soul is also in its default team. Written by `oats soul teams <soul>\|'*' --add … --remove …`. |
72
- | `souls.default` | A per-soul override of `defaultTeam`; it must be one of that soul's teams here (`E_TEAM_NOT_ELIGIBLE`). Written by `oats soul teams <soul> --default <label>`. |
64
+ | `teams.<label>` | A **local** team: `{ team: <provider team id>, description? }`, a team only this deployment uses; every soul may join it. Allowed only where `oats-workspace.yaml` says `localTeams: true` (or in the standalone view); otherwise refused (`E_WORKSPACE_SCHEMA`, reason `local-teams-closed`). Shared teams are committed in `oats-workspace.yaml`; a label in both is `team-label-collision` (the shared one wins). Written by `oats teams add <label> --team <id>` and `oats teams remove <label>`. |
65
+ | `defaultTeam` | This deployment's default team: a label of a local or shared team, under the same condition as `teams`. A soul's own default in the workspace's `souls:` wins over it; it wins over the workspace's `defaultTeam`. The first `oats teams add` sets it; `oats teams default <label>` changes it. |
73
66
  | `souls.disabled` | Souls not run on this machine; a spawn is refused with `E_SOUL_DISABLED`. A bare name disables every soul of that name; `<package>/<soul>` or `<member>/<soul>` disables one. |
74
67
  | `session.tmuxSession` | The tmux session new tmux instances open their windows in (0.31). Absent: `OATS_TMUX_SESSION`, else `PI_AGENTS_TMUX_SESSION` (the pre-0.31 variable), else `oats-agents`. `session: { tmuxSession: pi-agents }` keeps the pre-0.31 layout. `oats inspect --json` reports it as `session`. |
75
68
  | `host.name` | This machine's name. A workspace trigger or schedule runs only on the host named by its `runsOn` ([schedules.md](schedules.md)). |
@@ -78,14 +71,11 @@ refused (`E_WORKSPACE_SCHEMA`).
78
71
  | `launch-configs.<name>` | A named way to start a harness on this host, chosen at spawn or session start, never by the soul. `default: true` makes it this host's baseline for its harness (0.32). See [Launch configurations](#launch-configurations). |
79
72
  | `souls.launch` | This machine's launch preference per soul (0.30): `"*"` for every soul, a soul's own entry (its name, or `<package>/<soul>`) over it. A value is a `launch-configs` name or an inline `{ harness, model? }`. It overrides the soul's own `launch:`; explicit spawn flags win over both. See [Launch preferences](#launch-preferences). |
80
73
 
81
- How teams are resolved, and what a messaging provider does with them, is in
82
- [workspaces.md](workspaces.md#teams).
83
-
84
- OATS 0.38.0 (team model 3) removes `souls.teams` and `souls.default` (they move
85
- to `souls:` in `oats-workspace.yaml`) and allows `teams` and `defaultTeam` here
86
- only when the workspace file says `localTeams: true`. 0.36.x and 0.37.x still apply all
87
- four keys and warn about them (`team-model-3-migration`): see
88
- [Preparing for team model 3](workspaces.md#preparing-for-team-model-3-036x).
74
+ Which teams a soul may join, and its default, are committed in the workspace's
75
+ `souls:`, never here: `souls.teams` and `souls.default` were removed in 0.38.0
76
+ (`E_WORKSPACE_SCHEMA`, reason `removed-key`; the refusal prints the `souls:` to
77
+ commit instead). How teams are resolved, and what a messaging provider does
78
+ with them, is in [workspaces.md](workspaces.md#teams).
89
79
 
90
80
  ## Launch configurations
91
81
 
@@ -332,7 +322,7 @@ is safe to delete.
332
322
  oats workspace status # members, locked packages, external souls
333
323
  oats sync # confirm, resolve, lock, report the diff
334
324
  oats teams # shared and local teams, and the default
335
- oats soul teams <soul> # the teams one soul joins here
325
+ oats soul teams <soul> # the teams one soul may join here, and why
336
326
  oats spawn <soul> --preview # the exact modules, teams and provider payloads
337
327
  oats doctor # this deployment's files and the lock
338
328
  ```
@@ -1,6 +1,6 @@
1
1
  # Team model v2: shared and local teams, the default team, and membership per deployment
2
2
 
3
- **Status:** decided and implemented (kernel 0.30.0, feature `team-model-2`). This record decides where teams, the default team and team membership live, how the kernel resolves them, and what the kernel hands a messaging provider. The reference pages ([workspaces.md](../workspaces.md#teams), [capabilities.md](../capabilities.md#teams-in-the-provider-environment), [desktop-cli-api.md](../desktop-cli-api.md#team-model-v2-feature-team-model-2-oats-0300-replaces-feature-teams)) win on operator-visible behaviour.
3
+ **Status:** superseded in part by [team model 3](2026-10-02-team-model-3.md) (kernel 0.38.0): which teams a soul may join and its default team are committed in the workspace's `souls:` and `defaultTeam`, and local teams need `localTeams: true`; `souls.teams`, `souls.default` and the `oats soul teams` edits below are removed. The rest stands. Decided and implemented (kernel 0.30.0, feature `team-model-2`). This record decides where teams, the default team and team membership live, how the kernel resolves them, and what the kernel hands a messaging provider. The reference pages ([workspaces.md](../workspaces.md#teams), [capabilities.md](../capabilities.md#teams-in-the-provider-environment), [desktop-cli-api.md](../desktop-cli-api.md#team-model-v2-feature-team-model-2-oats-0300-replaces-feature-teams)) win on operator-visible behaviour.
4
4
 
5
5
  ## Why
6
6
 
@@ -0,0 +1,146 @@
1
+ # Team model 3: the teams a soul may join, and its default, are committed in the workspace
2
+
3
+ **Status:** decided and implemented (kernel 0.38.0, feature `team-model-3`; prepared in 0.36.1).
4
+ Specs: awebai/oats#484 and #485, agreed by both maintainers on 2026-10-02. This record supersedes the
5
+ membership and default-team parts of [team model v2](2026-09-27-team-model-v2.md); v2's shared teams,
6
+ unmapped ids, live teams and provider environment stand. The reference pages
7
+ ([workspaces.md](../workspaces.md#teams), [capabilities.md](../capabilities.md#teams-in-the-provider-environment),
8
+ [desktop-cli-api.md](../desktop-cli-api.md#team-model-3-feature-team-model-3-oats-0370-replaces-feature-team-model-2))
9
+ win on operator-visible behaviour.
10
+
11
+ ## Why
12
+
13
+ **The local bridge.** An instance that sits in two teams is a bridge between them: one process reads
14
+ both inboxes, holds both contexts in one session, and can relay anything from one to the other. Under
15
+ team model v2, one person's `oats-local.yaml` could put an organisation's instance into a team the
16
+ organisation never sees: a local extra team in `souls.teams`, or a local default team while its souls
17
+ joined the organisation's shared teams. Either way it never showed in the organisation's git, and it
18
+ differed per person and per machine. The organisation owns its teams' trust boundary, so the teams its
19
+ instances can be in are its decision, committed in its workspace, and closed by default.
20
+
21
+ **Scale.** With many souls and teams, per-deployment lists were repeated by every person on every
22
+ machine.
23
+
24
+ **Principle**, consistent with v2's "shared facts committed, personal choices local": everything
25
+ workspace-specific lives in the workspace file; souls stay team-free in their repositories, open source
26
+ included; an organisation says how souls behave in its teams in its own `oats-workspace.yaml`, and
27
+ someone running the souls standalone sees none of it.
28
+
29
+ ## What it protects against, and what it does not
30
+
31
+ The workspace's eligibility rules (`souls:`, `localTeams` closed by default) **prevent accidental joins**
32
+ in a cooperative installation, and make the organisation's intended team set visible and reviewable in
33
+ its git. That is all they claim.
34
+
35
+ They are not anti-bridging enforcement, and neither is the messaging layer. Distinct keys do not prove
36
+ distinct processes: an operator controlling two identities can read under one and relay under the
37
+ other, or copy content outside the messaging layer. aweb admission controls who holds credentials for
38
+ a team (the team controller key signs memberships; hosted invites are mediated by the service), not
39
+ what a process does with what it reads. A deployment's authority comes from the credentials it holds,
40
+ not from its path or its name. Today's team certificates carry no soul, workspace or home-team claim,
41
+ and any such metadata would be self-asserted, so it would not be prevention. Enforcement on the
42
+ messaging side (admission per root, claims in certificates, a namespace policy, bridge visibility for a
43
+ team's owner) is a separate question for the aweb protocol owners.
44
+
45
+ ## The model
46
+
47
+ ### `oats-workspace.yaml`
48
+
49
+ ```yaml
50
+ teams: # shared teams, as in v2
51
+ engineering: { team: "engineering:acme.aweb.ai" }
52
+ security: { team: "security:acme.aweb.ai" }
53
+ docs: { team: "docs:acme.aweb.ai" }
54
+ defaultTeam: engineering # the workspace's fallback default team (a shared label)
55
+ localTeams: false # may deployments declare their own teams? (absent: false)
56
+ souls: # per pattern: the default team and the other teams a soul may join
57
+ "*": { teams: [] }
58
+ security-souls/*: { default: security, teams: [engineering] }
59
+ security-souls/incident-responder: { default: security, teams: [engineering, docs] }
60
+ oats.engineering/*: { teams: any }
61
+ ```
62
+
63
+ - **Keys are patterns:** `<member>/<soul>` or `<package>/<soul>`, `<member>/*` or `<package>/*`, and
64
+ `"*"`, with the names `souls.disabled` already uses. A soul's key is its qualified name. Bare names are
65
+ refused: a bare name is ambiguous across members, and the qualified form is what the workspace sees.
66
+ - **The most specific key wins outright for teams,** with no merging across levels; the default comes
67
+ from the most specific key that sets one. `a/*: {default: security}` and `a/x: {teams: [docs]}` give
68
+ `a/x` the default `security` and the teams `[docs]` only. Merging was rejected: an entry that adds to
69
+ a broader one hides what a soul can join behind two places, and the point of the model is that a
70
+ reviewer reads one entry.
71
+ - **`any`** is every shared team of the file.
72
+ - **A soul no key matches** (and no `"*"`) has its default only, member and package souls alike; a
73
+ workspace opens a package explicitly.
74
+ - **Every label is a shared team of the same file.** An unknown label, or a default that isn't one, is
75
+ `E_WORKSPACE_SCHEMA` when the file is read, never at a spawn on someone else's machine. A key that is
76
+ neither a pattern nor a discovered soul's qualified name is the warning `team-soul-unknown` (a typo
77
+ guard; not an error, because the file is validated without discovery and packages come and go).
78
+
79
+ ### The default team, in order
80
+
81
+ 1. the soul's `default` from `souls:` (`from: "soul"`);
82
+ 2. else the deployment's `oats-local.yaml` `defaultTeam`, only when `localTeams: true`
83
+ (`from: "deployment"`);
84
+ 3. else the workspace's `defaultTeam` (`from: "workspace"`);
85
+ 4. else none (`E_TEAM_UNCONFIGURED` when a messaging layer is active).
86
+
87
+ The soul's own default comes first because it is the organisation's most specific statement; a
88
+ personal default, where allowed, comes before the workspace's fallback so a person can keep their own
89
+ default team (awebai/oats sets `localTeams: true` for this).
90
+
91
+ ### The teams a soul may join
92
+
93
+ Its default, plus the `teams` of its most specific matching key, plus, with `localTeams: true`, every
94
+ local team the deployment declares (there is no per-soul local list: a deployment that is trusted with
95
+ local teams is trusted with them for every soul). Each row says why: `via` ⊂ `default`, `workspace`,
96
+ `local`. Unchanged from v2: an instance joins only its default at spawn; the others are offered and
97
+ joined on request, and the messaging provider decides admission from the eligible rows it receives in
98
+ `OATS_TEAMS`.
99
+
100
+ ### `oats-local.yaml`
101
+
102
+ - `souls.teams` and `souls.default` are removed keys. The refusal names each key found and prints the
103
+ `souls:` that gives the same result (a v2 soul's teams were its default ∪ `"*"` ∪ its own list, so each
104
+ named soul's entry lists `"*"`'s teams too; a bare soul name is written `<member>/<soul>` for the
105
+ operator to qualify), so a deployment's migration is a copy, a qualification and a PR.
106
+ - `teams` and `defaultTeam` are allowed only when the workspace says `localTeams: true`. Otherwise every
107
+ spawn, preview and inspect refuses with `E_WORKSPACE_SCHEMA` (reason `local-teams-closed`), and `oats
108
+ teams`, readiness and doctor report it as a failure. The message names both fixes: allow local teams,
109
+ or commit them in the workspace and remove them locally. The rule can only be checked where the
110
+ workspace file has been read, so it is not part of `oats-local.yaml`'s own validation.
111
+ - The standalone view (explicit, or a fallback when the workspace can't be read) has no workspace rules:
112
+ it is an individual running souls outside any organisation, so local teams apply.
113
+ - Verbs: `oats teams add` and `oats teams default` refuse where local teams are closed; `oats teams
114
+ remove` runs there, since removing local teams is the last step of committing them in the workspace.
115
+ `oats soul teams` is read only; its edit flags were removed (a soul's teams are a PR to the workspace).
116
+
117
+ ### `soul.yaml` and `oats-membership.yaml`
118
+
119
+ Unchanged: they say nothing about teams, so a soul in a public repository carries no organisation's
120
+ labels. Per-soul lists in `oats-membership.yaml` and teams in `soul.yaml` were rejected for the same
121
+ reason v2 rejected them: labels belong to a workspace, and there should be one central place.
122
+
123
+ ## Reporting (contract)
124
+
125
+ - `defaultTeam.from` (spawn preview, `inspect`, `oats souls`, readiness, `OATS_DEFAULT_TEAM_FROM`):
126
+ `soul`, `deployment` or `workspace`. `soul` changed meaning: it was the local `souls.default`.
127
+ - Every `TeamRow` gains `via`.
128
+ - `oats teams --json` is `teamsApi: 2`: `localTeams`, a `DefaultTeam` for the deployment, and the
129
+ workspace's `souls:` in place of the local `souls` block. `oats soul teams --json` is
130
+ `soulTeamsApi: 2`: `match` and `defaultMatch` (the `souls:` keys that applied) in place of `local` and
131
+ `all`. Feature `team-model-3` replaces `team-model-2`.
132
+ - Readiness: local-teams-closed (failure) and `team-soul-unknown` (warning) join the team items;
133
+ `E_TEAM_NOT_ELIGIBLE` is no longer a kernel item (a soul's default is always one of its teams); it
134
+ remains the provider's join refusal.
135
+
136
+ ## 0.36.x → 0.38.0
137
+
138
+ 0.36.1 accepted and validated `defaultTeam`, `localTeams` and `souls:` without applying them, and warned
139
+ (`team-model-3-migration`) about what 0.38.0 refuses, so every workspace could commit its side first, as
140
+ 0.29.4 did for 0.30. 0.38.0 applies the keys, refuses the old shape, and retires the warning. The steps
141
+ for each kind of deployment are in the [0.38.0 release notes](../release-notes/v0.38.0.md).
142
+
143
+ ## Out of scope
144
+
145
+ Enforcement on the aweb side (above); nested teams; the Desktop's team-editing UI (its own spec:
146
+ anything that writes `souls.teams` or `souls.default` goes).
@@ -17,7 +17,11 @@ successor, with a link to its full text.
17
17
  - [OKF knowledge operations](2026-09-26-okf-knowledge-operations.md): package
18
18
  souls, triggers and automations for harvest and maintenance.
19
19
  - [Team model v2](2026-09-27-team-model-v2.md): shared teams in the workspace,
20
- local teams, the default team and membership in each deployment.
20
+ local teams, live teams and the provider environment (its membership and
21
+ default-team parts are superseded by team model 3).
22
+ - [Team model 3](2026-10-02-team-model-3.md): the teams a soul may join, and
23
+ its default, are committed in the workspace; local teams only where the
24
+ workspace allows them.
21
25
 
22
26
  The Desktop's design brief for designers is in
23
27
  [packages/desktop/docs](../../packages/desktop/docs/design-brief.md).