@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,195 @@
1
+ import { ConfigError, describeRename } from './load.ts';
2
+ import type { Config, ConnectionConfig, GrantConfig, TargetConfig } from './schema.ts';
3
+
4
+ /**
5
+ * The join between a profile and the workspace it lives in.
6
+ *
7
+ * A connection is declared once, in `connections.yaml`, and a profile names the
8
+ * ones it selects in `grants:` (ADR-057). Nothing downstream should perform
9
+ * that join itself: `dispatch`, `visibility`, `status`, `setup` and the
10
+ * reconciler all want the same answer — *which accounts does this profile
11
+ * reach, and what may be done with each* — and three implementations of one
12
+ * join is three chances for discovery and enforcement to disagree, which is the
13
+ * failure `allowedConnections` exists to prevent on the capability axis.
14
+ *
15
+ * So this file answers it once and everything else asks.
16
+ */
17
+
18
+ /** `<provider>.<id>` — the same string an agent passes as `connection`. */
19
+ export function connectionRefOf(connection: ConnectionConfig): string {
20
+ return `${connection.provider}.${connection.id}`;
21
+ }
22
+
23
+ /**
24
+ * One account this profile reaches, with the rules that govern it.
25
+ *
26
+ * The grant travels with the connection deliberately. Every caller that has a
27
+ * connection in its hand is about to ask what may be done with it, and handing
28
+ * back a pair removes the second lookup — along with the possibility of pairing
29
+ * a connection with the wrong profile's grant, which is the one mistake this
30
+ * shape makes unrepresentable.
31
+ */
32
+ export interface SelectedConnection {
33
+ readonly ref: string;
34
+ readonly connection: ConnectionConfig;
35
+ readonly grant: GrantConfig;
36
+ }
37
+
38
+ /**
39
+ * The connections a profile selects, in the order it declares them.
40
+ *
41
+ * Declaration order rather than the workspace's, because the profile is what
42
+ * this is a view of, and a listing that reordered the owner's own file would be
43
+ * answering a question nobody asked. A grant naming a connection that does not
44
+ * exist is skipped here and refused by `assertGrantsResolve` at load — this
45
+ * function is a view, not a gate, and a view that threw would make every
46
+ * listing depend on the config being valid.
47
+ */
48
+ export function selectConnections(
49
+ config: Config,
50
+ available: readonly ConnectionConfig[],
51
+ ): SelectedConnection[] {
52
+ const byRef = new Map(available.map((connection) => [connectionRefOf(connection), connection]));
53
+
54
+ return config.grants.flatMap((grant) => {
55
+ const connection = byRef.get(grant.connection);
56
+ return connection === undefined ? [] : [{ ref: grant.connection, connection, grant }];
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Every reference in a profile resolves, and nothing is granted twice.
62
+ *
63
+ * Two failures, and the second is the one worth having a check for. A grant
64
+ * naming a connection the workspace does not hold is visible the moment anyone
65
+ * looks — the surface is simply absent. Two grants naming the *same* connection
66
+ * is not: one of them silently wins, and which one depends on iteration order,
67
+ * so a profile could deny a capability in one row and allow it in another and
68
+ * behave differently between releases. Refusing at load is what keeps
69
+ * `grants:` a set rather than a sequence with precedence.
70
+ */
71
+ export function assertGrantsResolve(
72
+ config: Config,
73
+ available: readonly ConnectionConfig[],
74
+ ): void {
75
+ const refs = new Set(available.map(connectionRefOf));
76
+
77
+ const missing = config.grants
78
+ .map((grant) => grant.connection)
79
+ .filter((ref) => !refs.has(ref));
80
+
81
+ if (missing.length > 0) {
82
+ throw new ConfigError(
83
+ `Profile "${config.instance.profile}" grants a connection this workspace does not hold: ` +
84
+ `${[...new Set(missing)].join(', ')}.\n` +
85
+ ` Connections live in connections.yaml. Run "lanes link connection list" to see them.`,
86
+ );
87
+ }
88
+
89
+ const seen = new Set<string>();
90
+ const duplicated = config.grants
91
+ .map((grant) => grant.connection)
92
+ .filter((ref) => (seen.has(ref) ? true : (seen.add(ref), false)));
93
+
94
+ if (duplicated.length > 0) {
95
+ throw new ConfigError(
96
+ `Profile "${config.instance.profile}" grants the same connection more than once: ` +
97
+ `${[...new Set(duplicated)].join(', ')}.\n` +
98
+ ` One row per connection — merge the allow and deny lists into it.`,
99
+ );
100
+ }
101
+ }
102
+
103
+ /**
104
+ * No two connections in a workspace share a reference.
105
+ *
106
+ * `<provider>.<id>` is what a credential ref is derived from and what an agent
107
+ * names, so a duplicate is two accounts answering to one address. It was
108
+ * impossible to express before contract 3 only because a profile held its own
109
+ * list; one workspace-wide list makes it expressible, so it has to be refused.
110
+ */
111
+ export function assertConnectionsUnique(connections: readonly ConnectionConfig[]): void {
112
+ const seen = new Set<string>();
113
+ const duplicated = connections
114
+ .map(connectionRefOf)
115
+ .filter((ref) => (seen.has(ref) ? true : (seen.add(ref), false)));
116
+
117
+ if (duplicated.length > 0) {
118
+ throw new ConfigError(
119
+ `connections.yaml declares the same connection more than once: ` +
120
+ `${[...new Set(duplicated)].join(', ')}.\n` +
121
+ ` A connection is addressed as "<provider>.<id>", so two rows sharing one ` +
122
+ `are two accounts at one address.`,
123
+ );
124
+ }
125
+ }
126
+
127
+ /**
128
+ * The one connection this profile grants for a single-instance provider.
129
+ *
130
+ * `undefined` when it grants none, which is a legitimate state: a profile that
131
+ * denies `skills.*` reaches no skills, and the surface is absent rather than
132
+ * empty.
133
+ */
134
+ export function soleGrantFor(config: Config, provider: string): string | undefined {
135
+ const prefix = `${provider}.`;
136
+ const granted = config.grants.find((grant) => grant.connection.startsWith(prefix));
137
+ return granted?.connection.slice(prefix.length);
138
+ }
139
+
140
+ /**
141
+ * Where this profile's vault document lives, in one spelling.
142
+ *
143
+ * **Two places derived this, and they disagreed.** `openVault` names it per
144
+ * connection (ADR-059) — `vault/main` for a profile granting `vault.main` — and
145
+ * the deploy's provisioning step named `vault/document`, the contract-2 constant.
146
+ * So every deployed workspace that did not hand-write `vault.ref` created and
147
+ * granted one secret and then asked Secret Manager for another: the revision got
148
+ * `PERMISSION_DENIED` on the ref nothing had made, exited 1, and never listened
149
+ * on its port. A deploy that hand-wrote a `ref` worked, which is what kept this
150
+ * out of sight — a rehearsal that sets one is testing the path nobody takes.
151
+ *
152
+ * `ref` still wins where it is written, because a deployment already sealing
153
+ * under one name has to keep opening it.
154
+ */
155
+ export function vaultRef(declared: TargetConfig | undefined, config: Config): string {
156
+ // **The profile, then the connection.** This was `vault/<connection>`, which
157
+ // was distinct per profile only while each profile had its own vault
158
+ // instance. ADR-066 merges the owner layer to one row per surface, so every
159
+ // profile grants `lanes_vault.lan5` and every profile opened one sealed
160
+ // document — `vault_put` from `personal` overwriting `work`'s item of the
161
+ // same id, and `vault_get` reading the other profile's credential. The `file`
162
+ // and `blob` adapters take the profile from `layout`; this is the same fact
163
+ // for the adapter every deployment uses.
164
+ //
165
+ // A `ref` the target states outright still wins: a deployment already sealing
166
+ // under one name has to keep opening it.
167
+ const connection = soleGrantFor(config, 'lanes_vault') ?? 'main';
168
+ return declared?.vault?.ref ?? `vault/${config.instance.profile}/${connection}`;
169
+ }
170
+
171
+ /**
172
+ * A connection whose provider id moved out from under it.
173
+ *
174
+ * `tasks` is the built-in task list; Google's is `google_tasks` (ADR-051). A row
175
+ * that still says `tasks` and names an address rather than the built-in's own
176
+ * label is somebody's Google account resolving to the wrong provider — and the
177
+ * failure is silent in the worst way: the row loads, reconcile calls it active,
178
+ * and every call goes to an empty local store.
179
+ *
180
+ * Here rather than in the profile loader, because the evidence is the *account*
181
+ * and accounts live in this file.
182
+ */
183
+ export function assertNoRenamedProviders(
184
+ connections: readonly ConnectionConfig[],
185
+ repair: string,
186
+ ): void {
187
+ const problems = connections.flatMap((connection, index) => {
188
+ const renamed = describeRename(connection, repair);
189
+ return renamed === null ? [] : [`connections[${index}]: ${renamed}`];
190
+ });
191
+
192
+ if (problems.length > 0) {
193
+ throw new ConfigError(`connections.yaml:\n${problems.map((p) => ` ${p}`).join('\n')}`, problems);
194
+ }
195
+ }
@@ -1,7 +1,13 @@
1
1
  import { parseDocument } from 'yaml';
2
2
  import { readWorkspaceFile, workspaceFiles, writeWorkspaceFile } from './files.ts';
3
- import { SUPPORTED_CONTRACT, workspaceSchema, type WorkspaceTarget } from './schema.ts';
3
+ import {
4
+ SUPPORTED_CONTRACT,
5
+ workspaceSchema,
6
+ workspaceTargetSchema,
7
+ type WorkspaceTarget,
8
+ } from './schema.ts';
4
9
  import { WORKSPACE_FILE } from './workspace.ts';
10
+ import { LEGACY_WORKSPACE_FILE } from './layout.ts';
5
11
 
6
12
  /**
7
13
  * Writing the target registry.
@@ -74,24 +80,96 @@ async function editRegistry(
74
80
  edit: (targets: Record<string, WorkspaceTarget>) => void,
75
81
  ): Promise<void> {
76
82
  const files = workspaceFiles(workspaceRoot);
83
+
84
+ // **Either name, and the one it read is the one it writes.** Reading only the
85
+ // new name meant an unmigrated workspace fell through to a bare
86
+ // `contract: 4` document: `deploy` then wrote a registry holding one target
87
+ // beside the `lanes-link.yaml` that declared them all, and `readWorkspace`
88
+ // prefers the new name — so every other target vanished from a command whose
89
+ // job was to record one.
90
+ const held = await readWorkspaceFile(files, WORKSPACE_FILE);
91
+ const key = held === null ? LEGACY_WORKSPACE_FILE : WORKSPACE_FILE;
77
92
  const text =
78
- (await readWorkspaceFile(files, WORKSPACE_FILE)) ?? `contract: ${SUPPORTED_CONTRACT}\n`;
93
+ held ??
94
+ (await readWorkspaceFile(files, LEGACY_WORKSPACE_FILE)) ??
95
+ `contract: ${SUPPORTED_CONTRACT}\n`;
79
96
 
80
97
  const document = parseDocument(text);
81
- const targets = (document.toJSON()?.targets ?? {}) as Record<string, WorkspaceTarget>;
98
+ const raw = (document.toJSON() ?? {}) as {
99
+ workspaces?: Record<string, WorkspaceTarget>;
100
+ targets?: Record<string, LegacyTargetEntry>;
101
+ };
102
+
103
+ // **Whichever block this file already keeps its registry in.**
104
+ //
105
+ // This read and wrote `workspaces:` unconditionally. A contract-2 file keeps
106
+ // it under `targets:`, so recording a deploy into one found no registry,
107
+ // added the entry to an empty object, and wrote a *second* block beside the
108
+ // first. `workspaceSchema` has no `targets` key and zod strips what it does
109
+ // not declare, so the hybrid validated and landed.
110
+ //
111
+ // That is reachable from an ordinary command: `deploy` migrates the target
112
+ // workspace, never the local one, so deploying to a bucket from a laptop that
113
+ // has not run `update` yet does exactly this. The result is a file whose two
114
+ // registries disagree — and `rewriteRegistry` then rebuilds `workspaces:`
115
+ // from the stale `targets:`, discarding the newer record without a word.
116
+ //
117
+ // Writing into the block the file already has keeps it coherent at whatever
118
+ // contract it is, and leaves converting the two to the migration that owns
119
+ // that job.
120
+ const legacy = raw.workspaces === undefined && raw.targets !== undefined;
121
+ const block = legacy ? 'targets' : 'workspaces';
122
+ const targets = legacy ? current(raw.targets ?? {}) : (raw.workspaces ?? {});
82
123
 
83
124
  edit(targets);
84
125
 
85
- if (Object.keys(targets).length === 0) document.deleteIn(['targets']);
86
- else document.setIn(['targets'], sorted(targets));
126
+ if (Object.keys(targets).length === 0) document.deleteIn([block]);
127
+ else document.setIn([block], sorted(legacy ? asLegacy(targets) : targets));
87
128
 
88
129
  // Validated before it lands, on the rendered tree rather than the input, so
89
130
  // what is checked is what would be read back.
90
- workspaceSchema.parse(document.toJSON());
131
+ //
132
+ // `workspaceSchema` declares no `targets` key and zod strips what it does not
133
+ // declare, so on the legacy branch parsing the whole document checks nothing
134
+ // at all — an entry that is neither pointer nor declaration, or one trying to
135
+ // be both, landed silently and only surfaced weeks later when the migration
136
+ // converted it and refused. The entries are checked directly there, in the
137
+ // contract-3 shape `current` normalised them to.
138
+ if (legacy) for (const entry of Object.values(targets)) workspaceTargetSchema.parse(entry);
139
+ else workspaceSchema.parse(document.toJSON());
91
140
 
92
- await writeWorkspaceFile(files, WORKSPACE_FILE, String(document));
141
+ await writeWorkspaceFile(files, key, String(document));
93
142
  }
94
143
 
95
- function sorted(targets: Record<string, WorkspaceTarget>): Record<string, WorkspaceTarget> {
144
+ function sorted<T>(targets: Record<string, T>): Record<string, T> {
96
145
  return Object.fromEntries(Object.entries(targets).sort(([a], [b]) => a.localeCompare(b)));
97
146
  }
147
+
148
+ /**
149
+ * A contract-2 entry spelled a pointer `workspace:`; contract 3 spells it `at:`.
150
+ *
151
+ * Normalised on the way in and back on the way out, so `edit` and `pick` see one
152
+ * shape and never have to ask which contract they are looking at — and so a
153
+ * legacy file keeps the spelling its own schema expects.
154
+ */
155
+ interface LegacyTargetEntry extends Omit<WorkspaceTarget, 'at'> {
156
+ workspace?: string;
157
+ }
158
+
159
+ function current(targets: Record<string, LegacyTargetEntry>): Record<string, WorkspaceTarget> {
160
+ return Object.fromEntries(
161
+ Object.entries(targets).map(([name, entry]) => {
162
+ const { workspace, ...rest } = entry;
163
+ return [name, workspace === undefined ? rest : { at: workspace, ...rest }];
164
+ }),
165
+ );
166
+ }
167
+
168
+ function asLegacy(targets: Record<string, WorkspaceTarget>): Record<string, LegacyTargetEntry> {
169
+ return Object.fromEntries(
170
+ Object.entries(targets).map(([name, entry]) => {
171
+ const { at, ...rest } = entry;
172
+ return [name, at === undefined ? rest : { workspace: at, ...rest }];
173
+ }),
174
+ );
175
+ }
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * A profile: its file, its schema, and where it lives on disk.
3
3
  *
4
- * **One profile = one config = one database = one credential store.** Profiles
5
- * share an endpoint and its token (ADR-009); they never share state. This
6
- * component owns everything about what a profile *is* the YAML contract, the
7
- * loader that validates it, and the workspace resolution that finds it.
4
+ * **A profile is a selection, not an inventory.** Since contract 3 the accounts
5
+ * live in the workspace's `connections.yaml` and a profile names the ones it
6
+ * grants (ADR-057), so profiles share an endpoint, its token, the credential
7
+ * store, and any connection they both select. What they do not share is the
8
+ * grant: two profiles on one mailbox can permit entirely different things
9
+ * (ADR-058). This component owns everything about what a profile *is* — the
10
+ * YAML contract, the loader that validates it, the join onto the workspace's
11
+ * connections, and the resolution that finds them.
8
12
  *
9
13
  * What it deliberately does not own: which providers exist (`#registry`) and
10
14
  * how a call runs (`#dispatch`). Those were all one package called `core`,
@@ -15,23 +19,44 @@ export {
15
19
  DEPLOY_DEFAULTS,
16
20
  SUPPORTED_CONTRACT,
17
21
  configSchema,
22
+ SINGLE_INSTANCE_PROVIDERS,
23
+ connectionsFileSchema,
18
24
  declaredTarget,
25
+ grantSchema,
19
26
  isPointer,
27
+ memberSchema,
20
28
  workspaceSchema,
21
29
  workspaceTargetSchema,
22
30
  type AuthorizationConfig,
23
31
  type Config,
24
32
  type ConnectionConfig,
33
+ type ConnectionsFile,
25
34
  type DeployConfig,
35
+ type GrantConfig,
26
36
  type IdentityEntry,
37
+ type MemberConfig,
27
38
  type PolicyRuleConfig,
28
39
  type TargetConfig,
29
40
  type WorkspaceConfig,
30
41
  type WorkspaceTarget,
31
42
  } from './schema.ts';
32
43
 
44
+ export {
45
+ assertConnectionsUnique,
46
+ assertGrantsResolve,
47
+ assertNoRenamedProviders,
48
+ connectionRefOf,
49
+ selectConnections,
50
+ soleGrantFor,
51
+ vaultRef,
52
+ type SelectedConnection,
53
+ } from './connections.ts';
54
+
55
+ export { PAIR_CERT_REF, PAIR_KEY_REF, PAIR_TOKEN_REF } from './pairing.ts';
56
+
33
57
  export {
34
58
  KNOWLEDGE_LAYOUT,
59
+ KNOWLEDGE_PREFIX,
35
60
  knowledgeRoot,
36
61
  knowledgeTargetSchema,
37
62
  parseRepository,
@@ -103,13 +128,17 @@ export {
103
128
  writeWorkspaceFile,
104
129
  } from './files.ts';
105
130
  export {
131
+ CONNECTIONS_FILE,
132
+ readConnections,
106
133
  type ProfileSelection,
107
134
  type Resolution,
108
135
  type ResolveOptions,
109
136
  } from './workspace.ts';
110
137
 
111
138
  export {
112
- DATA_DIR,
139
+ PROFILE_FILE,
140
+ LEGACY_DATA_DIR,
141
+ LEGACY_WORKSPACE_FILE,
142
+ legacyProfileConfig,
113
143
  layout,
114
- profileDir,
115
144
  } from './layout.ts';
@@ -96,11 +96,17 @@ export type KnowledgeConfig = z.infer<typeof knowledgeTargetSchema>;
96
96
  /**
97
97
  * Where each area sits inside the repository.
98
98
  *
99
- * Three directories, named after the three things that move. `memory` is also
100
- * the memory provider's own blob namespace the prefix core scopes it into under
101
- * the profile's blob root which is why the same word does both jobs: the
102
- * route that redirects it and the directory it lands in are the same fact, and
103
- * spelling them separately is how they would come to disagree.
99
+ * Three directories, named after the three things that move and named for a
100
+ * *reader*, because a knowledge repository is somebody's own and they browse it
101
+ * on GitHub. `memory/` is what belongs at the top of that tree, not
102
+ * `lanes_memory/`.
103
+ *
104
+ * **This used to be the provider's blob prefix as well**, on the reasoning that
105
+ * one word doing both jobs could not come to disagree with itself. Contract 4
106
+ * prefixed the owner layer (`lanes_memory`), so the two are no longer the same
107
+ * string and the choice has to be made rather than avoided: the route below
108
+ * reads `PROVIDER_PREFIX`, and this stays the readable name. `KNOWLEDGE_ROUTES`
109
+ * pairs them in one place so a rename still cannot move one without the other.
104
110
  *
105
111
  * Declared here, beside the schema, so the runtime that opens these stores and
106
112
  * the command that migrates into them read one definition.
@@ -113,6 +119,13 @@ export const KNOWLEDGE_LAYOUT = {
113
119
 
114
120
  export type KnowledgeArea = keyof typeof KNOWLEDGE_LAYOUT;
115
121
 
122
+ /** The blob prefix each area is scoped into locally — the provider's own id. */
123
+ export const KNOWLEDGE_PREFIX = {
124
+ memory: 'lanes_memory',
125
+ skills: 'lanes_skills',
126
+ entities: 'lanes_entities',
127
+ } as const;
128
+
116
129
  /** The directory one area occupies, under the profile's optional path prefix. */
117
130
  export function knowledgeRoot(knowledge: KnowledgeConfig, area: KnowledgeArea): string {
118
131
  const directory = KNOWLEDGE_LAYOUT[area];