@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
@@ -0,0 +1,183 @@
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
+ return declared?.vault?.ref ?? `vault/${soleGrantFor(config, 'vault') ?? 'main'}`;
157
+ }
158
+
159
+ /**
160
+ * A connection whose provider id moved out from under it.
161
+ *
162
+ * `tasks` is the built-in task list; Google's is `google_tasks` (ADR-051). A row
163
+ * that still says `tasks` and names an address rather than the built-in's own
164
+ * label is somebody's Google account resolving to the wrong provider — and the
165
+ * failure is silent in the worst way: the row loads, reconcile calls it active,
166
+ * and every call goes to an empty local store.
167
+ *
168
+ * Here rather than in the profile loader, because the evidence is the *account*
169
+ * and accounts live in this file.
170
+ */
171
+ export function assertNoRenamedProviders(
172
+ connections: readonly ConnectionConfig[],
173
+ repair: string,
174
+ ): void {
175
+ const problems = connections.flatMap((connection, index) => {
176
+ const renamed = describeRename(connection, repair);
177
+ return renamed === null ? [] : [`connections[${index}]: ${renamed}`];
178
+ });
179
+
180
+ if (problems.length > 0) {
181
+ throw new ConfigError(`connections.yaml:\n${problems.map((p) => ` ${p}`).join('\n')}`, problems);
182
+ }
183
+ }
@@ -78,12 +78,12 @@ async function editRegistry(
78
78
  (await readWorkspaceFile(files, WORKSPACE_FILE)) ?? `contract: ${SUPPORTED_CONTRACT}\n`;
79
79
 
80
80
  const document = parseDocument(text);
81
- const targets = (document.toJSON()?.targets ?? {}) as Record<string, WorkspaceTarget>;
81
+ const targets = (document.toJSON()?.workspaces ?? {}) as Record<string, WorkspaceTarget>;
82
82
 
83
83
  edit(targets);
84
84
 
85
- if (Object.keys(targets).length === 0) document.deleteIn(['targets']);
86
- else document.setIn(['targets'], sorted(targets));
85
+ if (Object.keys(targets).length === 0) document.deleteIn(['workspaces']);
86
+ else document.setIn(['workspaces'], sorted(targets));
87
87
 
88
88
  // Validated before it lands, on the rendered tree rather than the input, so
89
89
  // what is checked is what would be read back.
@@ -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,21 +19,41 @@ 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,
35
59
  knowledgeRoot,
@@ -103,6 +127,8 @@ export {
103
127
  writeWorkspaceFile,
104
128
  } from './files.ts';
105
129
  export {
130
+ CONNECTIONS_FILE,
131
+ readConnections,
106
132
  type ProfileSelection,
107
133
  type Resolution,
108
134
  type ResolveOptions,
@@ -111,5 +137,4 @@ export {
111
137
  export {
112
138
  DATA_DIR,
113
139
  layout,
114
- profileDir,
115
140
  } from './layout.ts';
@@ -1,123 +1,120 @@
1
1
  /**
2
- * Where a profile's data lives, in one place.
2
+ * Where a workspace's data lives, in one place.
3
3
  *
4
4
  * ```
5
5
  * ~/.lanes-link/
6
- * ├── lanes-link.yaml which profiles exist, which is default
7
- * ├── profiles/<name>.yaml each profile's declared config
6
+ * ├── lanes-link.yaml the workspaces this machine knows, and the default
7
+ * ├── connections.yaml every account authorised in this workspace
8
+ * ├── profiles/<name>.yaml which of them a profile selects, and who may use it
8
9
  * └── data/
9
- * └── <profile>/ everything one profile owns
10
- * ├── state.kv/ state, connections, cursors
11
- * ├── audit.log/ one object per event
12
- * ├── credentials.enc system credentials, and its .key
13
- * ├── vault.enc the owner's items, its own key
14
- * ├── skills.d/ procedures, one <name>/SKILL.md each
15
- * ├── providers.d/ the operator's own provider manifests
16
- * └── <provider>/<connection>/… whatever that provider stores
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
17
17
  * ```
18
18
  *
19
- * **One profile, one directory.** Before this, `data/` held
20
- * `personal.db`, `personal.credentials.enc`, `personal.credentials.enc.key`,
21
- * `work.db`, and a `personal/` directory, all in one flat listing three
22
- * profiles' worth of state interleaved, and no single thing to copy, back up, or
23
- * delete. Now `rm -r data/work` is exactly "remove the work profile's data" and
24
- * nothing else.
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.
25
26
  *
26
- * **The blob root is the profile directory itself.** It used to be a `files/`
27
- * subdirectory, which bought nothing and cost a level: a memory entry landed at
28
- * `data/personal/files/memory/memory/entry/<id>.md`. The provider name and the
29
- * connection name are the isolation boundary (`scopeBlobStore` namespaces
30
- * `<provider>/<connection>`) and they are enough on their own, so an entry is
31
- * now `data/personal/memory/main/<id>.md`. There is no collision risk with the
32
- * files beside it: a provider id is `[a-z][a-z0-9_]*` and every reserved name
33
- * here contains a dot.
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.
34
35
  *
35
- * **Skills and provider manifests are the profile's too**, which reverses where
36
- * both used to sit. They were at the workspace root, shared by every profile, on
37
- * the reasoning that a procedure is not private to a profile the way its
38
- * knowledge is. That was wrong twice over. A skill is instructions an agent will
39
- * be handed, and ADR-014 §1 already treats authoring one as a grant worth
40
- * governing — an odd thing to say about a document every profile reads anyway.
41
- * And a procedure written for work names work's accounts, its people, and its
42
- * conventions, so it is exactly as private as the knowledge it operates on.
43
- * ADR-030 has the argument; ADR-009's "profiles share nothing" is what it
44
- * restores.
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.
45
40
  *
46
- * The `.d` suffix is the dot rule above rather than decoration. A plain
47
- * `data/<profile>/skills/` is precisely the namespace the skills provider's own
48
- * blobs scope into, so the one name that could not be used is the obvious one.
49
- *
50
- * These are **defaults**. A profile that declares its own paths keeps them.
51
- *
52
- * There is no migration from the layout this replaced, deliberately: a
53
- * workspace is profiles, credentials, and whatever the owner has stored, and
54
- * re-creating one is `lanes link profile add` plus `lanes link connect` per account. Machinery
55
- * to move an old one would be more code than the thing it moves, and it would
56
- * have to keep working forever to be worth having. Skills and manifests left at
57
- * the old workspace-root paths are the same case: they stop loading, and moving
58
- * them is one `mv` per profile that should see them.
41
+ * These are **defaults**. A workspace that declares its own paths keeps them.
59
42
  */
60
43
 
61
- /** The directory under the workspace root that holds every profile's data. */
44
+ /** The directory under the workspace root that holds everything the workspace owns. */
62
45
  export const DATA_DIR = 'data';
63
46
 
64
47
  /**
65
- * Everything one profile owns, relative to the workspace root.
48
+ * No leading `./` on any of these.
66
49
  *
67
- * No leading `./`. It used to carry one, which `path.resolve` discards and an
68
- * object key does not: a bucket read `./data/personal/state.kv/x` as a
69
- * directory literally named `.`, so every deployed key landed one level away
70
- * from where the config said it did. The visible cost was that the conditioned
71
- * IAM binding `deployments/gcp/provision.ts` writes — which grants writes under
50
+ * 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
52
+ * every deployed key landed one level away from where the config said it did.
53
+ * The visible cost was that the conditioned IAM binding
54
+ * `deployments/gcp/provision.ts` writes — which grants writes under
72
55
  * `objects/data/` — matched nothing, and the first revision 403'd on its boot
73
56
  * reconcile. A relative path is relative without being spelled that way.
74
57
  */
75
- export function profileDir(profile: string): string {
76
- return `${DATA_DIR}/${profile}`;
77
- }
78
-
79
58
  export const layout = {
59
+ /** Connections, provider state, and cursors: one object per key. */
60
+ state: (): string => `${DATA_DIR}/state.kv`,
61
+ /**
62
+ * System credentials — OAuth refresh tokens, the CI token. Never reachable
63
+ * from MCP.
64
+ *
65
+ * One store for the workspace, where there used to be one per profile. That
66
+ * is what makes a `credential_ref` unique by construction and retires
67
+ * `collidingRefs`, the deploy preflight that existed because two profiles
68
+ * deployed into one project shared this namespace and the collision was
69
+ * "silent until one profile is reading the other's account" (ADR-043,
70
+ * ADR-057).
71
+ */
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`,
80
77
  /**
81
- * Connections, provider state, and cursors: one object per key.
78
+ * The same document, keyed relative to the blob store.
82
79
  *
83
- * The dot is load-bearing, exactly as it is for `audit` below a provider
84
- * is namespaced `<provider>/<connection>` under `blobs`, and a provider id
85
- * is `[a-z][a-z0-9_]*`, so a name carrying a dot is one no provider can be
86
- * scoped into.
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.
87
84
  */
88
- state: (profile: string): string => `${profileDir(profile)}/state.kv`,
89
- /** System credentials — OAuth tokens, the profile token. Never reachable from MCP. */
90
- credentials: (profile: string): string => `${profileDir(profile)}/credentials.enc`,
91
- /** The owner's own items, under their own key. */
92
- vault: (profile: string): string => `${profileDir(profile)}/vault.enc`,
85
+ vaultKey: (connection: string): string => `vault.d/${connection}.enc`,
93
86
  /**
94
- * This profile's skills `<name>.md` or `<name>/SKILL.md`, either layout.
87
+ * Every skills connection's procedures live under here.
95
88
  *
96
- * The dot carries the same weight it does for `state` and `audit`, and here
97
- * it is not hypothetical: `skills` is a real provider id, so `skills/` under
98
- * the blob root is the prefix its own connection blobs would scope into.
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.
99
94
  */
100
- skills: (profile: string): string => `${profileDir(profile)}/skills.d`,
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}`,
101
98
  /**
102
- * The provider manifests this profile declares.
99
+ * The provider manifests this workspace declares.
103
100
  *
104
- * Per profile for the same reason a connection is. A manifest names a host,
105
- * an OpenAPI document, and the credential refs that reach them — which is a
106
- * description of somebody's infrastructure, and work's is not personal's to
107
- * read.
101
+ * Workspace-level, where ADR-030 put them in the profile. A manifest names a
102
+ * host, an OpenAPI document, and the credential refs that reach them — which
103
+ * 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.
108
107
  */
109
- providers: (profile: string): string => `${profileDir(profile)}/providers.d`,
108
+ providers: (): string => `${DATA_DIR}/providers.d`,
110
109
  /** The blob root every provider is namespaced under. */
111
- blobs: (profile: string): string => profileDir(profile),
110
+ blobs: (): string => DATA_DIR,
112
111
  /**
113
- * The audit log: one object per event, under the profile's blob root.
112
+ * The audit log: one object per event, one chain for the workspace.
114
113
  *
115
- * The dot is doing real work. A provider is namespaced to
116
- * `<provider>/<connection>` under `blobs` above, and a provider id is
117
- * `[a-z][a-z0-9_]*` so a name carrying a dot is one no provider can be
118
- * scoped to. Without it, a provider called `audit` would be handed a store
119
- * rooted inside the log, which is a hole in ADR-007's wall rather than an
120
- * untidy filename.
114
+ * One chain rather than one per profile, because one endpoint serves them all
115
+ * (ADR-009) and every event already records the profile it acted in. It is
116
+ * also what lets `audit tail` filter where it used to select, and what gives
117
+ * the dashboard a single log to read.
121
118
  */
122
- audit: (profile: string): string => `${profileDir(profile)}/audit.log`,
119
+ audit: (): string => `${DATA_DIR}/audit.log`,
123
120
  } as const;