@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
@@ -1,123 +1,196 @@
1
1
  /**
2
- * Where a profile'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 which profiles exist, which is default
7
- * ├── profiles/<name>.yaml each profile's declared config
8
- * └── 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
6
+ * ├── workspaces.yaml the workspaces this machine knows, and the default
7
+ * ├── connections.yaml every account authorised in this workspace
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
16
18
  * └── <provider>/<connection>/… whatever that provider stores
17
19
  * ```
18
20
  *
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.
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).
25
28
  *
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.
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.
34
34
  *
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.
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.
45
43
  *
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.
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.
49
47
  *
50
- * These are **defaults**. A profile that declares its own paths keeps them.
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.
51
54
  *
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.
55
+ * These are **defaults**. A workspace that declares its own paths keeps them.
59
56
  */
60
57
 
61
- /** The directory under the workspace root that holds every profile's data. */
62
- export const DATA_DIR = 'data';
58
+ /** The directory holding one subdirectory per profile. */
59
+ export const PROFILES_DIR = 'profiles';
63
60
 
64
61
  /**
65
- * Everything one profile owns, relative to the workspace root.
62
+ * What a profile's declaration is called inside its own directory.
66
63
  *
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
72
- * `objects/data/` — matched nothing, and the first revision 403'd on its boot
73
- * reconcile. A relative path is relative without being spelled that way.
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.
74
68
  */
75
- export function profileDir(profile: string): string {
76
- return `${DATA_DIR}/${profile}`;
77
- }
69
+ export const PROFILE_FILE = 'profile.yaml';
78
70
 
71
+ /**
72
+ * No leading `./` on any of these.
73
+ *
74
+ * It used to carry one, which `path.resolve` discards and an object key does
75
+ * not: a bucket read `./state.kv/x` as a directory literally named `.`, so
76
+ * every deployed key landed one level away from where the config said it did.
77
+ * The visible cost was that the conditioned IAM binding
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.
81
+ */
79
82
  export const layout = {
80
83
  /**
81
- * Connections, provider state, and cursors: one object per key.
84
+ * System credentials OAuth refresh tokens, the CI token. Never reachable
85
+ * from MCP.
86
+ *
87
+ * One store for the workspace, where there used to be one per profile. That
88
+ * is what makes a `credential_ref` unique by construction and retires
89
+ * `collidingRefs`, the deploy preflight that existed because two profiles
90
+ * deployed into one project shared this namespace and the collision was
91
+ * "silent until one profile is reading the other's account" (ADR-043,
92
+ * ADR-057).
93
+ */
94
+ credentials: (): string => 'credentials.enc',
95
+ /**
96
+ * The provider manifests this workspace declares.
97
+ *
98
+ * Workspace-level, where ADR-030 put them in the profile. A manifest names a
99
+ * host, an OpenAPI document, and the credential refs that reach them — which
100
+ * is to say it *defines a connection*, and connections do not live in a
101
+ * profile any more (ADR-057).
102
+ */
103
+ providers: (): string => 'providers.d',
104
+ /**
105
+ * The audit log: one object per event, one chain for the workspace.
106
+ *
107
+ * One chain rather than one per profile, because one endpoint serves them all
108
+ * (ADR-009) and every event already records the profile it acted in. It is
109
+ * also what lets `audit tail` filter where it used to select, and what gives
110
+ * the dashboard a single log to read.
111
+ */
112
+ audit: (): string => 'audit.log',
113
+ /**
114
+ * What the workspace knows about its accounts, and about itself.
82
115
  *
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.
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.
87
122
  */
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`,
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}`,
93
129
  /**
94
- * This profile's skills `<name>.md` or `<name>/SKILL.md`, either layout.
130
+ * What this profile selects, and who may use it.
95
131
  *
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.
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.
99
136
  */
100
- skills: (profile: string): string => `${profileDir(profile)}/skills.d`,
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`,
101
145
  /**
102
- * The provider manifests this profile declares.
146
+ * The same document, keyed relative to the blob store.
103
147
  *
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.
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.
108
153
  */
109
- providers: (profile: string): string => `${profileDir(profile)}/providers.d`,
110
- /** The blob root every provider is namespaced under. */
111
- blobs: (profile: string): string => profileDir(profile),
154
+ vaultKey: (connection: string): string => `vault.d/${connection}.enc`,
112
155
  /**
113
- * The audit log: one object per event, under the profile's blob root.
156
+ * Every skills connection's procedures, for this profile.
114
157
  *
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.
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.
121
163
  */
122
- audit: (profile: string): string => `${profileDir(profile)}/audit.log`,
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}`,
123
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
+ }
@@ -1,10 +1,10 @@
1
1
  import { parse as parseYaml } from 'yaml';
2
2
  import { z } from 'zod';
3
3
  import {
4
+ SINGLE_INSTANCE_PROVIDERS,
4
5
  SUPPORTED_CONTRACT,
5
6
  configSchema,
6
7
  type Config,
7
- type PolicyRuleConfig,
8
8
  } from './schema.ts';
9
9
  import { findSecrets, formatSecretFindings } from './secret-detection.ts';
10
10
 
@@ -21,7 +21,7 @@ export class ConfigError extends Error {
21
21
  export interface LoadedConfig {
22
22
  readonly config: Config;
23
23
  readonly source: string;
24
- /** `provider.id` for every declared connection, in declaration order. */
24
+ /** `provider.id` for every connection this profile grants, in declaration order. */
25
25
  readonly connectionKeys: readonly string[];
26
26
  }
27
27
 
@@ -39,8 +39,12 @@ export interface LoadedConfig {
39
39
  * 3. Schema shape.
40
40
  * 4. Referential integrity, which needs a well-formed document to check.
41
41
  */
42
- export function validateConfig(raw: unknown, source = '<config>'): Config {
43
- const config = validateConfigShape(raw, source);
42
+ export function validateConfig(
43
+ raw: unknown,
44
+ source = '<config>',
45
+ contract = SUPPORTED_CONTRACT,
46
+ ): Config {
47
+ const config = validateConfigShape(raw, source, contract);
44
48
  assertReferentialIntegrity(config, source);
45
49
  return config;
46
50
  }
@@ -58,12 +62,16 @@ export function validateConfig(raw: unknown, source = '<config>'): Config {
58
62
  * through `validateConfig`, and the split exists so that the one command whose
59
63
  * job is to fix a refusal is not blocked by it.
60
64
  */
61
- export function validateConfigShape(raw: unknown, source = '<config>'): Config {
65
+ export function validateConfigShape(
66
+ raw: unknown,
67
+ source = '<config>',
68
+ contract = SUPPORTED_CONTRACT,
69
+ ): Config {
62
70
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
63
71
  throw new ConfigError(`${source}: expected a YAML mapping at the top level`);
64
72
  }
65
73
 
66
- assertSupportedContract(raw, source);
74
+ assertSupportedContract(raw, source, contract);
67
75
 
68
76
  const secrets = findSecrets(raw);
69
77
  if (secrets.length > 0) {
@@ -88,7 +96,17 @@ export function validateConfigShape(raw: unknown, source = '<config>'): Config {
88
96
  * risks reading a document as more permissive than the operator wrote it, and
89
97
  * refusing to start is always the safer failure.
90
98
  */
91
- function assertSupportedContract(raw: object, source: string): void {
99
+ /**
100
+ * The contract this document must declare.
101
+ *
102
+ * `expected` is `SUPPORTED_CONTRACT` everywhere except inside a migration,
103
+ * which writes intermediate shapes on the way to the newest one — contract 1 to
104
+ * 2 to 3 to 4, saving at each step. Refusing those would mean a migration
105
+ * cannot use `ConfigDocument` at all and has to write YAML past the validator,
106
+ * losing the secret-shaped-value check that is the reason the validator runs on
107
+ * every save. A step that declares which contract it is producing keeps both.
108
+ */
109
+ function assertSupportedContract(raw: object, source: string, expected: number): void {
92
110
  const contract = (raw as { contract?: unknown }).contract;
93
111
 
94
112
  if (typeof contract !== 'number' || !Number.isInteger(contract)) {
@@ -97,14 +115,20 @@ function assertSupportedContract(raw: object, source: string): void {
97
115
  );
98
116
  }
99
117
 
100
- if (contract !== SUPPORTED_CONTRACT) {
101
- const direction = contract > SUPPORTED_CONTRACT ? 'newer than' : 'older than';
118
+ if (contract !== expected) {
119
+ const direction = contract > expected ? 'newer than' : 'older than';
102
120
  throw new ConfigError(
103
121
  `${source}: contract ${contract} is ${direction} the contract this binary implements (${SUPPORTED_CONTRACT}). ` +
104
122
  `Refusing to load rather than guessing at what the document means. ` +
123
+ // **Naming the command is the whole point of this branch.** ADR-051
124
+ // records what a refusal that names none costs: `validateConfig` runs
125
+ // from every command, so one stale workspace takes `status`, `start`,
126
+ // `plan` and `doctor` down together, and "migrate the config" left an
127
+ // operator with a wall and no door. `doctor --fix` runs every migration
128
+ // between the contract on disk and this one.
105
129
  (contract > SUPPORTED_CONTRACT
106
130
  ? 'Upgrade lanes-link.'
107
- : 'Migrate the config, or use a matching lanes-link version.'),
131
+ : 'Migrate it with: lanes link doctor --fix'),
108
132
  );
109
133
  }
110
134
  }
@@ -166,8 +190,11 @@ function formatZodIssues(error: z.ZodError): string {
166
190
  export interface ProviderRename {
167
191
  /** What a row naming the old id should say instead. */
168
192
  readonly to: string;
169
- /** The account label that means this row is the built-in, not a vendor one. */
170
- readonly keeps: string;
193
+ /**
194
+ * The account label that meant this row was the built-in rather than a
195
+ * vendor's, or `null` where the id belongs to nobody now.
196
+ */
197
+ readonly keeps: string | null;
171
198
  /** What the plain noun now names, for the sentence below. */
172
199
  readonly becomes: string;
173
200
  /** What it used to name. */
@@ -190,8 +217,12 @@ export interface ProviderRename {
190
217
  export const RENAMED_PROVIDERS: Readonly<Record<string, ProviderRename>> = {
191
218
  tasks: {
192
219
  to: 'google_tasks',
193
- keeps: 'Tasks',
194
- becomes: 'the built-in task list',
220
+ // **`null`, where this was `'Tasks'`.** The refusal existed because the
221
+ // built-in claimed `tasks`, so a stale Google Tasks row rebound to it and
222
+ // the label was the only evidence of which was meant (ADR-051). The
223
+ // built-in is `lanes_tasks` now, so no label makes a row here legitimate.
224
+ keeps: null,
225
+ becomes: 'nobody\u2019s provider id \u2014 the built-in task list is lanes_tasks',
195
226
  was: 'Google Tasks',
196
227
  noun: 'task list',
197
228
  },
@@ -208,7 +239,7 @@ export const RENAMED_PROVIDERS: Readonly<Record<string, ProviderRename>> = {
208
239
  * and whoever is reading this refusal just typed which one.
209
240
  */
210
241
  function repairCommand(config: Config): string {
211
- return `lanes link doctor --fix --profile ${config.instance.profile} --target <target>`;
242
+ return `lanes link doctor --fix --profile ${config.instance.profile} --workspace <name>`;
212
243
  }
213
244
 
214
245
  /** The rename a row is owed, or `null` when it is owed none. */
@@ -217,11 +248,12 @@ export function renamedProviderFor(connection: {
217
248
  account: string;
218
249
  }): ProviderRename | null {
219
250
  const moved = RENAMED_PROVIDERS[connection.provider];
220
- if (!moved || connection.account === moved.keeps) return null;
221
- return moved;
251
+ if (!moved) return null;
252
+ // `keeps === null` means the id belongs to nobody, so no label exempts a row.
253
+ return moved.keeps !== null && connection.account === moved.keeps ? null : moved;
222
254
  }
223
255
 
224
- function renamedProvider(
256
+ export function describeRename(
225
257
  connection: { provider: string; account: string },
226
258
  repair: string,
227
259
  ): string | null {
@@ -229,11 +261,15 @@ function renamedProvider(
229
261
  if (!moved) return null;
230
262
 
231
263
  return (
232
- `"${connection.provider}" is now ${moved.becomes}, and this row is labelled ` +
233
- `"${connection.account}" rather than "${moved.keeps}".\n` +
264
+ (moved.keeps === null
265
+ ? `"${connection.provider}" is ${moved.becomes}.\n`
266
+ : `"${connection.provider}" is now ${moved.becomes}, and this row is labelled ` +
267
+ `"${connection.account}" rather than "${moved.keeps}".\n`) +
234
268
  ` If it was ${moved.was}: set provider to ${moved.to} here, and rename any ` +
235
269
  `"${connection.provider}.*" policy rule.\n` +
236
- ` If it is your own ${moved.noun}: set account to ${moved.keeps}.\n` +
270
+ (moved.keeps === null
271
+ ? ` If it is your own ${moved.noun}: it is lanes_${connection.provider} now.\n`
272
+ : ` If it is your own ${moved.noun}: set account to ${moved.keeps}.\n`) +
237
273
  ` ${repair} applies the first, where a stored credential proves it.`
238
274
  );
239
275
  }
@@ -241,30 +277,74 @@ function renamedProvider(
241
277
  function assertReferentialIntegrity(config: Config, source: string): void {
242
278
  const problems: string[] = [];
243
279
 
244
- // There is nothing to check about targets here any more. A profile declares
245
- // none (ADR-052): the workspace holding this file declares the one target it
246
- // lives in, and `workspaceSchema` is what validates that. A profile is now
247
- // portable between targets precisely because it says nothing about them.
248
-
249
- // Connection ids are unique per provider, so `gmail.main` and
250
- // `icloud_mail.main` can coexist.
251
- const connectionKeys = new Set<string>();
252
- const providerNames = new Set(config.connections.map((connection) => connection.provider));
253
-
254
- config.connections.forEach((connection, index) => {
255
- const key = `${connection.provider}.${connection.id}`;
256
- if (connectionKeys.has(key)) {
257
- problems.push(`connections[${index}]: duplicate connection "${key}"`);
280
+ // Nothing about targets is checked here any more. A profile declares none
281
+ // (ADR-052): the workspace holding this file declares the one it lives in.
282
+ //
283
+ // Nothing about *connections* is either, and that is newer. A connection
284
+ // belongs to the workspace (ADR-057), so whether a grant names a real one is
285
+ // a question this file cannot answer on its own — `assertGrantsResolve` in
286
+ // `./connections.ts` answers it once `connections.yaml` has been read. What
287
+ // is checkable from one profile alone is checked here, and only that.
288
+
289
+ // A grant names one connection, so every capability in it belongs to that
290
+ // connection's provider. Worth refusing rather than warning: `allowedConnections`
291
+ // filters candidates to the capability's own provider before policy is
292
+ // consulted, so `allow: [calendar.*]` on a row granting `gmail.personal`
293
+ // matches nothing, ever, while reading exactly like a grant that works.
294
+ const grantKeys = new Set<string>();
295
+ config.grants.forEach((grant, index) => {
296
+ if (grantKeys.has(grant.connection)) {
297
+ problems.push(`grants[${index}]: duplicate grant for "${grant.connection}"`);
258
298
  }
259
- connectionKeys.add(key);
299
+ grantKeys.add(grant.connection);
300
+
301
+ const provider = grant.connection.split('.')[0] ?? '';
302
+
303
+ // The renamed-provider check is not here any more. It compares a row's
304
+ // *account* against the label the built-in keeps — "is this really Google
305
+ // Tasks?" — and an account lives in `connections.yaml` now (ADR-057). A
306
+ // profile grant carries only the key, so asking here would either need the
307
+ // other file or answer from nothing; `assertNoRenamedProviders` asks where
308
+ // the answer is.
309
+
310
+ for (const [field, rules] of [
311
+ ['allow', grant.allow],
312
+ ['deny', grant.deny],
313
+ ] as const) {
314
+ rules.forEach((rule, ruleIndex) => {
315
+ if (rule.capability === '*') return;
316
+ const named = rule.capability.split('.')[0] ?? '';
317
+ if (named === provider) return;
260
318
 
261
- const renamed = renamedProvider(connection, repairCommand(config));
262
- if (renamed) problems.push(`connections[${index}]: ${renamed}`);
319
+ problems.push(
320
+ `grants[${index}].${field}[${ruleIndex}]: "${rule.capability}" names provider ` +
321
+ `"${named}", but this row grants "${grant.connection}". A rule here governs ` +
322
+ `that connection and nothing else, so it can only name "${provider}.*".`,
323
+ );
324
+ });
325
+ }
263
326
  });
264
327
 
265
- // Same reason as a duplicate connection: two entries with the same kind and
266
- // value cannot both be meant, and the one that loses is invisible. It matters
267
- // more here than it looks, because the two would usually differ only in their
328
+ // At most one skills grant and one vault grant, for the reason
329
+ // `SINGLE_INSTANCE_PROVIDERS` gives: both surface as flat names with no
330
+ // argument to route on, so a second instance is a collision rather than a
331
+ // choice.
332
+ for (const provider of SINGLE_INSTANCE_PROVIDERS) {
333
+ const granted = config.grants
334
+ .map((grant) => grant.connection)
335
+ .filter((ref) => ref.startsWith(`${provider}.`));
336
+
337
+ if (granted.length > 1) {
338
+ problems.push(
339
+ `grants: ${granted.join(' and ')} — a profile may grant one "${provider}" connection. ` +
340
+ `It is surfaced by name with nothing to route on, so two would be one name for two things.`,
341
+ );
342
+ }
343
+ }
344
+
345
+ // Same reason as a duplicate grant: two entries with the same kind and value
346
+ // cannot both be meant, and the one that loses is invisible. It matters more
347
+ // here than it looks, because the two would usually differ only in their
268
348
  // `note` — so the discarded one is precisely the guidance someone wrote down
269
349
  // to stop an agent picking wrong.
270
350
  const identityKeys = new Set<string>();
@@ -276,28 +356,17 @@ function assertReferentialIntegrity(config: Config, source: string): void {
276
356
  identityKeys.add(key);
277
357
  });
278
358
 
279
- const checkRules = (rules: readonly PolicyRuleConfig[], field: 'allow' | 'deny'): void => {
280
- rules.forEach((rule, index) => {
281
- const where = `policy.${field}[${index}]`;
282
- if (rule.capability === '*') return;
283
-
284
- // A rule naming a provider with no connection is almost always a typo,
285
- // and one that fails open-looking: it reads as a grant while matching
286
- // nothing. Worth saying, but not fatal for a `deny` — denying something
287
- // you have not connected yet is a perfectly reasonable thing to write
288
- // ahead of time.
289
- const providerOfCapability = rule.capability.split('.')[0] ?? '';
290
- if (field === 'allow' && !providerNames.has(providerOfCapability)) {
291
- problems.push(
292
- `${where}: "${rule.capability}" names provider "${providerOfCapability}", which has no connection` +
293
- (providerNames.size > 0 ? ` (have: ${[...providerNames].join(', ')})` : ''),
294
- );
295
- }
296
- });
297
- };
298
-
299
- checkRules(config.policy.allow, 'allow');
300
- checkRules(config.policy.deny, 'deny');
359
+ // A subject listed twice is one row deciding the role and the other doing
360
+ // nothing, and which one wins is iteration order. Since the two would differ
361
+ // only in `role`, the silent loser is exactly the line someone added to
362
+ // promote or demote somebody (ADR-060).
363
+ const subjects = new Set<string>();
364
+ config.members.forEach((member, index) => {
365
+ if (subjects.has(member.subject)) {
366
+ problems.push(`members[${index}]: duplicate subject "${member.subject}"`);
367
+ }
368
+ subjects.add(member.subject);
369
+ });
301
370
 
302
371
  if (problems.length > 0) {
303
372
  throw new ConfigError(`${source}:\n${problems.map((p) => ` ${p}`).join('\n')}`, problems);
@@ -316,7 +385,7 @@ export function parseConfig(text: string, source = '<config>'): LoadedConfig {
316
385
  return {
317
386
  config,
318
387
  source,
319
- connectionKeys: config.connections.map((c) => `${c.provider}.${c.id}`),
388
+ connectionKeys: config.grants.map((grant) => grant.connection),
320
389
  };
321
390
  }
322
391