@lanes-sh/link 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/README.md +20 -10
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +148 -73
  4. package/package.json +2 -1
  5. package/src/audit/index.ts +8 -1
  6. package/src/auth/index.ts +58 -2
  7. package/src/auth/lanes/assertion.ts +256 -0
  8. package/src/auth/lanes/callback.ts +135 -0
  9. package/src/auth/lanes/federation.ts +50 -0
  10. package/src/auth/lanes/login.ts +294 -0
  11. package/src/auth/lanes/members.ts +103 -0
  12. package/src/auth/lanes/session.ts +97 -0
  13. package/src/auth/oauth/grant.ts +183 -0
  14. package/src/auth/oauth/result.ts +27 -0
  15. package/src/auth/oauth/server.ts +176 -203
  16. package/src/auth/oauth/store.ts +85 -4
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +109 -0
  19. package/src/cli/argv.ts +57 -3
  20. package/src/cli/audit-change.ts +140 -0
  21. package/src/cli/callback-page.ts +36 -115
  22. package/src/cli/commands/auth-dispatch.ts +48 -0
  23. package/src/cli/commands/auth.ts +229 -0
  24. package/src/cli/commands/connect/accounts.ts +4 -4
  25. package/src/cli/commands/connect/authorise.ts +4 -4
  26. package/src/cli/commands/connect/bind-credential.ts +2 -1
  27. package/src/cli/commands/connect/custom/index.ts +1 -1
  28. package/src/cli/commands/connect/custom/write.ts +2 -2
  29. package/src/cli/commands/connect/grant.ts +29 -14
  30. package/src/cli/commands/connect/index.ts +90 -88
  31. package/src/cli/commands/connect/options.ts +83 -0
  32. package/src/cli/commands/connect/registration.ts +50 -0
  33. package/src/cli/commands/connect/requirements.ts +1 -1
  34. package/src/cli/commands/connect/settle.ts +16 -6
  35. package/src/cli/commands/connect/target-note.ts +7 -2
  36. package/src/cli/commands/connect/unknown.ts +1 -1
  37. package/src/cli/commands/connect/variables.ts +3 -2
  38. package/src/cli/commands/connection-list.ts +116 -0
  39. package/src/cli/commands/connection.ts +183 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +24 -12
  42. package/src/cli/commands/knowledge/index.ts +49 -89
  43. package/src/cli/commands/knowledge/migrate.ts +79 -13
  44. package/src/cli/commands/knowledge/show.ts +97 -0
  45. package/src/cli/commands/knowledge.ts +2 -1
  46. package/src/cli/commands/mcp/harnesses.ts +30 -8
  47. package/src/cli/commands/mcp/onboarding.ts +86 -0
  48. package/src/cli/commands/mcp/register.ts +16 -2
  49. package/src/cli/commands/mcp.ts +1 -0
  50. package/src/cli/commands/members.ts +288 -0
  51. package/src/cli/commands/operate/attach.ts +3 -3
  52. package/src/cli/commands/operate/audit.ts +11 -7
  53. package/src/cli/commands/operate/auth.ts +28 -11
  54. package/src/cli/commands/operate/findings.ts +2 -1
  55. package/src/cli/commands/operate/inspect.ts +37 -19
  56. package/src/cli/commands/operate/migrate.ts +33 -13
  57. package/src/cli/commands/operate/outputs.ts +3 -3
  58. package/src/cli/commands/operate/pair-certificate.ts +141 -0
  59. package/src/cli/commands/operate/pair.ts +324 -0
  60. package/src/cli/commands/operate/policy.ts +73 -22
  61. package/src/cli/commands/operate/serve.ts +53 -5
  62. package/src/cli/commands/operate/status.ts +18 -10
  63. package/src/cli/commands/operate/tools.ts +2 -2
  64. package/src/cli/commands/operate.ts +2 -0
  65. package/src/cli/commands/owner/assets.ts +2 -2
  66. package/src/cli/commands/owner/entities.ts +2 -2
  67. package/src/cli/commands/owner/memory.ts +2 -2
  68. package/src/cli/commands/owner/shared.ts +13 -2
  69. package/src/cli/commands/owner/skills.ts +28 -8
  70. package/src/cli/commands/owner/tasks.ts +2 -2
  71. package/src/cli/commands/owner/vault.ts +3 -3
  72. package/src/cli/commands/profile/disposition.ts +236 -0
  73. package/src/cli/commands/profile/removal.ts +154 -64
  74. package/src/cli/commands/profile/remove.ts +83 -7
  75. package/src/cli/commands/profile.ts +79 -16
  76. package/src/cli/commands/relabel.ts +112 -0
  77. package/src/cli/commands/secrets.ts +39 -17
  78. package/src/cli/commands/set-workspace.ts +96 -0
  79. package/src/cli/commands/setup.ts +2 -2
  80. package/src/cli/commands/sync.ts +8 -8
  81. package/src/cli/commands/target.ts +9 -7
  82. package/src/cli/commands/update-migration.ts +54 -0
  83. package/src/cli/commands/update.ts +78 -24
  84. package/src/cli/config-edit.ts +99 -143
  85. package/src/cli/config-migrate.ts +82 -64
  86. package/src/cli/config-repair-sweep.ts +119 -0
  87. package/src/cli/config-repair.ts +131 -125
  88. package/src/cli/config-templates.ts +200 -0
  89. package/src/cli/contract3-credentials.ts +294 -0
  90. package/src/cli/contract3-data.ts +262 -0
  91. package/src/cli/contract3-layout.ts +46 -0
  92. package/src/cli/contract3-shape.ts +212 -0
  93. package/src/cli/contract3.ts +399 -0
  94. package/src/cli/contract4-credentials.ts +207 -0
  95. package/src/cli/contract4-data.ts +399 -0
  96. package/src/cli/contract4-rename.ts +73 -0
  97. package/src/cli/contract4-yaml.ts +223 -0
  98. package/src/cli/contract4.ts +342 -0
  99. package/src/cli/endpoint-url.ts +1 -1
  100. package/src/cli/identity.ts +44 -26
  101. package/src/cli/lanes.ts +25 -1
  102. package/src/cli/main.ts +94 -14
  103. package/src/cli/migrate-move.ts +166 -0
  104. package/src/cli/migrate-plan.ts +12 -6
  105. package/src/cli/output.ts +34 -1
  106. package/src/cli/publish.ts +6 -7
  107. package/src/cli/runtime/open.ts +64 -99
  108. package/src/cli/runtime/registry.ts +6 -7
  109. package/src/cli/runtime/select.ts +2 -11
  110. package/src/cli/runtime/stores.ts +58 -0
  111. package/src/cli/runtime/types.ts +106 -0
  112. package/src/cli/runtime/vault.ts +19 -4
  113. package/src/cli/runtime/workspace.ts +60 -0
  114. package/src/cli/runtime.ts +2 -1
  115. package/src/cli/selection-require.ts +44 -13
  116. package/src/cli/selection.ts +127 -145
  117. package/src/cli/usage.ts +40 -20
  118. package/src/cli/workspace-migrate.ts +152 -22
  119. package/src/connectivity/manifest/provider.ts +34 -13
  120. package/src/connectivity/manifest/requirements.ts +1 -1
  121. package/src/connectivity/transports/imap/parser.ts +70 -9
  122. package/src/deployments/adapters/filesystem.ts +18 -3
  123. package/src/deployments/bind.ts +1 -1
  124. package/src/deployments/deploy.ts +38 -29
  125. package/src/deployments/gcp/bucket.ts +58 -11
  126. package/src/deployments/gcp/provision.ts +7 -7
  127. package/src/deployments/knowledge.ts +9 -4
  128. package/src/deployments/prepare.ts +72 -24
  129. package/src/deployments/record.ts +1 -1
  130. package/src/deployments/report.ts +2 -2
  131. package/src/deployments/serving.ts +15 -74
  132. package/src/deployments/target.ts +34 -13
  133. package/src/deployments/upload.ts +60 -27
  134. package/src/dispatch/deps.ts +88 -0
  135. package/src/dispatch/dispatch.ts +21 -62
  136. package/src/policy/index.ts +47 -15
  137. package/src/profile/connections.ts +195 -0
  138. package/src/profile/deployments.ts +86 -8
  139. package/src/profile/index.ts +35 -6
  140. package/src/profile/knowledge.ts +18 -5
  141. package/src/profile/layout.ts +163 -90
  142. package/src/profile/load.ts +133 -64
  143. package/src/profile/pairing.ts +32 -0
  144. package/src/profile/primitives.ts +35 -1
  145. package/src/profile/registry.ts +6 -6
  146. package/src/profile/schema.ts +181 -21
  147. package/src/profile/targets.ts +21 -9
  148. package/src/profile/testing.ts +104 -2
  149. package/src/profile/workspace.ts +124 -33
  150. package/src/providers/assets/provider.ts +6 -6
  151. package/src/providers/custom/index.ts +1 -1
  152. package/src/providers/custom/load.ts +2 -3
  153. package/src/providers/entities/provider.ts +6 -6
  154. package/src/providers/entities/writes.ts +1 -1
  155. package/src/providers/identity/provider.ts +2 -2
  156. package/src/providers/memory/provider.ts +26 -8
  157. package/src/providers/setup/plan.ts +1 -1
  158. package/src/providers/setup/provider.ts +3 -3
  159. package/src/providers/skills/provider.ts +2 -2
  160. package/src/providers/slack/index.ts +2 -2
  161. package/src/providers/tasks/provider.ts +6 -6
  162. package/src/providers/vault/provider.ts +1 -1
  163. package/src/registry/policy-bridge.ts +33 -11
  164. package/src/registry/reconcile.ts +4 -4
  165. package/src/registry/registry.ts +1 -1
  166. package/src/server/authorization.ts +94 -0
  167. package/src/server/edge.ts +14 -1
  168. package/src/server/endpoint.ts +89 -104
  169. package/src/server/generation.ts +10 -1
  170. package/src/server/harness.ts +71 -13
  171. package/src/server/index.ts +31 -0
  172. package/src/server/mcp/build.ts +20 -1
  173. package/src/server/mcp/client-info.ts +54 -0
  174. package/src/server/mcp/guide.ts +120 -0
  175. package/src/server/mcp/instructions.ts +22 -22
  176. package/src/server/mcp/prompts.ts +7 -3
  177. package/src/server/mcp/resources.ts +16 -8
  178. package/src/server/mcp/routing.ts +3 -3
  179. package/src/server/mcp/tools.ts +25 -6
  180. package/src/server/mcp/visibility.ts +74 -7
  181. package/src/server/oauth.ts +29 -109
  182. package/src/server/read/credential.ts +134 -0
  183. package/src/server/read/deployed.ts +56 -0
  184. package/src/server/read/listener.ts +54 -0
  185. package/src/server/read/open.ts +101 -0
  186. package/src/server/read/routes.ts +247 -0
  187. package/src/server/read/state.ts +171 -0
  188. package/src/stores/blobs/conformance.ts +19 -0
  189. package/src/stores/state/index.ts +76 -10
  190. package/src/stores/state/testing.ts +5 -1
@@ -0,0 +1,223 @@
1
+ import {
2
+ CONNECTIONS_FILE,
3
+ connectionsFileSchema,
4
+ ConfigError,
5
+ layout,
6
+ readWorkspaceFile,
7
+ workspaceFiles,
8
+ } from '#profile';
9
+ import { parseDocument } from 'yaml';
10
+ import { ConfigDocument } from './config-edit.ts';
11
+ import type { Renames } from './contract4-data.ts';
12
+
13
+ /**
14
+ * The two YAML rewrites contract 4 makes, and the order between them.
15
+ *
16
+ * The grants go first and the rows second, with the stamp after both — every
17
+ * window that leaves is one a rerun closes, because both rewrites are
18
+ * idempotent: a grant or a row already naming the new ref is not in the map and
19
+ * is left alone. Renaming the rows first destroyed the only source the map is
20
+ * derived from, and the rerun then stamped a profile whose grants named a
21
+ * connection nothing declared.
22
+ */
23
+
24
+ /**
25
+ * `memory.*` becomes `lanes_memory.*`, in either spelling a rule may take.
26
+ *
27
+ * A rule is a bare pattern string or `{ capability, expires_at }`, and an
28
+ * expiry has to survive: dropping it would turn a lapsing grant into a
29
+ * permanent one, which is the direction that fails unsafely.
30
+ */
31
+ function retitle(rules: unknown, was: string, now: string): unknown {
32
+ if (!Array.isArray(rules)) return rules;
33
+
34
+ const moved = (pattern: string): string =>
35
+ pattern === was || pattern.startsWith(`${was}.`) ? `${now}${pattern.slice(was.length)}` : pattern;
36
+
37
+ return rules.map((rule) => {
38
+ if (typeof rule === 'string') return moved(rule);
39
+ const held = rule as { capability?: unknown };
40
+ return typeof held.capability === 'string'
41
+ ? { ...held, capability: moved(held.capability) }
42
+ : rule;
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Every profile's grants, onto the ids and providers the rename settled.
48
+ *
49
+ * Its own pass rather than a step inside the stamping loop, because the two
50
+ * have to happen either side of `renameConnections` — the grants while the rows
51
+ * still carry the old ids, the stamp once everything else is done.
52
+ *
53
+ * A rule may only name the provider its row grants (ADR-058), so the
54
+ * `allow`/`deny` patterns move with the `connection` or the profile is one the
55
+ * loader refuses.
56
+ */
57
+ export async function rewriteGrants(
58
+ root: string,
59
+ profiles: readonly string[],
60
+ renames: Renames,
61
+ ): Promise<void> {
62
+ for (const profile of profiles) {
63
+ const document = await ConfigDocument.openKey(root, layout.profileConfig(profile));
64
+
65
+ const held = document.toJSON() as { grants?: unknown };
66
+ const grants = held.grants;
67
+ if (!Array.isArray(grants)) continue;
68
+
69
+ // **Deduplicated, because the owner layer merges.** Every profile's own
70
+ // `memory` grant renames to `lanes_memory.lan1` (ADR-066), and a profile
71
+ // that granted two instances of one surface would emit two rows at one
72
+ // address — which `validateConfig` refuses, on the save below, after the
73
+ // registry and every byte have already moved.
74
+ const seen = new Set<string>();
75
+
76
+ document.setIn(
77
+ ['grants'],
78
+ grants.flatMap((grant) => {
79
+ const one = grant as { connection?: unknown; allow?: unknown; deny?: unknown };
80
+ const ref = typeof one.connection === 'string' ? one.connection : undefined;
81
+ if (ref === undefined) return [grant];
82
+
83
+ const to = renames.get(ref) ?? ref;
84
+ if (seen.has(to)) return [];
85
+ seen.add(to);
86
+
87
+ const was = ref.slice(0, ref.indexOf('.'));
88
+ const now = to.slice(0, to.indexOf('.'));
89
+
90
+ return [
91
+ {
92
+ ...one,
93
+ connection: to,
94
+ ...(was === now
95
+ ? {}
96
+ : { allow: retitle(one.allow, was, now), deny: retitle(one.deny, was, now) }),
97
+ },
98
+ ];
99
+ }),
100
+ );
101
+
102
+ // `shapeOnly`, because the rows still name the old ids at this point and
103
+ // `assertGrantsResolve` would refuse a grant it cannot yet resolve. The
104
+ // secret-shaped-value check and the schema both still run.
105
+ await document.save({ shapeOnly: true, contract: 3 });
106
+ }
107
+ }
108
+
109
+ /** Every connection row, however far through the migration this workspace is. */
110
+ /**
111
+ * Refuse a `connections.yaml` the rewrite at the end could not have saved.
112
+ *
113
+ * `readConnectionRows` reads raw and drops a row it cannot understand, which is
114
+ * what lets it run before the guard that would block its own fix — but it means
115
+ * a malformed file yields *fewer* rows rather than an error, and a rehearsal
116
+ * found what that costs: the rename map came out empty, every byte moved
117
+ * verbatim into the profiles, and the validating save at the very end refused.
118
+ * `data/` was then empty, so a rerun had nothing to move and left the bytes at
119
+ * their old ids while the rows moved on without them.
120
+ *
121
+ * So it is checked here, before the first byte moves, which is where this
122
+ * migration says everything that can fail belongs. Deliberately *not*
123
+ * `readConnections`: that one also runs `assertNoRenamedProviders`, and a
124
+ * workspace holding `tasks` is the workspace this migration exists to repair.
125
+ */
126
+ export async function assertConnectionsSavable(root: string): Promise<void> {
127
+ const text = await readWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE);
128
+ if (text === null) return;
129
+
130
+ const parsed = connectionsFileSchema.safeParse(parseDocument(text).toJSON());
131
+ if (parsed.success) return;
132
+
133
+ throw new ConfigError(
134
+ `${CONNECTIONS_FILE} is not valid, and this migration rewrites it — so it stops here,\n` +
135
+ ' with the workspace exactly as it was. Fix these, then run it again:\n' +
136
+ parsed.error.issues.map((issue) => ` ${issue.path.join('.')}: ${issue.message}`).join('\n'),
137
+ );
138
+ }
139
+
140
+ export async function readConnectionRows(
141
+ root: string,
142
+ full = false,
143
+ ): Promise<{ id: string; provider: string; account: string; credential_ref?: string }[]> {
144
+ const text = await readWorkspaceFile(workspaceFiles(root), CONNECTIONS_FILE);
145
+ if (text === null) return [];
146
+
147
+ const held = parseDocument(text).toJSON() as { connections?: unknown };
148
+ if (!Array.isArray(held.connections)) return [];
149
+
150
+ return held.connections.flatMap((row) => {
151
+ const one = row as { id?: unknown; provider?: unknown; account?: unknown };
152
+ if (typeof one.id !== 'string' || typeof one.provider !== 'string') return [];
153
+
154
+ // `account` matters only to `credentialRefFor`, which reads a row's own
155
+ // `credential_ref` off it; the id and provider are all the rest wants.
156
+ return [
157
+ {
158
+ ...(full ? (row as object) : {}),
159
+ id: one.id,
160
+ provider: one.provider,
161
+ account: typeof one.account === 'string' ? one.account : '',
162
+ },
163
+ ];
164
+ });
165
+ }
166
+
167
+ /**
168
+ * The ids, in `connections.yaml` and in the credential store's refs.
169
+ *
170
+ * A `credential_ref` defaults to `<provider>/<connection>` and is derived
171
+ * rather than written, so the rows carry no ref to update — but a connection
172
+ * that *declares* one, and every `oauth_apps` entry, is a string naming an id
173
+ * and has to move with it.
174
+ */
175
+ export async function renameConnections(root: string, renames: Renames): Promise<void> {
176
+ const document = await ConfigDocument.openKey(root, CONNECTIONS_FILE);
177
+
178
+ // `toJSON()`, not `getIn`: `getIn` hands back YAML nodes rather than plain
179
+ // JS, so an `Array.isArray` on the result is false and the rewrite silently
180
+ // does nothing — which is exactly what it did, and the migration reported
181
+ // success with every id unchanged.
182
+ const held = document.toJSON() as { connections?: unknown };
183
+ if (!Array.isArray(held.connections)) return;
184
+
185
+ // **Deduplicated, because the owner layer merges.** Three profiles' own
186
+ // `memory` rows all rename to `lanes_memory.lan1` (ADR-066), and keeping
187
+ // three of them would be three rows at one address — which
188
+ // `assertConnectionsUnique` refuses, on the save at the end of this. The
189
+ // first row wins and the rest are dropped; the *bytes* are not merged, they
190
+ // land under each profile's own directory.
191
+ const seen = new Set<string>();
192
+ const rows = held.connections.flatMap((row) => {
193
+ const one = row as { id?: unknown; provider?: unknown };
194
+ if (typeof one.id !== 'string' || typeof one.provider !== 'string') return [row];
195
+
196
+ const to = renames.get(`${one.provider}.${one.id}`);
197
+ if (to === undefined) return [row];
198
+ if (seen.has(to)) return [];
199
+ seen.add(to);
200
+
201
+ // Both halves. The provider moved for the owner layer (`memory` became
202
+ // `lanes_memory`) and the id moved for everything unallocated, and a row
203
+ // that took one without the other names a connection nothing resolves.
204
+ const dot = to.indexOf('.');
205
+ return [{ ...one, provider: to.slice(0, dot), id: to.slice(dot + 1) }];
206
+ });
207
+
208
+ // **Lanes' own rows last, and the accounts in file order.** Nothing reads the
209
+ // order, but somebody does: interleaved, `con17` landed between `lan14` and
210
+ // `lan15` and the file read as though the numbering had gone wrong.
211
+ const owned = (row: unknown): boolean =>
212
+ typeof (row as { provider?: unknown }).provider === 'string' &&
213
+ (row as { provider: string }).provider.startsWith('lanes_');
214
+
215
+ document.setIn(['connections'], [...rows.filter((r) => !owned(r)), ...rows.filter(owned)]);
216
+
217
+ // The stamp this file was missing. Nothing reads it — every contract check is
218
+ // on a profile — which is exactly why it went stale, and a marker that lies
219
+ // is worse than no marker for whoever writes the next migration.
220
+ document.setIn(['contract'], 4);
221
+
222
+ await document.save();
223
+ }
@@ -0,0 +1,342 @@
1
+ import {
2
+ ConfigError,
3
+ layout,
4
+ listProfiles,
5
+ readWorkspaceFile,
6
+ workspaceFiles,
7
+ writeWorkspaceFile,
8
+ WORKSPACE_FILE,
9
+ CONNECTIONS_FILE,
10
+ openTarget,
11
+ } from '#profile';
12
+ import { parseDocument } from 'yaml';
13
+ import { ConfigDocument } from './config-edit.ts';
14
+ import { C3 } from './contract3-layout.ts';
15
+ import { grantingProfiles, planMoves, type DataPlan, type Renames } from './contract4-data.ts';
16
+ import { planRenames } from './contract4-rename.ts';
17
+ import {
18
+ assertConnectionsSavable,
19
+ readConnectionRows,
20
+ renameConnections,
21
+ rewriteGrants,
22
+ } from './contract4-yaml.ts';
23
+ import { applyMoves, assertOneObjectPerDestination } from './migrate-move.ts';
24
+ import {
25
+ applyCredentialMoves,
26
+ planCredentialMoves,
27
+ planVaultMoves,
28
+ } from './contract4-credentials.ts';
29
+ import { openSecretStoreFor } from './runtime/select.ts';
30
+ import { buildRegistryWithWorkspace } from './runtime/registry.ts';
31
+
32
+ /**
33
+ * Contract 3 to contract 4: a profile owns its data again.
34
+ *
35
+ * Contract 3 moved every account up to the workspace and, in the same sweep,
36
+ * moved the owner layer's bytes beside the *connection* — so a profile owned
37
+ * nothing and the shipped default had every profile granting one memory.
38
+ * ADR-066 reverses that half; ADR-067 collapses a profile into one directory
39
+ * and retires `data/`, which had stopped meaning anything once the declaration
40
+ * moved in beside the bytes.
41
+ *
42
+ * Four steps, ordered so a crash between any two leaves a workspace that still
43
+ * opens:
44
+ *
45
+ * 1. The registry is renamed. One document, cannot half-apply.
46
+ * 2. Bytes move — the workspace's up out of `data/`, a profile's into its
47
+ * directory.
48
+ * 3. Declarations move into the directories they now name.
49
+ * 4. Each profile is stamped `contract: 4`.
50
+ *
51
+ * **The stamp is last, and it is the record that this finished** rather than a
52
+ * step among steps. Contract 3 shipped with it written first, which left
53
+ * profiles claiming the new contract with every byte still at the old path and
54
+ * a rerun that read the stamp and found nothing to do.
55
+ *
56
+ * Every read here goes through `C3`, and every write through `layout`. A
57
+ * migration that asks the live layout for its *source* paths finds nothing to
58
+ * move the moment the next contract lands.
59
+ */
60
+
61
+ export interface Contract4Migration {
62
+ readonly workspaceRoot: string;
63
+ readonly profiles: readonly string[];
64
+ readonly changes: readonly string[];
65
+ /** Copied into more than one profile, original left. The operator decides. */
66
+ readonly shared: DataPlan['shared'];
67
+ /** Granted by no profile. Left where it is. */
68
+ readonly orphaned: readonly string[];
69
+ readonly alreadyCurrent: boolean;
70
+ }
71
+
72
+ /** Whether this workspace still holds anything at contract 3. */
73
+ export async function needsContract4(workspaceRoot: string): Promise<boolean> {
74
+ for (const profile of await listProfiles(workspaceRoot)) {
75
+ const raw = await readProfile(workspaceRoot, profile);
76
+ if (raw !== null && (raw.contract ?? 0) === 3) return true;
77
+ }
78
+ return false;
79
+ }
80
+
81
+ async function readProfile(
82
+ root: string,
83
+ profile: string,
84
+ ): Promise<{ contract?: number; grants?: { connection?: unknown }[] } | null> {
85
+ const files = workspaceFiles(root);
86
+ // The contract-3 path first: a profile still to be migrated is there, and one
87
+ // already migrated is at the new path. Reading only one shape makes a rerun
88
+ // after an interruption see half a workspace.
89
+ const text =
90
+ (await readWorkspaceFile(files, C3.profile(profile))) ??
91
+ (await readWorkspaceFile(files, layout.profileConfig(profile)));
92
+ if (text === null) return null;
93
+ try {
94
+ return parseDocument(text).toJSON() as { contract?: number };
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ export async function migrateToContract4(
101
+ workspaceRoot: string,
102
+ options: { apply: boolean; target?: string } = { apply: true },
103
+ ): Promise<Contract4Migration> {
104
+ const target = options.target ?? 'local';
105
+ const names = await listProfiles(workspaceRoot);
106
+ const configs = new Map<string, { grants?: { connection?: unknown }[] }>();
107
+
108
+ for (const profile of names) {
109
+ const raw = await readProfile(workspaceRoot, profile);
110
+ if (raw !== null && (raw.contract ?? 0) === 3) configs.set(profile, raw);
111
+ }
112
+
113
+ if (configs.size === 0) {
114
+ return {
115
+ workspaceRoot,
116
+ profiles: [],
117
+ changes: [],
118
+ shared: [],
119
+ orphaned: [],
120
+ alreadyCurrent: true,
121
+ };
122
+ }
123
+
124
+ const profiles = [...configs.keys()];
125
+ const files = workspaceFiles(workspaceRoot);
126
+
127
+ // Every connection the workspace holds, and the id each ends up with. Built
128
+ // once and read by every rewrite below — the contract-3 mover's own bug was a
129
+ // map keyed one way and queried the other, which made the resolution a silent
130
+ // no-op and sent two profiles' blobs into one namespace.
131
+ await assertConnectionsSavable(workspaceRoot);
132
+
133
+ const rows = await readConnectionRows(workspaceRoot);
134
+ const renames = planRenames(rows);
135
+
136
+ const plan = await planMoves(files, grantingProfiles(configs), profiles, renames);
137
+
138
+ // Everything that can refuse, before the first byte moves. A `keep` move is
139
+ // exempt: two profiles granting one store are *meant* to write one source to
140
+ // two destinations, which is the shape this check exists to catch elsewhere.
141
+ assertOneObjectPerDestination(plan.moves.filter((move) => move.keep !== true));
142
+
143
+ const changes = [...describe(plan, profiles), ...repositoryNotes(configs, renames)];
144
+ if (!options.apply) {
145
+ return {
146
+ workspaceRoot,
147
+ profiles,
148
+ changes,
149
+ shared: plan.shared,
150
+ orphaned: plan.orphaned,
151
+ alreadyCurrent: false,
152
+ };
153
+ }
154
+
155
+ await renameRegistry(workspaceRoot);
156
+ await applyMoves(files, plan.moves);
157
+
158
+ // Credentials before the rows: a ref is derived from the id, so the rows must
159
+ // still name the old one for `planCredentialMoves` to compute the same pair
160
+ // on a rerun.
161
+ const credentials = await moveCredentials(workspaceRoot, target, rows, renames, configs);
162
+
163
+ // **The rows are renamed after the grants and before the stamp**, and every
164
+ // window that leaves is one a rerun closes. `connections.yaml` is the only
165
+ // source `planRenames` has, so renaming it before the grants destroyed the
166
+ // map mid-flight: the rerun read the new rows, computed `lan1 → lan1`, found
167
+ // no mapping for `memory.main`, and stamped a profile whose grants named a
168
+ // connection nothing declared — refused at load, and `needsContract4` false,
169
+ // so no migration would ever run again.
170
+ //
171
+ // Both rewrites are idempotent, which is what makes the order safe rather
172
+ // than merely better: a grant already naming the new ref is not in the map
173
+ // and is left alone, and so is a row.
174
+ await rewriteGrants(workspaceRoot, profiles, renames);
175
+ await renameConnections(workspaceRoot, renames);
176
+
177
+ // Last. The stamp is the record that the migration finished.
178
+ for (const profile of profiles) {
179
+ const document = await ConfigDocument.openKey(workspaceRoot, layout.profileConfig(profile));
180
+ document.setIn(['contract'], 4);
181
+ await document.save();
182
+ }
183
+
184
+ return {
185
+ workspaceRoot,
186
+ profiles,
187
+ changes: [...changes, ...credentials],
188
+ shared: plan.shared,
189
+ orphaned: plan.orphaned,
190
+ alreadyCurrent: false,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Move each stored credential to the ref its renamed connection now derives.
196
+ *
197
+ * **Throws rather than warns**, and that is the whole of its error handling. It
198
+ * warned once, and the rehearsal that found it showed why it must not: the
199
+ * warning was printed, `renameConnections` ran anyway, and the workspace came
200
+ * out with rows naming `gmail.con1` while the secret sat at
201
+ * `gmail/wjj_andrews`. A rerun cannot repair that — the rows are renamed, so
202
+ * the second run computes no rename for them and the old ref is orphaned with
203
+ * nothing left that knows what it belonged to.
204
+ *
205
+ * Failing here leaves the rows untouched, which is the state a rerun *can*
206
+ * finish from. Bytes that already moved are found in place and skipped.
207
+ */
208
+ async function moveCredentials(
209
+ root: string,
210
+ target: string,
211
+ rows: readonly { id: string; provider: string }[],
212
+ renames: Renames,
213
+ profiles: ReadonlyMap<string, { grants?: { connection?: unknown }[] }>,
214
+ ): Promise<string[]> {
215
+ if (rows.length === 0) return [];
216
+
217
+ const [store, registry] = await Promise.all([
218
+ openSecretStoreFor(root, target),
219
+ buildRegistryWithWorkspace(root),
220
+ ]);
221
+
222
+ // `readConnectionRows`, not `readConnections`. The latter runs
223
+ // `assertNoRenamedProviders`, which refuses a `tasks` row — and a workspace
224
+ // holding one is exactly the workspace being migrated, so reading through the
225
+ // guard makes the refusal block its own fix. ADR-051's rule, met twice now: a
226
+ // refusal has to name a command, and the command has to be able to run.
227
+ // The vault's document is planned separately because `credentialRefFor`
228
+ // cannot name it — see `planVaultMoves`. One apply for both, so a source
229
+ // feeding several destinations is deleted once rather than per plan.
230
+ const declared = (await openTarget(root, target)).declared;
231
+
232
+ return await applyCredentialMoves(store, [
233
+ ...planCredentialMoves(await readConnectionRows(root, true), registry, renames),
234
+ ...(declared.vault?.adapter === 'secret'
235
+ ? planVaultMoves(profiles, renames, declared.vault.ref)
236
+ : []),
237
+ ]);
238
+ }
239
+
240
+ /**
241
+ * `lanes-link.yaml` becomes `workspaces.yaml`.
242
+ *
243
+ * Written then deleted rather than moved, so an interruption leaves both and
244
+ * `readWorkspace` — which prefers the new name — still opens the workspace.
245
+ * Losing this file is losing the address of every target.
246
+ */
247
+ async function renameRegistry(root: string): Promise<void> {
248
+ const files = workspaceFiles(root);
249
+ if (await files.has(WORKSPACE_FILE)) return;
250
+
251
+ const text = await readWorkspaceFile(files, C3.workspace);
252
+ if (text === null) return;
253
+
254
+ await writeWorkspaceFile(files, WORKSPACE_FILE, text);
255
+ if ((await readWorkspaceFile(files, WORKSPACE_FILE)) === null) {
256
+ throw new ConfigError(
257
+ `${WORKSPACE_FILE} did not read back after being written. Nothing has been deleted; ` +
258
+ 'fix the store and run this again.',
259
+ );
260
+ }
261
+ await files.delete(C3.workspace);
262
+ }
263
+
264
+ /**
265
+ * What a `knowledge:` repository needs done by hand, named rather than left.
266
+ *
267
+ * A profile keeping its memory and entities in GitHub addresses them by the
268
+ * connection id — an entry reaches the repository as `memory/<id>/<entry>.md`.
269
+ * Contract 4 renames the id, so the provider starts reading `memory/lan1/`
270
+ * while the repository still holds `memory/main/`: `memory_search` returns
271
+ * nothing and `entities_find` finds nobody, with the data sitting intact under
272
+ * the old name and nothing having failed.
273
+ *
274
+ * Not repaired here on purpose. Renaming directories in somebody's repository
275
+ * is a network write to a thing this migration does not own, and `applyMoves`
276
+ * cannot reach a GitHub store at all — so the honest move is to say exactly
277
+ * what to rename, which is one `git mv` per area. `removalPlan` warns about the
278
+ * same class for the same reason.
279
+ */
280
+ function repositoryNotes(
281
+ configs: ReadonlyMap<string, { knowledge?: unknown; grants?: { connection?: unknown }[] }>,
282
+ renames: Renames,
283
+ ): string[] {
284
+ const notes: string[] = [];
285
+
286
+ for (const [profile, config] of configs) {
287
+ const repo = (config.knowledge as { repo?: unknown } | undefined)?.repo;
288
+ if (typeof repo !== 'string') continue;
289
+
290
+ for (const surface of ['memory', 'entities'] as const) {
291
+ const granted = (config.grants ?? [])
292
+ .map((grant) => grant.connection)
293
+ .find((ref): ref is string => typeof ref === 'string' && ref.startsWith(`${surface}.`));
294
+ if (granted === undefined) continue;
295
+
296
+ const to = renames.get(granted);
297
+ if (to === undefined) continue;
298
+
299
+ const was = granted.slice(granted.indexOf('.') + 1);
300
+ const now = to.slice(to.indexOf('.') + 1);
301
+ if (was === now) continue;
302
+
303
+ notes.push(
304
+ `${repo}: rename ${surface}/${was}/ to ${surface}/${now}/ — "${profile}" reads it by the ` +
305
+ 'connection id, and nothing here can write to your repository',
306
+ );
307
+ }
308
+ }
309
+
310
+ return notes;
311
+ }
312
+
313
+ function describe(plan: DataPlan, profiles: readonly string[]): string[] {
314
+ const changes = [`${C3.workspace} → ${WORKSPACE_FILE}`];
315
+
316
+ for (const profile of profiles) {
317
+ changes.push(`${C3.profile(profile)} → ${layout.profileConfig(profile)}: contract 4`);
318
+ }
319
+
320
+ const moved = plan.moves.filter((move) => move.keep !== true).length;
321
+ if (moved > 0) changes.push(`${moved} object(s) moved out of data/`);
322
+
323
+ for (const { key, profiles: owners } of plan.shared) {
324
+ changes.push(`${key}: copied into ${owners.join(' and ')} — the original is left for you`);
325
+ }
326
+ for (const key of plan.orphaned) {
327
+ changes.push(`${key}: no profile grants it, so it stays where it is`);
328
+ }
329
+
330
+ // Said as what it is. Contract 3 merged these and deliberately did not delete
331
+ // them, so a workspace that came through it still holds a decryptable
332
+ // credential document per profile — and reporting that as an ungranted store
333
+ // reads as tidy-up rather than as a credential left on disk.
334
+ for (const key of plan.leftover) {
335
+ changes.push(
336
+ `${key}: a credential store contract 3 merged and left behind — its contents are in ` +
337
+ `${layout.credentials()} now, so delete it`,
338
+ );
339
+ }
340
+
341
+ return changes;
342
+ }
@@ -9,7 +9,7 @@ import type { Config, DeployConfig, TargetConfig } from '#profile';
9
9
  *
10
10
  * Shared because getting it wrong is silent in the worst way. `outputs` asked
11
11
  * the driver; `mcp add` built the local URL unconditionally, so
12
- * `lanes link mcp add --target cloud` registered `http://127.0.0.1:7337/mcp`
12
+ * `lanes link mcp add --workspace cloud` registered `http://127.0.0.1:7337/mcp`
13
13
  * with the agent — a registration that looks successful, names the right
14
14
  * server, and points at a port with nothing behind it. One of the two had the
15
15
  * answer and the other could not see it, which is what a copied line does
@@ -88,10 +88,10 @@ export async function resolveAccount(
88
88
  if (!primary || !identity.qualifier) return primary;
89
89
 
90
90
  // `alice (Acme)` rather than `alice`. The bracketed half is what makes
91
- // two workspaces two accounts instead of one overwritten twice, and it
92
- // survives into the connection id because `idFromAccount` slugifies the
93
- // whole string when there is no `@` in it `alice_acme`, which is a row
94
- // somebody can read in `status`.
91
+ // two workspaces two accounts instead of one overwritten twice: the
92
+ // reconnect match below is on the account, so without it the second
93
+ // connect repairs the first row rather than adding one. It used to reach
94
+ // the id as well, back when the id was slugified from this.
95
95
  const qualifier = pluck(body, identity.qualifier);
96
96
  return qualifier ? `${primary} (${qualifier})` : primary;
97
97
  }
@@ -116,29 +116,47 @@ export async function resolveAccount(
116
116
  }
117
117
 
118
118
  /**
119
- * Turn an account into a connection id.
119
+ * `lan` for a surface built into Lanes, `con` for somebody's account.
120
120
  *
121
- * `ada.lovelace@example.com` becomes `ada_lovelace`, which is what appears in
122
- * `credential_ref` and in the agent's `connection` argument. The local part is
123
- * enough to tell accounts apart in practice, and the full address is still
124
- * right there in `account` when it is not.
121
+ * The prefix is the only thing an id says, and it says the one thing that is
122
+ * true forever: whether there is a vendor behind this row. Everything else a
123
+ * reader wants whose mailbox, what the operator calls it — is `account` and
124
+ * `label`, one field each, both changeable without moving a reference.
125
125
  */
126
- export function idFromAccount(account: string, taken: readonly string[] = []): string {
127
- const local = account.includes('@') ? (account.split('@')[0] ?? account) : account;
128
-
129
- const base =
130
- local
131
- .toLowerCase()
132
- .replace(/[^a-z0-9]+/g, '_')
133
- .replace(/^_+|_+$/g, '')
134
- .slice(0, 40) || 'main';
135
-
136
- if (!taken.includes(base)) return base;
137
-
138
- // A genuine collision — two accounts sharing a local part, e.g. the same
139
- // name at two domains. Suffixing beats overwriting someone else's credential.
140
- for (let n = 2; ; n++) {
141
- const candidate = `${base}${n}`;
142
- if (!taken.includes(candidate)) return candidate;
126
+ export const OWNER_ID_PREFIX = 'lan';
127
+ export const ACCOUNT_ID_PREFIX = 'con';
128
+
129
+ /**
130
+ * The next free connection id.
131
+ *
132
+ * **Opaque, where this used to derive the id from the account.**
133
+ * `ada.lovelace@example.com` became `ada_lovelace`, on the reasoning that the
134
+ * local part tells accounts apart in practice. It does not: the same name at
135
+ * two domains produced `ada_lovelace` and `ada_lovelace2`, and an id that half
136
+ * describes its account is worse than one that does not, because it invites
137
+ * being trusted. What made that concrete is that the id is the whole of the
138
+ * `connection` enum a model chooses from.
139
+ *
140
+ * So the id is a key and nothing else. `account` carries the identity the
141
+ * provider reports and `label` the operator's own word, which means a `relabel`
142
+ * never moves a `credential_ref`, a blob path, or a grant.
143
+ *
144
+ * **A leading letter, not a bare number.** `id: 001` parses as the integer `1`
145
+ * in YAML, so `gmail.001` in a grant would match nothing and every id would need
146
+ * quoting forever. `con1` is a string unconditionally.
147
+ *
148
+ * Numbers are never reused: the highest taken plus one, so an id that appears
149
+ * in an audit log years later still means the row it meant then.
150
+ */
151
+ export function nextConnectionId(taken: readonly string[], owner: boolean): string {
152
+ const prefix = owner ? OWNER_ID_PREFIX : ACCOUNT_ID_PREFIX;
153
+ const pattern = new RegExp(`^${prefix}([0-9]+)$`);
154
+
155
+ let highest = 0;
156
+ for (const id of taken) {
157
+ const match = pattern.exec(id);
158
+ if (match) highest = Math.max(highest, Number(match[1]));
143
159
  }
160
+
161
+ return `${prefix}${highest + 1}`;
144
162
  }