@lanes-sh/link 0.7.2 → 0.8.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 (156) 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 +136 -61
  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 +65 -0
  17. package/src/auth/remote.ts +32 -9
  18. package/src/cli/accepts.ts +108 -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 +88 -87
  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 +4 -2
  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 +182 -165
  40. package/src/cli/commands/grant.ts +140 -0
  41. package/src/cli/commands/identity.ts +21 -9
  42. package/src/cli/commands/knowledge/index.ts +46 -79
  43. package/src/cli/commands/knowledge/migrate.ts +74 -13
  44. package/src/cli/commands/knowledge/show.ts +92 -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 +29 -12
  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 +52 -4
  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/shared.ts +13 -2
  66. package/src/cli/commands/owner/skills.ts +28 -8
  67. package/src/cli/commands/profile/removal.ts +79 -76
  68. package/src/cli/commands/profile/remove.ts +16 -1
  69. package/src/cli/commands/profile.ts +46 -10
  70. package/src/cli/commands/relabel.ts +112 -0
  71. package/src/cli/commands/secrets.ts +34 -12
  72. package/src/cli/commands/set-workspace.ts +96 -0
  73. package/src/cli/commands/setup.ts +2 -2
  74. package/src/cli/commands/sync.ts +8 -8
  75. package/src/cli/commands/target.ts +9 -7
  76. package/src/cli/commands/update.ts +58 -17
  77. package/src/cli/config-edit.ts +75 -140
  78. package/src/cli/config-migrate.ts +82 -64
  79. package/src/cli/config-repair.ts +89 -32
  80. package/src/cli/config-templates.ts +198 -0
  81. package/src/cli/contract3-data.ts +328 -0
  82. package/src/cli/contract3-shape.ts +186 -0
  83. package/src/cli/contract3.ts +282 -0
  84. package/src/cli/endpoint-url.ts +1 -1
  85. package/src/cli/lanes.ts +25 -1
  86. package/src/cli/main.ts +89 -14
  87. package/src/cli/migrate-plan.ts +12 -6
  88. package/src/cli/output.ts +34 -1
  89. package/src/cli/publish.ts +5 -2
  90. package/src/cli/runtime/open.ts +63 -98
  91. package/src/cli/runtime/registry.ts +6 -7
  92. package/src/cli/runtime/stores.ts +53 -0
  93. package/src/cli/runtime/types.ts +106 -0
  94. package/src/cli/runtime/vault.ts +19 -4
  95. package/src/cli/runtime/workspace.ts +60 -0
  96. package/src/cli/runtime.ts +2 -1
  97. package/src/cli/selection-require.ts +44 -13
  98. package/src/cli/selection.ts +127 -145
  99. package/src/cli/usage.ts +34 -18
  100. package/src/cli/workspace-migrate.ts +125 -16
  101. package/src/connectivity/manifest/provider.ts +3 -1
  102. package/src/connectivity/manifest/requirements.ts +1 -1
  103. package/src/deployments/bind.ts +1 -1
  104. package/src/deployments/deploy.ts +36 -27
  105. package/src/deployments/gcp/bucket.ts +18 -7
  106. package/src/deployments/gcp/provision.ts +7 -7
  107. package/src/deployments/prepare.ts +72 -24
  108. package/src/deployments/record.ts +1 -1
  109. package/src/deployments/report.ts +2 -2
  110. package/src/deployments/serving.ts +15 -74
  111. package/src/deployments/target.ts +15 -15
  112. package/src/deployments/upload.ts +46 -22
  113. package/src/dispatch/deps.ts +88 -0
  114. package/src/dispatch/dispatch.ts +21 -62
  115. package/src/policy/index.ts +47 -15
  116. package/src/profile/connections.ts +183 -0
  117. package/src/profile/deployments.ts +3 -3
  118. package/src/profile/index.ts +30 -5
  119. package/src/profile/layout.ts +86 -89
  120. package/src/profile/load.ts +80 -47
  121. package/src/profile/pairing.ts +32 -0
  122. package/src/profile/primitives.ts +35 -1
  123. package/src/profile/registry.ts +6 -6
  124. package/src/profile/schema.ts +172 -21
  125. package/src/profile/targets.ts +21 -9
  126. package/src/profile/testing.ts +69 -2
  127. package/src/profile/workspace.ts +58 -3
  128. package/src/providers/custom/index.ts +1 -1
  129. package/src/providers/custom/load.ts +2 -3
  130. package/src/providers/identity/provider.ts +1 -1
  131. package/src/providers/memory/provider.ts +20 -2
  132. package/src/providers/setup/plan.ts +1 -1
  133. package/src/providers/slack/index.ts +2 -2
  134. package/src/registry/policy-bridge.ts +33 -11
  135. package/src/registry/reconcile.ts +4 -4
  136. package/src/server/authorization.ts +94 -0
  137. package/src/server/edge.ts +14 -1
  138. package/src/server/endpoint.ts +85 -104
  139. package/src/server/generation.ts +10 -1
  140. package/src/server/harness.ts +71 -13
  141. package/src/server/index.ts +31 -0
  142. package/src/server/mcp/build.ts +20 -1
  143. package/src/server/mcp/client-info.ts +54 -0
  144. package/src/server/mcp/guide.ts +120 -0
  145. package/src/server/mcp/instructions.ts +1 -1
  146. package/src/server/mcp/prompts.ts +7 -3
  147. package/src/server/mcp/resources.ts +16 -8
  148. package/src/server/mcp/tools.ts +9 -3
  149. package/src/server/mcp/visibility.ts +18 -3
  150. package/src/server/oauth.ts +29 -109
  151. package/src/server/read/credential.ts +134 -0
  152. package/src/server/read/deployed.ts +56 -0
  153. package/src/server/read/listener.ts +54 -0
  154. package/src/server/read/open.ts +101 -0
  155. package/src/server/read/routes.ts +247 -0
  156. package/src/server/read/state.ts +171 -0
@@ -1,3 +1,5 @@
1
+ import { migrateToContract3, needsContract3, type Contract3Migration } from './contract3.ts';
2
+ import { readSession } from '#auth/lanes/session.ts';
1
3
  import { parseDocument } from 'yaml';
2
4
  import {
3
5
  ConfigError,
@@ -25,7 +27,7 @@ import { hoist, summarise } from './migrate-plan.ts';
25
27
  * of each profile — one in `~/.lanes-link`, one in the bucket the endpoint reads
26
28
  * — and gave them nothing to keep them honest. It failed the way it was always
27
29
  * going to: a local file was rewritten, lost its cloud target and eight
28
- * connections, and `status --target cloud` reported seven where the endpoint was
30
+ * connections, and `status --workspace cloud` reported seven where the endpoint was
29
31
  * serving fifteen. `sync targets` and ADR-044's deployment index were both
30
32
  * written in response to earlier rounds of the same thing.
31
33
  *
@@ -122,8 +124,8 @@ export async function migrateWorkspace(
122
124
  const changes: string[] = [];
123
125
  for (const [name, entry] of Object.entries(registry)) {
124
126
  changes.push(
125
- entry.workspace !== undefined
126
- ? `targets.${name}: pointer to ${entry.workspace}`
127
+ entry.at !== undefined
128
+ ? `workspaces.${name}: pointer to ${entry.at}`
127
129
  : `targets.${name}: declared in ${WORKSPACE_FILE}`,
128
130
  );
129
131
  }
@@ -147,17 +149,30 @@ export async function migrateWorkspace(
147
149
  // again.
148
150
  await writeRegistry(workspaceRoot, registry);
149
151
 
150
- for (const { document } of legacy) {
152
+ for (const { document, profile } of legacy) {
151
153
  document.removeIn(['targets']);
152
- document.setIn(['contract'], SUPPORTED_CONTRACT);
154
+ // Literally 2, not `SUPPORTED_CONTRACT`. This migration produces a
155
+ // contract-2 profile and nothing else; `migrateToContract3` takes it the
156
+ // rest of the way. Writing the current contract here would stamp a file
157
+ // that still has contract-2 shapes with the newest number, and the loader
158
+ // would then read it as a contract-3 document that is missing `grants:`.
159
+ document.setIn(['contract'], 2);
153
160
  // `instance.default_target` went with the block it selected from. It has
154
161
  // been inert since ADR-037 and there is now nothing for it to name.
155
162
  document.removeIn(['instance', 'default_target']);
156
- // Shape-only: a profile may carry an unrelated problem the full loader
157
- // refuses a connection row still spelling a renamed provider is the one
158
- // that happens and blocking the structural fix on it would strand the file
159
- // at a contract nothing reads. `doctor --fix` names and repairs that one.
160
- await document.save({ shapeOnly: true });
163
+ // Written rather than saved, because `save` validates and what this
164
+ // produces is deliberately not valid *yet*: a contract-2 profile, which the
165
+ // runtime does not read. `migrateToContract3` is the step that makes it
166
+ // loadable, and it runs immediately after so validating here would refuse
167
+ // the only shape this function is able to produce.
168
+ //
169
+ // Nothing is lost by not validating: the next step reads the file it just
170
+ // wrote, and `check` refuses anything either step leaves broken.
171
+ await writeWorkspaceFile(
172
+ workspaceFiles(workspaceRoot),
173
+ `profiles/${profile}.yaml`,
174
+ document.toString(),
175
+ );
161
176
  }
162
177
 
163
178
  return {
@@ -169,6 +184,82 @@ export async function migrateWorkspace(
169
184
  };
170
185
  }
171
186
 
187
+ export interface ContractMigration {
188
+ readonly workspaceRoot: string;
189
+ /** The contract 1 → 2 half, when this workspace needed one. */
190
+ readonly legacy: WorkspaceMigration | null;
191
+ /** The contract 2 → 3 half, when this workspace needed one. */
192
+ readonly contract3: Contract3Migration | null;
193
+ /** Every profile either half rewrote, deduplicated. */
194
+ readonly profiles: readonly string[];
195
+ /** Targets written into the registry by the contract 1 → 2 half. */
196
+ readonly targets: readonly { name: string; kind: 'declared' | 'pointer'; where?: string }[];
197
+ /** Both halves' lines, in the order they happened. */
198
+ readonly changes: readonly string[];
199
+ /** Nothing to do: this workspace is already at `SUPPORTED_CONTRACT`. */
200
+ readonly alreadyCurrent: boolean;
201
+ }
202
+
203
+ /**
204
+ * Bring one workspace to `SUPPORTED_CONTRACT`, whatever it is on now.
205
+ *
206
+ * **This exists because "migrate a workspace" was spelled three times and only
207
+ * one of them arrived.** `update` ran the 1→2 step and then the 2→3 step;
208
+ * `deploy` and `doctor --fix` ran the first and stopped, which nothing said out
209
+ * loud — `migrateTargetWorkspace`'s own docstring claimed it brought a target
210
+ * "to the current contract" while calling only `migrateWorkspace`.
211
+ *
212
+ * What that cost is worth writing down, because it is not a refusal. A deploy
213
+ * uploaded contract-3 config over a contract-2 bucket and rolled a revision that
214
+ * read `data/state.kv` and `data/audit.log` — while the bytes sat where contract
215
+ * 2 put them, under `data/<profile>/`. The endpoint came up healthy and answered
216
+ * every call with an empty store: no memory, no tasks, no skills, and an audit
217
+ * log that verified as intact because an empty chain does. Nothing in the
218
+ * deploy, the health probe, or `doctor` had a way to notice.
219
+ *
220
+ * So there is one function now, and the contract number lives in one place. A
221
+ * fourth contract adds a step here and every caller gets it.
222
+ *
223
+ * **A dry run of a contract-1 workspace reports only the first half**, and that
224
+ * is honest rather than a gap: the 1→2 step wrote nothing, so the profiles are
225
+ * still contract 1 and the 2→3 step has nothing it recognises to describe. What
226
+ * it would do is knowable only after the step before it has run.
227
+ *
228
+ * **The signed-in subject is defaulted here rather than by each caller.** A
229
+ * migrated profile that lists nobody is an endpoint that advertises OAuth and
230
+ * then refuses its own owner, so it matters on every path — but `deployments`
231
+ * may not import `#auth` (see `MAY_IMPORT` in `src/architecture.test.ts`), and
232
+ * `deploy` is the caller that most needs it: once a target's profiles live in
233
+ * its bucket there is no upload behind it to put the members row back. A caller
234
+ * that knows better passes one; nobody signed in is a legitimate answer and
235
+ * stays default-deny on the identity axis.
236
+ */
237
+ export async function migrateToCurrentContract(
238
+ workspaceRoot: string,
239
+ options: { apply: boolean; subject?: string } = { apply: true },
240
+ ): Promise<ContractMigration> {
241
+ const legacy = (await needsMigration(workspaceRoot))
242
+ ? await migrateWorkspace(workspaceRoot, { apply: options.apply })
243
+ : null;
244
+
245
+ const subject = options.subject ?? (await readSession().catch(() => null))?.subject;
246
+
247
+ const contract3 = await migrateToContract3(workspaceRoot, {
248
+ apply: options.apply,
249
+ ...(subject === undefined ? {} : { subject }),
250
+ });
251
+
252
+ return {
253
+ workspaceRoot,
254
+ legacy: legacy !== null && !legacy.alreadyCurrent ? legacy : null,
255
+ contract3: contract3.alreadyCurrent ? null : contract3,
256
+ profiles: [...new Set([...(legacy?.profiles ?? []), ...contract3.profiles])],
257
+ targets: legacy?.targets ?? [],
258
+ changes: [...(legacy?.changes ?? []), ...contract3.changes],
259
+ alreadyCurrent: (legacy === null || legacy.alreadyCurrent) && contract3.alreadyCurrent,
260
+ };
261
+ }
262
+
172
263
  /**
173
264
  * Write the registry into the workspace file, creating it when absent.
174
265
  *
@@ -186,7 +277,7 @@ async function writeRegistry(
186
277
  const text = (await readWorkspaceFile(files, WORKSPACE_FILE)) ?? `contract: ${SUPPORTED_CONTRACT}\n`;
187
278
  const document = parseDocument(text);
188
279
  const current = (document.toJSON() ?? {}) as {
189
- targets?: Record<string, WorkspaceTarget>;
280
+ workspaces?: Record<string, WorkspaceTarget>;
190
281
  deployments?: {
191
282
  target?: string;
192
283
  workspace?: string;
@@ -198,7 +289,7 @@ async function writeRegistry(
198
289
  // Anything already in the file wins over what was hoisted: a workspace part
199
290
  // way through this has entries that are already right, and re-deriving them
200
291
  // from a profile that still carries a stale block would undo a correction.
201
- const merged: Record<string, WorkspaceTarget> = { ...registry, ...(current.targets ?? {}) };
292
+ const merged: Record<string, WorkspaceTarget> = { ...registry, ...(current.workspaces ?? {}) };
202
293
 
203
294
  // ADR-044's index, folded into the entries it described. `primary` and
204
295
  // `last_deploy` were kept beside the declaration precisely because the
@@ -214,11 +305,16 @@ async function writeRegistry(
214
305
  };
215
306
  }
216
307
 
308
+ // `workspaces:`, not `targets:` — the registry is read back by the current
309
+ // schema even while the profiles beside it are still contract 2, because it
310
+ // is one document that both migrations share (ADR-061). The profiles are the
311
+ // half that stays at 2 until `migrateToContract3` runs.
217
312
  document.setIn(['contract'], SUPPORTED_CONTRACT);
218
313
  document.setIn(
219
- ['targets'],
314
+ ['workspaces'],
220
315
  Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b))),
221
316
  );
317
+ document.deleteIn(['targets']);
222
318
  document.deleteIn(['deployments']);
223
319
  document.deleteIn(['default_target']);
224
320
 
@@ -229,8 +325,8 @@ function describe(
229
325
  registry: Record<string, WorkspaceTarget>,
230
326
  ): { name: string; kind: 'declared' | 'pointer'; where?: string }[] {
231
327
  return Object.entries(registry).map(([name, entry]) =>
232
- entry.workspace !== undefined
233
- ? { name, kind: 'pointer' as const, where: entry.workspace }
328
+ entry.at !== undefined
329
+ ? { name, kind: 'pointer' as const, where: entry.at }
234
330
  : { name, kind: 'declared' as const },
235
331
  );
236
332
  }
@@ -249,6 +345,19 @@ function describe(
249
345
  * part of what it already means (ADR-052).
250
346
  */
251
347
  export async function refuseIfUnmigrated(root: string): Promise<void> {
348
+ if (await needsContract3(root)) {
349
+ throw new ConfigError(
350
+ `${root} is a contract 2 workspace, and this version does not read one.\n\n` +
351
+ ' Under contract 2 each profile carried its own connections and one flat\n' +
352
+ ' policy. Connections belong to the workspace now, and a profile selects\n' +
353
+ ' among them with per-connection scopes (ADR-057, ADR-058).\n\n' +
354
+ ' Migrate it: lanes link update\n' +
355
+ ' Preview it: lanes link update --check\n\n' +
356
+ ' Nothing is deleted by the migration: credentials are merged and read back\n' +
357
+ ' before the old stores are left in place.',
358
+ );
359
+ }
360
+
252
361
  if (!(await needsMigration(root))) return;
253
362
 
254
363
  throw new ConfigError(
@@ -258,6 +367,6 @@ export async function refuseIfUnmigrated(root: string): Promise<void> {
258
367
  ' copies of every profile that could drift apart (ADR-052).\n\n' +
259
368
  ' Migrate it: lanes link update\n' +
260
369
  ' A deployed target is migrated by the deploy that ships the image reading it:\n' +
261
- ' lanes link deploy --target <name>',
370
+ ' lanes link deploy --workspace <name>',
262
371
  );
263
372
  }
@@ -18,7 +18,9 @@ import { connectionVariableSchema, placeholdersInConnector } from './variables.t
18
18
  *
19
19
  * - built-ins, written as typed TS modules under `#providers/` and validated
20
20
  * at import
21
- * - the profile's own manifests in `data/<profile>/providers.d/*.yaml`, validated on load
21
+ * - the workspace's own manifests in `data/providers.d/*.yaml`, validated on load
22
+ * — the workspace's, not a profile's, because a manifest defines a
23
+ * connection and connections stopped belonging to profiles (ADR-057)
22
24
  *
23
25
  * That second one is the scalability claim. A service nobody has integrated is
24
26
  * a YAML file the operator writes, not a pull request they wait on.
@@ -76,7 +76,7 @@ export interface SetupNeeds {
76
76
  function storeCommand(ref: string, placeholder: string, where: Selection): string {
77
77
  return (
78
78
  `printf %s "${placeholder}" | lanes link secrets set ${ref}` +
79
- ` --profile ${where.profile} --target ${where.target}`
79
+ ` --profile ${where.profile} --workspace ${where.target}`
80
80
  );
81
81
  }
82
82
 
@@ -121,7 +121,7 @@ export async function bindConnectionCredentials(input: {
121
121
  `could not bind ${connection.provider}.${connection.id}'s credential to ` +
122
122
  `${serviceAccount}, so the deployed endpoint will be able to read it and not ` +
123
123
  'rotate it — which fails about an hour after the first use. ' +
124
- `Run \`lanes link deploy --target ${input.target}\` to bind it. ` +
124
+ `Run \`lanes link deploy --workspace ${input.target}\` to bind it. ` +
125
125
  `(${driver.tool}: ${result.stderr.trim().split('\n').slice(-1)[0] ?? 'failed'})`,
126
126
  };
127
127
  }
@@ -15,9 +15,9 @@ import { printSteps, runSteps } from './steps.ts';
15
15
  import { driverFor } from './drivers.ts';
16
16
  import { prepareSecrets, readableRefs, rotatableRefs } from './prepare.ts';
17
17
  import { repairOwnerLayer } from '#cli/config-repair.ts';
18
- import { migrateWorkspace } from '#cli/workspace-migrate.ts';
18
+ import { migrateToCurrentContract } from '#cli/workspace-migrate.ts';
19
19
  import { deployedWorkspace, uploadWorkspace } from './upload.ts';
20
- import { collidingRefs, collisionRefusal, servingProfiles } from './serving.ts';
20
+ import { servingProfiles } from './serving.ts';
21
21
  import { healthLine, reachability, registerLine, reportUnauthorised } from './report.ts';
22
22
 
23
23
  /**
@@ -75,9 +75,14 @@ export async function deploy(flags: DeployFlags): Promise<void> {
75
75
  // declare anything: it reads this machine's pointer and stops. So the bucket is
76
76
  // located, migrated, and only then opened.
77
77
  //
78
- // Idempotent, and silent on a workspace already at contract 2 — a listing and
79
- // no writes. `--dry-run` reports what it would do and writes nothing, like
80
- // every other step of this command.
78
+ // Idempotent, and silent on a workspace already current — a listing and no
79
+ // writes. `--dry-run` reports what it would do and writes nothing, like every
80
+ // other step of this command.
81
+ //
82
+ // It goes all the way to `SUPPORTED_CONTRACT`, which it did not use to: this
83
+ // ran the contract 1→2 step alone, so a contract-2 bucket passed straight
84
+ // through and the revision came up reading an empty `data/` while the bytes
85
+ // stayed under `data/<profile>/`. See `migrateToCurrentContract`.
81
86
  if (!(await migrateTargetWorkspace(requireTargetFlag(flags), flags.dryRun !== true))) return;
82
87
 
83
88
  // The one command allowed to name a target that does not exist yet: creating
@@ -112,14 +117,13 @@ export async function deploy(flags: DeployFlags): Promise<void> {
112
117
  print(style.dim(` serving ${serving.join(', ')} — ${primary} owns the token`));
113
118
  }
114
119
 
115
- // Before anything external, beside `check`, and for the same reason: two
116
- // profiles writing one flat credential ref into one store is a failure with
117
- // no later symptom worth having.
118
- const colliding = await collidingRefs(resolution.workspaceRoot, serving);
119
- if (colliding.length > 0) {
120
- heading('Cannot share a credential store');
121
- throw new ConfigError(collisionRefusal(colliding, target));
122
- }
120
+ // The credential-collision preflight is gone. It existed because two profiles
121
+ // each held their own `gmail.main` and both derived the flat ref `gmail/main`
122
+ // into one store — the last deploy winning, with no later symptom worth
123
+ // having. A connection belongs to the workspace now and `<provider>.<id>` is
124
+ // unique by construction (ADR-057), so the state it guarded against cannot be
125
+ // written: `assertConnectionsUnique` refuses it at load, before a deploy runs
126
+ // at all.
123
127
 
124
128
  // `check` before anything external, per the gate order: a config that will be
125
129
  // rejected on boot should be rejected here, not after a five-minute build.
@@ -150,7 +154,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
150
154
  // below are, and read from config and manifests before anything opens a
151
155
  // store — `--dry-run` must reach the printed step list without touching a
152
156
  // credential.
153
- const rotatable = await rotatableRefs(resolution.workspaceRoot, serving);
157
+ const rotatable = await rotatableRefs(resolution.workspaceRoot, serving, declared);
154
158
  const readable = await readableRefs(resolution.workspaceRoot, serving, declared);
155
159
  const provision = await driver.provision({
156
160
  deploy: deployConfig,
@@ -240,7 +244,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
240
244
  for (const problem of prepared.blocking) print(fail(problem));
241
245
  throw new ConfigError(
242
246
  'The deployed instance cannot start without these. Store them with ' +
243
- `lanes link secrets set <ref> --profile ${resolution.profile} --target ${target}, or ` +
247
+ `lanes link secrets set <ref> --profile ${resolution.profile} --workspace ${target}, or ` +
244
248
  `copy a local setup with lanes link secrets push --profile ${resolution.profile} ` +
245
249
  `--from local --to ${target}.`,
246
250
  );
@@ -275,6 +279,17 @@ export async function deploy(flags: DeployFlags): Promise<void> {
275
279
  // workspace that this target cannot open, and nothing left to check.
276
280
  await repairOwnerLayer(resolution.workspaceRoot, serving);
277
281
 
282
+ // **The bucket's own contract, before anything is written over it.**
283
+ //
284
+ // The pass at the top of the command resolves the target through this
285
+ // machine's pointer, which a *first* deploy does not have: the entry is
286
+ // still a declaration, `resolveTargetWorkspace` answers with the local root,
287
+ // and it returns early. So this is the only pass that ever sees an existing
288
+ // bucket at contract 2 — and running it after the upload is the same as not
289
+ // running it, because the upload writes contract-3 profiles and
290
+ // `needsContract3` reads the profiles.
291
+ await migrateToCurrentContract(workspace, { apply: true });
292
+
278
293
  // Before the rollout, so the revision that comes up finds a config to read.
279
294
  // Uploading after would leave a window where the service is serving and the
280
295
  // workspace it was told to read is not there yet.
@@ -291,15 +306,9 @@ export async function deploy(flags: DeployFlags): Promise<void> {
291
306
  await uploadWorkspace(resolution.workspaceRoot, workspace, serving);
292
307
  }
293
308
 
294
- // **The bucket's own migration, here and nowhere else.**
295
- //
296
- // A second pass, and it is not redundant. The one at the top of the command
297
- // migrated whatever the bucket already held; this catches what the upload
298
- // just put there — profiles from a workspace that is itself at contract 2
299
- // arrive migrated, but a first deploy of a *newly created* bucket writes
300
- // them here for the first time. Idempotent, so the ordinary case is one
301
- // listing and no writes.
302
- await migrateWorkspace(workspace, { apply: true });
309
+ // Again for what the upload put there: a newly created bucket gets its
310
+ // profiles written here for the first time. Idempotent — one listing.
311
+ await migrateToCurrentContract(workspace, { apply: true });
303
312
 
304
313
  // Where this deployment lives, in both registries — see `record.ts`. The
305
314
  // declaration has to land before the revision boots, so it goes here rather
@@ -325,7 +334,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
325
334
  if (!url) {
326
335
  print(
327
336
  warn(
328
- `deployed, but the platform reported no URL yet — run: lanes link outputs --profile ${resolution.profile} --target ${target}`,
337
+ `deployed, but the platform reported no URL yet — run: lanes link outputs --profile ${resolution.profile} --workspace ${target}`,
329
338
  ),
330
339
  );
331
340
  return;
@@ -365,7 +374,7 @@ async function migrateTargetWorkspace(target: string, apply: boolean): Promise<b
365
374
  const workspace = await resolveTargetWorkspace(root, target).catch(() => null);
366
375
  if (workspace === null || workspace === root) return true;
367
376
 
368
- const migrated = await migrateWorkspace(workspace, { apply });
377
+ const migrated = await migrateToCurrentContract(workspace, { apply });
369
378
  if (migrated.alreadyCurrent) return true;
370
379
 
371
380
  heading(apply ? 'Migrated' : 'Would migrate');
@@ -383,6 +392,6 @@ async function migrateTargetWorkspace(target: string, apply: boolean): Promise<b
383
392
  print(style.dim(' Nothing was written, and nothing else was checked: the rest of this'));
384
393
  print(style.dim(' command opens the target, which is not readable until this has run.'));
385
394
  print('');
386
- print(style.dim(` Run it for real: lanes link deploy --target ${target}`));
395
+ print(style.dim(` Run it for real: lanes link deploy --workspace ${target}`));
387
396
  return false;
388
397
  }
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path';
2
- import { installRoot, layout } from '#profile';
2
+ import { CONNECTIONS_FILE, WORKSPACE_FILE, installRoot, layout } from '#profile';
3
3
  import type { DeployStep } from '../driver.ts';
4
4
  import {
5
5
  removalStep,
@@ -77,9 +77,10 @@ export function bucketGrants(bucket: string, profiles: readonly string[]): Condi
77
77
  */
78
78
  const theBucket = `resource.name == "projects/_/buckets/${bucket}"`;
79
79
 
80
- const manifestPrefixes = profiles.map((profile) =>
81
- objectsUnder(`${layout.providers(profile)}/`),
82
- );
80
+ // One prefix, not one per profile. Manifests are the workspace's since
81
+ // ADR-057 — a manifest defines a connection, and connections do not live in a
82
+ // profile — so the carve-out no longer varies with the profile set.
83
+ const manifestPrefixes = [objectsUnder(`${layout.providers()}/`)];
83
84
  // No profiles leaves the carve-out off rather than guessing at one: the
84
85
  // revision keeps write on its own data, as it did before this existed.
85
86
  const manifests = manifestPrefixes.length > 0 ? `(${manifestPrefixes.join(' || ')})` : null;
@@ -95,11 +96,21 @@ export function bucketGrants(bucket: string, profiles: readonly string[]): Condi
95
96
  {
96
97
  // `expression=true` was here, which is every object in the bucket — the
97
98
  // step title and ADR-023 both claim a narrowing this did not do. The
98
- // config the revision reads is the workspace file, the profiles beside it,
99
- // and each profile's own manifests, so name exactly those.
99
+ // config the revision reads is the workspace file, `connections.yaml`
100
+ // beside it, the profiles, and the manifests, so name exactly those.
101
+ //
102
+ // **Named by the constants, because the list went stale once already.**
103
+ // ADR-057 moved connections out of the profile into `connections.yaml` at
104
+ // the workspace root, and `upload.ts` puts it in the bucket because "the
105
+ // endpoint cannot resolve a single grant without it" — but this expression
106
+ // still enumerated the contract-2 set. Every fresh deploy built its image,
107
+ // rolled a revision, and died on `GCS refused to read "connections.yaml"
108
+ // (403)` before it could listen on its port. Nothing caught it: the
109
+ // condition is a string assembled here and asserted nowhere, so the suite
110
+ // saw a passing build and Cloud Run saw a container that never started.
100
111
  role: 'roles/storage.objectViewer',
101
112
  title: 'reads-its-config',
102
- expression: `${theBucket} || ${objectsUnder('profiles/')} || ${objectIs('lanes-link.yaml')}${manifests ? ` || ${manifests}` : ''}`,
113
+ expression: `${theBucket} || ${objectsUnder('profiles/')} || ${objectIs(WORKSPACE_FILE)} || ${objectIs(CONNECTIONS_FILE)}${manifests ? ` || ${manifests}` : ''}`,
103
114
  },
104
115
  ];
105
116
  }
@@ -1,4 +1,4 @@
1
- import { VAULT_DOCUMENT_REF, type SecretRef } from '#secrets';
1
+ import type { SecretRef } from '#secrets';
2
2
  import type { DeployStep, ProvisionInput } from '../driver.ts';
3
3
  import { encodeRef } from '../adapters/gcp-secret-manager.ts';
4
4
  import { bucketSteps } from './bucket.ts';
@@ -312,12 +312,12 @@ export async function provisionSteps(
312
312
  // mind: nothing here was wrong, it was incomplete, and being incomplete
313
313
  // looked exactly like being finished. Reading mail 403'd an hour after every
314
314
  // deploy.
315
- const rotatable = [
316
- ...(input.declared.vault?.adapter === 'secret'
317
- ? [input.declared.vault.ref ?? VAULT_DOCUMENT_REF]
318
- : []),
319
- ...(input.rotatable ?? []),
320
- ];
315
+ // Both kinds arrive already derived: the vault document is named per vault
316
+ // connection (ADR-059) and only `prepare.ts` has the profile configs to work
317
+ // it out. Naming `vault/document` here — the contract-2 constant — created
318
+ // and granted one secret while `openVault` asked for another, and the
319
+ // revision died on `PERMISSION_DENIED` before it could listen. See `vaultRef`.
320
+ const rotatable = [...(input.rotatable ?? [])];
321
321
 
322
322
  // Read, named one secret at a time, for the same reason the write side is:
323
323
  // a resource-level grant needs no condition to be scoped.
@@ -1,5 +1,7 @@
1
1
  import { credentialRefFor, ownClientRefsFor, rotatableCredentialRefsFor } from '#registry';
2
- import { listProfiles, loadProfileConfig, type Config, type TargetConfig } from '#profile';
2
+ import {
3
+ PAIR_TOKEN_REF,
4
+ readConnections, listProfiles, loadProfileConfig, vaultRef, type Config, type TargetConfig } from '#profile';
3
5
  import { VAULT_DOCUMENT_REF, VAULT_KEY_REF, generateVaultKey, type SecretStore } from '#secrets';
4
6
  import { ok, print, style, warn } from '#cli/output.ts';
5
7
  import { buildRegistryWithWorkspace, ensureProfileToken } from '#cli/runtime.ts';
@@ -66,6 +68,7 @@ export interface PrepareResult {
66
68
  export async function rotatableRefs(
67
69
  root: string,
68
70
  profiles: readonly string[] | undefined,
71
+ declared: TargetConfig | undefined,
69
72
  ): Promise<string[]> {
70
73
  const refs = new Set<string>();
71
74
  const wanted = profiles === undefined ? undefined : new Set(profiles);
@@ -80,16 +83,27 @@ export async function rotatableRefs(
80
83
  continue;
81
84
  }
82
85
 
83
- // Inside the loop because manifests are the profile's own (ADR-030): a
84
- // connection in `work` naming a provider only `work` declares resolves to
85
- // nothing against `personal`'s registry, and a missed ref here is a 403 an
86
- // hour after the revision reports healthy.
87
- const registry = await buildRegistryWithWorkspace(root, name);
86
+ // `vault.put` is a capability an agent may hold under policy (ADR-022), so
87
+ // the revision rewrites this one and it is per vault connection, which is
88
+ // what makes this a per-profile pass rather than a target-level constant.
89
+ // The loop had been reduced to `void config` when the derivation moved out;
90
+ // this is it moving back, next to the config it needs.
91
+ if (declared?.vault?.adapter === 'secret') refs.add(vaultRef(declared, config));
92
+ }
88
93
 
89
- for (const connection of config.connections) {
90
- const manifest = registry.manifest(connection.provider);
91
- for (const ref of rotatableCredentialRefsFor(connection, manifest)) refs.add(ref);
92
- }
94
+ // Once, outside the profile loop. Connections and the manifests that describe
95
+ // them belong to the workspace (ADR-057), so the per-profile registry ADR-030
96
+ // required is gone — and with it the failure it guarded against, where a
97
+ // connection in `work` resolved to nothing against `personal`'s registry and
98
+ // the missed ref became a 403 an hour after the revision reported healthy.
99
+ //
100
+ // Every connection, not just granted ones: a credential a revision cannot read
101
+ // is a broken account, and whether some profile currently grants it is a
102
+ // question that changes without redeploying.
103
+ const registry = await buildRegistryWithWorkspace(root);
104
+ for (const connection of (await readConnections(root)).connections) {
105
+ const manifest = registry.manifest(connection.provider);
106
+ for (const ref of rotatableCredentialRefsFor(connection, manifest)) refs.add(ref);
93
107
  }
94
108
 
95
109
  // Sorted, and a set: two Gmail connections share one dynamically registered
@@ -132,9 +146,41 @@ export async function readableRefs(
132
146
  ): Promise<string[]> {
133
147
  const refs = new Set<string>();
134
148
 
149
+ // Unconditionally, for every deploy, whether or not this workspace has ever
150
+ // been paired — and that is the point rather than a rounding up.
151
+ //
152
+ // Secret Manager answers a *missing binding* with 403, not 404, so that an
153
+ // identity cannot enumerate secrets by their error codes. The adapter returns
154
+ // null for the 404 and throws for the 403, so an unbound ref is a thrown
155
+ // error on the read path — and that is the failure `server/read/open.ts`
156
+ // records: a deployed revision asked for refs no binding covered and never
157
+ // went healthy.
158
+ //
159
+ // Bound, the deployed-but-never-paired case becomes the 404 instead — a
160
+ // secret that exists with no version — which reads back as null and renders
161
+ // as `401 {error:'unpaired'}`. The grant is what turns a crash into a
162
+ // refusal.
163
+ //
164
+ // Deciding it by asking *whether the workspace is paired* is the thing that
165
+ // cannot happen: that means opening a credential store inside `readableRefs`,
166
+ // which `--dry-run` reaches and must not do.
167
+ //
168
+ // Deliberately not in `rotatableRefs`. The revision never writes this one —
169
+ // minting is `lanes link pair`, from the operator's machine — and
170
+ // `secretVersionAdder` here would let a compromised revision issue itself a
171
+ // credential that reads the whole workspace.
172
+ //
173
+ // The cert and key refs are absent for a different reason: Cloud Run
174
+ // terminates TLS with a certificate a browser already trusts, so a deployed
175
+ // revision never calls `serveRead` and never reads either.
176
+ refs.add(PAIR_TOKEN_REF);
177
+
178
+ // Only the key is the target's. The *document* is named per vault connection
179
+ // (ADR-059), so it is added inside the profile loop below — naming it here
180
+ // meant `vault/document`, which is not what `openVault` opens, and the
181
+ // revision 403'd on a ref nothing had created. See `vaultRef`.
135
182
  if (declared?.vault?.adapter === 'secret') {
136
183
  refs.add(VAULT_KEY_REF);
137
- refs.add(declared.vault.ref ?? VAULT_DOCUMENT_REF);
138
184
  } else if (declared?.vault?.adapter === 'blob') {
139
185
  // Ciphertext lives in the bucket, but the key that opens it is still here.
140
186
  refs.add(VAULT_KEY_REF);
@@ -153,22 +199,24 @@ export async function readableRefs(
153
199
  }
154
200
 
155
201
  refs.add(config.auth.token_ref);
202
+ if (declared?.vault?.adapter === 'secret') refs.add(vaultRef(declared, config));
156
203
  // The OIDC audience check reads this on every verify (`server/endpoint.ts`).
157
204
  if (config.auth.authorization?.mode === 'oidc') {
158
205
  refs.add(config.auth.authorization.client_id_ref);
159
206
  }
160
207
 
161
- // Per profile, for the reason `rotatableRefs` gives: a manifest is this
162
- // profile's, so the registry that resolves its connections must be too.
163
- const registry = await buildRegistryWithWorkspace(root, name);
208
+ }
164
209
 
165
- for (const connection of config.connections) {
166
- const manifest = registry.manifest(connection.provider);
167
- const ref = credentialRefFor(connection, manifest);
168
- if (ref) refs.add(ref);
169
- for (const rotatable of rotatableCredentialRefsFor(connection, manifest)) refs.add(rotatable);
170
- for (const client of ownClientRefsFor(manifest, config.oauth_apps)) refs.add(client);
171
- }
210
+ // Once, for the reason `rotatableRefs` gives.
211
+ const connectionsFile = await readConnections(root);
212
+ const registry = await buildRegistryWithWorkspace(root);
213
+
214
+ for (const connection of connectionsFile.connections) {
215
+ const manifest = registry.manifest(connection.provider);
216
+ const ref = credentialRefFor(connection, manifest);
217
+ if (ref) refs.add(ref);
218
+ for (const rotatable of rotatableCredentialRefsFor(connection, manifest)) refs.add(rotatable);
219
+ for (const client of ownClientRefsFor(manifest, connectionsFile.oauth_apps)) refs.add(client);
172
220
  }
173
221
 
174
222
  return [...refs].sort();
@@ -190,13 +238,13 @@ export async function prepareSecrets(input: PrepareInput): Promise<PrepareResult
190
238
  // The command is spelled out rather than described. This is the one manual
191
239
  // step a deploy genuinely cannot take for you, so it should cost a paste
192
240
  // rather than a trip to the docs to work out how the id is spelled.
193
- const registry = await buildRegistryWithWorkspace(root, config.instance.profile);
194
- for (const connection of config.connections) {
241
+ const registry = await buildRegistryWithWorkspace(root);
242
+ for (const connection of (await readConnections(root)).connections) {
195
243
  const ref = credentialRefFor(connection, registry.manifest(connection.provider));
196
244
  if (!ref || (await credentials.has(ref))) continue;
197
245
  warnings.push(
198
246
  `${connection.provider}.${connection.id} is not authorised yet — no credential at "${ref}"\n` +
199
- ` lanes link connect ${connection.provider} --profile ${input.config.instance.profile} --target ${target} --id ${connection.id}`,
247
+ ` lanes link connect ${connection.provider} --profile ${input.config.instance.profile} --workspace ${target} --id ${connection.id}`,
200
248
  );
201
249
  }
202
250
 
@@ -56,7 +56,7 @@ export async function recordDeployment(record: DeploymentRecord): Promise<void>
56
56
  // there pointed the bucket at itself, and the revision that came up refused to
57
57
  // open its own target.
58
58
  await recordTarget(resolveWorkspaceRoot(), record.target, {
59
- workspace: record.workspace,
59
+ at: record.workspace,
60
60
  ...stamp,
61
61
  });
62
62
  }
@@ -37,7 +37,7 @@ import { heading, ok, print, style, warn } from '#cli/output.ts';
37
37
  export function registerLine(profile: string, target: string): string {
38
38
  return style.dim(
39
39
  ` Connect your accounts first, then register with:\n` +
40
- ` lanes link outputs --profile ${profile} --target ${target}\n` +
40
+ ` lanes link outputs --profile ${profile} --workspace ${target}\n` +
41
41
  ' A client keeps the tool list it fetched when it connected, so one registered\n' +
42
42
  ' before the accounts holds a surface without them until it is re-added.',
43
43
  );
@@ -66,7 +66,7 @@ export function reportUnauthorised(warnings: readonly string[], profile: string,
66
66
  print(
67
67
  style.dim(
68
68
  ' A browser consent per account is the one step this cannot take for you:\n' +
69
- ` lanes link connect <provider> --profile ${profile} --target ${target}\n` +
69
+ ` lanes link connect <provider> --profile ${profile} --workspace ${target}\n` +
70
70
  ' Each is served as soon as it is authorised. There is no second deploy —\n' +
71
71
  ' deploying is how code gets here, and authorising an account changes none.',
72
72
  ),