@lanes-sh/link 0.8.0 → 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 (87) hide show
  1. package/instructions/skills/lanes-link/SKILL.md +12 -12
  2. package/package.json +1 -1
  3. package/src/auth/oauth/store.ts +21 -5
  4. package/src/cli/accepts.ts +6 -5
  5. package/src/cli/commands/connect/index.ts +2 -1
  6. package/src/cli/commands/connect/settle.ts +12 -4
  7. package/src/cli/commands/connection.ts +2 -1
  8. package/src/cli/commands/identity.ts +4 -4
  9. package/src/cli/commands/knowledge/index.ts +9 -16
  10. package/src/cli/commands/knowledge/migrate.ts +9 -4
  11. package/src/cli/commands/knowledge/show.ts +14 -9
  12. package/src/cli/commands/operate/migrate.ts +5 -2
  13. package/src/cli/commands/operate/pair.ts +1 -1
  14. package/src/cli/commands/operate/serve.ts +1 -1
  15. package/src/cli/commands/owner/assets.ts +2 -2
  16. package/src/cli/commands/owner/entities.ts +2 -2
  17. package/src/cli/commands/owner/memory.ts +2 -2
  18. package/src/cli/commands/owner/tasks.ts +2 -2
  19. package/src/cli/commands/owner/vault.ts +3 -3
  20. package/src/cli/commands/profile/disposition.ts +236 -0
  21. package/src/cli/commands/profile/removal.ts +100 -13
  22. package/src/cli/commands/profile/remove.ts +67 -6
  23. package/src/cli/commands/profile.ts +33 -6
  24. package/src/cli/commands/secrets.ts +4 -4
  25. package/src/cli/commands/update-migration.ts +54 -0
  26. package/src/cli/commands/update.ts +36 -23
  27. package/src/cli/config-edit.ts +29 -8
  28. package/src/cli/config-repair-sweep.ts +119 -0
  29. package/src/cli/config-repair.ts +90 -141
  30. package/src/cli/config-templates.ts +26 -24
  31. package/src/cli/contract3-credentials.ts +294 -0
  32. package/src/cli/contract3-data.ts +143 -209
  33. package/src/cli/contract3-layout.ts +46 -0
  34. package/src/cli/contract3-shape.ts +34 -8
  35. package/src/cli/contract3.ts +141 -24
  36. package/src/cli/contract4-credentials.ts +207 -0
  37. package/src/cli/contract4-data.ts +399 -0
  38. package/src/cli/contract4-rename.ts +73 -0
  39. package/src/cli/contract4-yaml.ts +223 -0
  40. package/src/cli/contract4.ts +342 -0
  41. package/src/cli/identity.ts +44 -26
  42. package/src/cli/main.ts +6 -1
  43. package/src/cli/migrate-move.ts +166 -0
  44. package/src/cli/migrate-plan.ts +3 -3
  45. package/src/cli/publish.ts +1 -5
  46. package/src/cli/runtime/open.ts +5 -5
  47. package/src/cli/runtime/select.ts +2 -11
  48. package/src/cli/runtime/stores.ts +16 -11
  49. package/src/cli/runtime/vault.ts +2 -2
  50. package/src/cli/usage.ts +5 -1
  51. package/src/cli/workspace-migrate.ts +32 -11
  52. package/src/connectivity/manifest/provider.ts +31 -12
  53. package/src/connectivity/transports/imap/parser.ts +70 -9
  54. package/src/deployments/adapters/filesystem.ts +18 -3
  55. package/src/deployments/deploy.ts +5 -5
  56. package/src/deployments/gcp/bucket.ts +42 -6
  57. package/src/deployments/knowledge.ts +9 -4
  58. package/src/deployments/target.ts +28 -7
  59. package/src/deployments/upload.ts +39 -30
  60. package/src/profile/connections.ts +13 -1
  61. package/src/profile/deployments.ts +86 -8
  62. package/src/profile/index.ts +5 -1
  63. package/src/profile/knowledge.ts +18 -5
  64. package/src/profile/layout.ts +147 -71
  65. package/src/profile/load.ts +53 -17
  66. package/src/profile/schema.ts +11 -2
  67. package/src/profile/testing.ts +45 -10
  68. package/src/profile/workspace.ts +66 -30
  69. package/src/providers/assets/provider.ts +6 -6
  70. package/src/providers/entities/provider.ts +6 -6
  71. package/src/providers/entities/writes.ts +1 -1
  72. package/src/providers/identity/provider.ts +1 -1
  73. package/src/providers/memory/provider.ts +6 -6
  74. package/src/providers/setup/provider.ts +3 -3
  75. package/src/providers/skills/provider.ts +2 -2
  76. package/src/providers/tasks/provider.ts +6 -6
  77. package/src/providers/vault/provider.ts +1 -1
  78. package/src/registry/registry.ts +1 -1
  79. package/src/server/endpoint.ts +4 -0
  80. package/src/server/harness.ts +1 -1
  81. package/src/server/mcp/instructions.ts +21 -21
  82. package/src/server/mcp/routing.ts +3 -3
  83. package/src/server/mcp/tools.ts +16 -3
  84. package/src/server/mcp/visibility.ts +56 -4
  85. package/src/stores/blobs/conformance.ts +19 -0
  86. package/src/stores/state/index.ts +76 -10
  87. package/src/stores/state/testing.ts +5 -1
@@ -61,8 +61,21 @@ export interface TargetInput {
61
61
  */
62
62
  export type StorageFactory = (area?: string) => BlobStore;
63
63
 
64
- export async function openSecrets(input: TargetInput): Promise<SecretStore> {
65
- const { declared, config, root, target } = input;
64
+ /**
65
+ * The workspace's credential store.
66
+ *
67
+ * Takes the adapters and the root, not a `TargetInput` — it never read the
68
+ * profile, and since contract 3 there is nothing in one for it to read: the
69
+ * store is the workspace's. Narrowing the parameter is what lets a *migration*
70
+ * open it, which it must be able to do while the profiles on disk still
71
+ * declare the contract being migrated away from and therefore will not parse.
72
+ */
73
+ export async function openSecrets(input: {
74
+ readonly declared: TargetConfig;
75
+ readonly root: string;
76
+ readonly target: string;
77
+ }): Promise<SecretStore> {
78
+ const { declared, root, target } = input;
66
79
 
67
80
  switch (declared.credentials.adapter) {
68
81
  case 'file':
@@ -96,9 +109,17 @@ export async function openSecrets(input: TargetInput): Promise<SecretStore> {
96
109
  *
97
110
  * Its own root, `layout.state`, so it is not addressable from a provider's
98
111
  * blob namespace — the same containment `openAudit` relies on.
112
+ *
113
+ * Two roots, because state divides by what a key is *about*. Connection
114
+ * records, the discovery cache and the endpoint's own OAuth server belong to
115
+ * the workspace: a `connect` run once must read as connected from every
116
+ * profile. Cursors and each provider's own keys belong to the profile, because
117
+ * two agents reading one mailbox at different rates must not consume each
118
+ * other's cursor. `isWorkspaceNamespace` in `#stores/state` is the whole rule,
119
+ * and it is closed — a namespace it does not name is the profile's.
99
120
  */
100
- export function openState(storage: StorageFactory): RuntimeState {
101
- return createRuntimeState(storage(layout.state()));
121
+ export function openState(storage: StorageFactory, profile: string): RuntimeState {
122
+ return createRuntimeState(storage(layout.state()), storage(layout.profileState(profile)));
102
123
  }
103
124
 
104
125
  /**
@@ -201,7 +222,7 @@ export async function openStorage(
201
222
  switch (declared.storage.adapter) {
202
223
  case 'filesystem': {
203
224
  const { createFilesystemBlobStore } = await import('./adapters/filesystem.ts');
204
- const base = declared.storage.path ?? layout.blobs();
225
+ const base = declared.storage.path ?? layout.blobs(config.instance.profile);
205
226
  return (area) =>
206
227
  createFilesystemBlobStore({ root: workspacePath(root, area === undefined ? base : area) });
207
228
  }
@@ -219,7 +240,7 @@ export async function openStorage(
219
240
 
220
241
  const { createGcsBlobStore } = await import('./adapters/gcs.ts');
221
242
  const base = prefix ?? '';
222
- const root = layout.blobs();
243
+ const root = layout.blobs(config.instance.profile);
223
244
 
224
245
  return (area) =>
225
246
  createGcsBlobStore({
@@ -249,7 +270,7 @@ export async function openStorage(
249
270
 
250
271
  const { createS3BlobStore } = await import('./adapters/s3.ts');
251
272
  const base = prefix ?? '';
252
- const root = layout.blobs();
273
+ const root = layout.blobs(config.instance.profile);
253
274
 
254
275
  return (area) =>
255
276
  createS3BlobStore({
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  openTarget,
3
- DATA_DIR,
4
3
  CONNECTIONS_FILE,
5
4
  WORKSPACE_FILE,
6
5
  layout,
@@ -58,16 +57,17 @@ export function deployedWorkspace(declared: TargetConfig): string | undefined {
58
57
  *
59
58
  * **Two areas inside `data/` are authored rather than accumulated.** Skills and
60
59
  * provider manifests have to go up or a deployed instance loses both — the
61
- * regression ADR-014 §2 fixed for skills. So this reaches into `data/` for
62
- * exactly those two, by whole path segment and never by prefix:
63
- * `data/skills.detour/` is not `skills.d`, and the difference between matching
64
- * it and not is a credential in a bucket.
60
+ * regression ADR-014 §2 fixed for skills. So the allowlist names those two
61
+ * areas, by whole path segment and never by prefix:
62
+ * `profiles/work/skills.detour/` is not `skills.d`, and the difference between
63
+ * matching it and not is a credential in a bucket.
65
64
  *
66
- * Neither is filtered by the profile set any more. Both are keyed by connection
67
- * now rather than by profile (ADR-057, ADR-059), and a connection can be
68
- * granted by any profile in the workspace so sending "only this profile's
69
- * skills" is not a thing that can be computed, and withholding them would
70
- * deploy an endpoint whose prompts are missing.
65
+ * The two are filtered differently, and that follows from where they sit.
66
+ * A manifest is the workspace's (ADR-057) and defines a connection any profile
67
+ * may grant, so it goes up whole. Skills are back inside a profile (ADR-066),
68
+ * so they are filtered by the profile set exactly as the declarations are —
69
+ * sending a profile's procedures on a deploy that does not carry that profile
70
+ * would put one profile's material in front of another's endpoint.
71
71
  */
72
72
  export function isWorkspaceConfig(key: string, profiles?: readonly string[]): boolean {
73
73
  // **Never the workspace file.** It was sent, and it is the one file that must
@@ -86,39 +86,48 @@ export function isWorkspaceConfig(key: string, profiles?: readonly string[]): bo
86
86
  // wherever the workspace is read from (ADR-057).
87
87
  if (key === CONNECTIONS_FILE) return true;
88
88
 
89
- if (isAuthoredArea(key)) return true;
90
-
91
89
  // A set rather than one name, because a deploy now sends every profile that
92
90
  // declares the target rather than the single one it was told. `undefined`
93
91
  // still means the whole workspace, and an *empty* set means nothing — which
94
92
  // is a distinction a bare string could not make.
95
93
  const wanted = profiles === undefined ? undefined : new Set(profiles);
94
+ const carried = (profile: string): boolean => wanted === undefined || wanted.has(profile);
95
+
96
+ if (isManifest(key)) return true;
97
+
98
+ const segments = key.split('/');
99
+ if (segments[0] !== layout.profilesRoot()) return false;
100
+
101
+ const profile = segments[1];
102
+ if (profile === undefined || !carried(profile)) return false;
103
+
104
+ // The declaration itself.
105
+ if (key === layout.profileConfig(profile)) return true;
96
106
 
97
- if (!key.startsWith('profiles/') || !key.endsWith('.yaml')) return false;
98
- const name = key.slice('profiles/'.length, -'.yaml'.length);
99
- return wanted === undefined || wanted.has(name);
107
+ // `profiles/<profile>/skills.d/<connection>/…`, and at least one segment
108
+ // past the connection — a directory is not a file to send. Both skill
109
+ // layouts satisfy it: `<name>.md` is five segments and `<name>/SKILL.md` is
110
+ // six.
111
+ if (`${segments[0]}/${segments[1]}/${segments[2]}` === layout.skillsRoot(profile)) {
112
+ return segments.length >= 5;
113
+ }
114
+
115
+ // Everything else under a profile is state, a sealed vault, or a provider's
116
+ // own blobs. None of it is configuration and the vault is a credential.
117
+ return false;
100
118
  }
101
119
 
102
120
  /**
103
- * Whether `key` is inside one of the two authored areas.
121
+ * Whether `key` is one of the workspace's own provider manifests.
104
122
  *
105
- * Composed back out of `layout` rather than compared against literals, so a
123
+ * Composed back out of `layout` rather than compared against a literal, so a
106
124
  * renamed directory moves both this and the store that reads it, or neither.
107
- *
108
- * The segment counts differ because the layouts do, and both are the "a
109
- * directory is not a file to send" rule: a manifest is `data/providers.d/<file>`
110
- * and a skill is `data/skills.d/<connection>/<file>`, so requiring one more
111
- * segment than the area itself has is what stops the bare directory key
112
- * matching.
125
+ * Two segments at least, which is the "a directory is not a file to send" rule
126
+ * `providers.d` alone is the area, `providers.d/<file>` is a manifest.
113
127
  */
114
- function isAuthoredArea(key: string): boolean {
128
+ function isManifest(key: string): boolean {
115
129
  const segments = key.split('/');
116
- if (segments.length < 3 || segments[0] !== DATA_DIR) return false;
117
-
118
- const area = `${DATA_DIR}/${segments[1]}`;
119
- if (area === layout.providers()) return segments.length >= 3;
120
- if (area === layout.skillsRoot()) return segments.length >= 4;
121
- return false;
130
+ return segments.length >= 2 && segments[0] === layout.providers();
122
131
  }
123
132
 
124
133
  /**
@@ -153,7 +153,19 @@ export function soleGrantFor(config: Config, provider: string): string | undefin
153
153
  * under one name has to keep opening it.
154
154
  */
155
155
  export function vaultRef(declared: TargetConfig | undefined, config: Config): string {
156
- return declared?.vault?.ref ?? `vault/${soleGrantFor(config, 'vault') ?? 'main'}`;
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}`;
157
169
  }
158
170
 
159
171
  /**
@@ -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()?.workspaces ?? {}) 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(['workspaces']);
86
- else document.setIn(['workspaces'], 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
+ }
@@ -56,6 +56,7 @@ export { PAIR_CERT_REF, PAIR_KEY_REF, PAIR_TOKEN_REF } from './pairing.ts';
56
56
 
57
57
  export {
58
58
  KNOWLEDGE_LAYOUT,
59
+ KNOWLEDGE_PREFIX,
59
60
  knowledgeRoot,
60
61
  knowledgeTargetSchema,
61
62
  parseRepository,
@@ -135,6 +136,9 @@ export {
135
136
  } from './workspace.ts';
136
137
 
137
138
  export {
138
- DATA_DIR,
139
+ PROFILE_FILE,
140
+ LEGACY_DATA_DIR,
141
+ LEGACY_WORKSPACE_FILE,
142
+ legacyProfileConfig,
139
143
  layout,
140
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];
@@ -1,63 +1,85 @@
1
1
  /**
2
- * Where a workspace's data lives, in one place.
2
+ * Where a workspace's files live, in one place.
3
3
  *
4
4
  * ```
5
5
  * ~/.lanes-link/
6
- * ├── lanes-link.yaml the workspaces this machine knows, and the default
6
+ * ├── workspaces.yaml the workspaces this machine knows, and the default
7
7
  * ├── connections.yaml every account authorised in this workspace
8
- * ├── profiles/<name>.yaml which of them a profile selects, and who may use it
9
- * └── data/
10
- * ├── state.kv/ state, connections, cursors
11
- * ├── audit.log/ one object per event, one chain
12
- * ├── credentials.enc system credentials, and its .key
13
- * ├── vault.d/<id>.enc one sealed document per vault connection
14
- * ├── skills.d/<id>/ procedures, one <name>/SKILL.md each
15
- * ├── providers.d/ the operator's own provider manifests
16
- * └── <provider>/<connection>/… whatever that provider stores
8
+ * ├── credentials.enc system credentials, and its .key
9
+ * ├── providers.d/ the operator's own provider manifests
10
+ * ├── audit.log/ one object per event, one chain
11
+ * ├── state.kv/ connections, discovery, the endpoint's own OAuth server
12
+ * └── profiles/
13
+ * └── <profile>/ everything one profile is
14
+ * ├── profile.yaml what it selects, and who may use it
15
+ * ├── state.kv/ cursors, and each provider's own keys
16
+ * ├── vault.d/<id>.enc one sealed document per vault connection
17
+ * ├── skills.d/<id>/ procedures, one <name>/SKILL.md each
18
+ * └── <provider>/<connection>/… whatever that provider stores
17
19
  * ```
18
20
  *
19
- * **Nothing here takes a profile.** It used to take one for everything: a
20
- * profile owned a directory, and `rm -r data/work` was the whole answer to
21
- * "remove the work profile's data". A connection belongs to the workspace now
22
- * (ADR-057) and so do the stores behind the owner layer (ADR-059), so a profile
23
- * owns no bytes at all it owns a selection, and the thing a selection points
24
- * at outlives it. `profile remove` prints what survives rather than letting the
25
- * difference go unnoticed.
21
+ * **A profile is one directory, and it owns its bytes.** Both halves reverse
22
+ * something recent. ADR-059 put the owner layer's bytes beside the *connection*,
23
+ * so a profile owned nothing and two profiles granting `memory.lan1` read one
24
+ * note; ADR-066 makes the profile the container again, so they read two. And the
25
+ * declaration used to sit at `profiles/<name>.yaml` while the data sat under
26
+ * `data/<name>/` the same structure in two places, for no reason anybody could
27
+ * state (ADR-067).
26
28
  *
27
- * **The dot is load-bearing, and it is the only thing keeping these apart from
28
- * a provider.** The blob root is `data/` and a provider is namespaced
29
- * `<provider>/<connection>` under it; a provider id is `[a-z][a-z0-9_]*`, so a
30
- * name carrying a dot is one no provider can be scoped into. That is not
31
- * hypothetical for three of the five names below — `skills`, `vault` and
32
- * `audit` are all real provider ids, and without the dot each would be handed a
33
- * store rooted inside the thing it is meant to be walled off from, which is a
34
- * hole in ADR-007's wall rather than an untidy filename.
29
+ * **There is no `data/` any more.** It meant "what a deployed revision writes,
30
+ * as against the config it reads", which was worth a directory. `profile.yaml`
31
+ * lives beside the bytes now, so that line has to be drawn inside it regardless
32
+ * and a directory holding everything names nothing. The IAM grant says which
33
+ * prefixes are writable instead of saying which one is not.
35
34
  *
36
- * `vault.d/<id>.enc` rather than `vault/<id>.enc` for exactly that reason. The
37
- * sealed document is not a blob the vault provider serves, and a `vault/`
38
- * prefix shared between the two would put the ciphertext inside the namespace
39
- * the provider is given.
35
+ * **The dot is load-bearing one level down, not here.** The root is not a
36
+ * provider blob root; a *profile's* directory is. A provider is namespaced
37
+ * `<provider>/<connection>` under it and a provider id is `[a-z][a-z0-9_]*`, so
38
+ * a name carrying a dot is one no provider can be scoped into. That is not
39
+ * hypothetical for three of the four names there — `skills`, `vault` and `state`
40
+ * would each otherwise be handed a store rooted inside the thing it is meant to
41
+ * be walled off from, which is a hole in ADR-007's wall rather than an untidy
42
+ * filename. `profile.yaml` carries one for the same reason.
43
+ *
44
+ * `vault.d/<id>.enc` rather than `vault/<id>.enc` on the same grounds: the
45
+ * sealed document is not a blob the vault provider serves, and a shared `vault/`
46
+ * prefix would put the ciphertext inside the namespace the provider is given.
47
+ *
48
+ * `audit.log/` and `state.kv/` stay at the workspace. One endpoint serves every
49
+ * profile and every event already records the profile it acted in, so one chain
50
+ * is both sufficient and better evidence than several; and a connection's
51
+ * reconcile status is a fact about an account, which a `connect` in one profile
52
+ * must not have to repeat in the next. What follows the profile is what a
53
+ * profile's use of an account produces — cursors, and the provider's own keys.
40
54
  *
41
55
  * These are **defaults**. A workspace that declares its own paths keeps them.
42
56
  */
43
57
 
44
- /** The directory under the workspace root that holds everything the workspace owns. */
45
- export const DATA_DIR = 'data';
58
+ /** The directory holding one subdirectory per profile. */
59
+ export const PROFILES_DIR = 'profiles';
60
+
61
+ /**
62
+ * What a profile's declaration is called inside its own directory.
63
+ *
64
+ * Named here rather than spelled at the two call sites that need it — the path
65
+ * builder below and `listProfiles`, which matches on it to tell a profile
66
+ * directory from anything else under `profiles/`. Two spellings of one filename
67
+ * is how a listing and a loader come to disagree about what exists.
68
+ */
69
+ export const PROFILE_FILE = 'profile.yaml';
46
70
 
47
71
  /**
48
72
  * No leading `./` on any of these.
49
73
  *
50
74
  * It used to carry one, which `path.resolve` discards and an object key does
51
- * not: a bucket read `./data/state.kv/x` as a directory literally named `.`, so
75
+ * not: a bucket read `./state.kv/x` as a directory literally named `.`, so
52
76
  * every deployed key landed one level away from where the config said it did.
53
77
  * The visible cost was that the conditioned IAM binding
54
- * `deployments/gcp/provision.ts` writes which grants writes under
55
- * `objects/data/` matched nothing, and the first revision 403'd on its boot
56
- * reconcile. A relative path is relative without being spelled that way.
78
+ * `deployments/gcp/provision.ts` writes matched nothing, and the first revision
79
+ * 403'd on its boot reconcile. A relative path is relative without being
80
+ * spelled that way.
57
81
  */
58
82
  export const layout = {
59
- /** Connections, provider state, and cursors: one object per key. */
60
- state: (): string => `${DATA_DIR}/state.kv`,
61
83
  /**
62
84
  * System credentials — OAuth refresh tokens, the CI token. Never reachable
63
85
  * from MCP.
@@ -69,45 +91,16 @@ export const layout = {
69
91
  * "silent until one profile is reading the other's account" (ADR-043,
70
92
  * ADR-057).
71
93
  */
72
- credentials: (): string => `${DATA_DIR}/credentials.enc`,
73
- /** Every vault connection's sealed document lives under here. */
74
- vaultRoot: (): string => `${DATA_DIR}/vault.d`,
75
- /** One sealed document per vault connection, each under its own key. */
76
- vault: (connection: string): string => `${DATA_DIR}/vault.d/${connection}.enc`,
77
- /**
78
- * The same document, keyed relative to the blob store.
79
- *
80
- * `blobs()` is already rooted at `data/`, so a blob adapter handed the path
81
- * above would write `data/data/vault.d/...`. Two spellings of one location is
82
- * exactly what this file exists to prevent, so the second one lives here
83
- * beside the first rather than being assembled at the call site.
84
- */
85
- vaultKey: (connection: string): string => `vault.d/${connection}.enc`,
86
- /**
87
- * Every skills connection's procedures live under here.
88
- *
89
- * The root is exported beside the per-connection path because `deploy` needs
90
- * to recognise the *area* without knowing which connections exist — and
91
- * `upload.ts` composes its allowlist back out of these rather than comparing
92
- * against literals, so a renamed directory moves the store and the thing that
93
- * sends it together, or neither.
94
- */
95
- skillsRoot: (): string => `${DATA_DIR}/skills.d`,
96
- /** One skills connection's procedures — `<name>.md` or `<name>/SKILL.md`, either layout. */
97
- skills: (connection: string): string => `${DATA_DIR}/skills.d/${connection}`,
94
+ credentials: (): string => 'credentials.enc',
98
95
  /**
99
96
  * The provider manifests this workspace declares.
100
97
  *
101
98
  * Workspace-level, where ADR-030 put them in the profile. A manifest names a
102
99
  * host, an OpenAPI document, and the credential refs that reach them — which
103
100
  * is to say it *defines a connection*, and connections do not live in a
104
- * profile any more. ADR-030's argument was about a procedure being as private
105
- * as the knowledge it operates on; that argument is answered by ADR-059's
106
- * instances, not by where a manifest sits.
101
+ * profile any more (ADR-057).
107
102
  */
108
- providers: (): string => `${DATA_DIR}/providers.d`,
109
- /** The blob root every provider is namespaced under. */
110
- blobs: (): string => DATA_DIR,
103
+ providers: (): string => 'providers.d',
111
104
  /**
112
105
  * The audit log: one object per event, one chain for the workspace.
113
106
  *
@@ -116,5 +109,88 @@ export const layout = {
116
109
  * also what lets `audit tail` filter where it used to select, and what gives
117
110
  * the dashboard a single log to read.
118
111
  */
119
- audit: (): string => `${DATA_DIR}/audit.log`,
112
+ audit: (): string => 'audit.log',
113
+ /**
114
+ * What the workspace knows about its accounts, and about itself.
115
+ *
116
+ * Connection records and the discovery cache, because both are facts about an
117
+ * account or a provider rather than about anybody's selection of it — a
118
+ * `connect` run once must read as connected from every profile. And the
119
+ * endpoint's own OAuth authorization-server state, which is not about a
120
+ * connection at all: it is the clients that have signed in *to* this endpoint,
121
+ * and there is one endpoint.
122
+ */
123
+ state: (): string => 'state.kv',
124
+
125
+ /** Every profile's directory sits under here. */
126
+ profilesRoot: (): string => PROFILES_DIR,
127
+ /** Everything one profile is. Also the blob root its providers are scoped under. */
128
+ profileDir: (profile: string): string => `${PROFILES_DIR}/${profile}`,
129
+ /**
130
+ * What this profile selects, and who may use it.
131
+ *
132
+ * Inside the profile's own directory, which is the whole of ADR-067 — but it
133
+ * is therefore inside the tree a running endpoint writes, so the IAM condition
134
+ * carves it back out. ADR-007 says a deployed revision never mutates its own
135
+ * configuration, and that rule is older than where the file sits.
136
+ */
137
+ profileConfig: (profile: string): string => `${PROFILES_DIR}/${profile}/${PROFILE_FILE}`,
138
+ /** Cursors, and each provider's own keys, for this profile's use of an account. */
139
+ profileState: (profile: string): string => `${PROFILES_DIR}/${profile}/state.kv`,
140
+ /** Every vault connection's sealed document, for this profile. */
141
+ vaultRoot: (profile: string): string => `${PROFILES_DIR}/${profile}/vault.d`,
142
+ /** One sealed document per vault connection, each under its own key. */
143
+ vault: (profile: string, connection: string): string =>
144
+ `${PROFILES_DIR}/${profile}/vault.d/${connection}.enc`,
145
+ /**
146
+ * The same document, keyed relative to the blob store.
147
+ *
148
+ * `blobs()` is already rooted at the profile's directory, so a blob adapter
149
+ * handed the path above would write `profiles/p/profiles/p/vault.d/...`. Two
150
+ * spellings of one location is exactly what this file exists to prevent, so
151
+ * the second one lives here beside the first rather than being assembled at
152
+ * the call site.
153
+ */
154
+ vaultKey: (connection: string): string => `vault.d/${connection}.enc`,
155
+ /**
156
+ * Every skills connection's procedures, for this profile.
157
+ *
158
+ * The root is exported beside the per-connection path because `deploy` needs
159
+ * to recognise the *area* without knowing which connections exist — and
160
+ * `upload.ts` composes its allowlist back out of these rather than comparing
161
+ * against literals, so a renamed directory moves the store and the thing that
162
+ * sends it together, or neither.
163
+ */
164
+ skillsRoot: (profile: string): string => `${PROFILES_DIR}/${profile}/skills.d`,
165
+ /** One skills connection's procedures — `<name>.md` or `<name>/SKILL.md`, either layout. */
166
+ skills: (profile: string, connection: string): string =>
167
+ `${PROFILES_DIR}/${profile}/skills.d/${connection}`,
168
+ /** The blob root this profile's providers are namespaced under. */
169
+ blobs: (profile: string): string => `${PROFILES_DIR}/${profile}`,
120
170
  } as const;
171
+
172
+ /**
173
+ * `data/`, which no longer exists — kept for the migrations that address it.
174
+ *
175
+ * Contracts 1 through 3 put everything a profile owned under `data/<profile>/`,
176
+ * and contract 4 moves it out (ADR-067). A migration reads the layout it is
177
+ * migrating *from*, and that layout is frozen: asking `layout` above would
178
+ * compare a contract-3 path against a contract-4 default and match nothing.
179
+ * `migrate-plan.ts` already spells the contract-1 defaults out for the same
180
+ * reason. Nothing outside a migration should import this.
181
+ */
182
+ export const LEGACY_DATA_DIR = 'data';
183
+
184
+ /**
185
+ * The registry's name, and a profile's path, through contract 3.
186
+ *
187
+ * Recognised, never written. Two live functions answer to these — the ancestor
188
+ * walk and `listProfiles` — for one reason: a workspace that needs migrating
189
+ * has to be findable by the command that migrates it, or `doctor --fix` reports
190
+ * no workspace here and the operator has a wall with no door.
191
+ */
192
+ export const LEGACY_WORKSPACE_FILE = 'lanes-link.yaml';
193
+
194
+ export function legacyProfileConfig(profile: string): string {
195
+ return `${PROFILES_DIR}/${profile}.yaml`;
196
+ }