@mnstry/atelier 0.2.0-alpha.13 → 0.2.0-alpha.15

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 (253) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +102 -52
  3. package/SECURITY.md +1 -1
  4. package/TRADEMARKS.md +2 -2
  5. package/contracts/atelier-extension-manifest.v1.schema.json +412 -0
  6. package/contracts/atelier-graph-view.v1.schema.json +1085 -0
  7. package/contracts/public-api-baseline.json +298 -19
  8. package/docs/README.md +90 -0
  9. package/docs/blocks/capability-stewardship.md +1 -1
  10. package/docs/blocks/claims.md +2 -2
  11. package/docs/blocks/conformance-admission.md +1 -1
  12. package/docs/blocks/learning-harnesses.md +1 -1
  13. package/docs/{atelier-sync.md → guides/atelier-sync.md} +20 -14
  14. package/docs/{capability-stewardship.md → guides/capability-stewardship.md} +2 -4
  15. package/docs/guides/co-authoring.md +113 -0
  16. package/docs/{distributions.md → guides/distributions.md} +13 -8
  17. package/docs/guides/extensions.md +189 -0
  18. package/docs/guides/graph-refresh.md +152 -0
  19. package/docs/guides/graph-workspace.md +64 -0
  20. package/docs/{ingestion.md → guides/ingestion.md} +16 -15
  21. package/docs/{inquiry-harnesses.md → guides/inquiry-harnesses.md} +2 -3
  22. package/docs/{knowledge-workspace.md → guides/knowledge-workspace.md} +2 -2
  23. package/docs/{learning-harnesses.md → guides/learning-harnesses.md} +2 -3
  24. package/docs/{learning.md → guides/learning.md} +12 -5
  25. package/docs/{obsidian.md → guides/obsidian.md} +370 -23
  26. package/docs/guides/ontology-guide.md +111 -0
  27. package/docs/{personal-workspace.md → guides/personal-workspace.md} +17 -5
  28. package/docs/{research-starter.md → guides/research-starter.md} +1 -1
  29. package/docs/{tenant-readiness.md → guides/tenant-readiness.md} +39 -0
  30. package/docs/{upgrade.md → guides/upgrade.md} +197 -11
  31. package/docs/knowledge-health/assessment/capabilities.md +6 -15
  32. package/docs/knowledge-health/facilitator.md +15 -4
  33. package/docs/knowledge-health/{devday/participant-response.md → participant-response.md} +4 -2
  34. package/docs/knowledge-health/quickstart.md +97 -75
  35. package/docs/knowledge-health/readme.md +13 -5
  36. package/docs/maintainers/describing-the-atelier.md +173 -0
  37. package/docs/{release-engineering.md → maintainers/release-engineering.md} +5 -4
  38. package/docs/{announcements.md → reference/announcements.md} +1 -1
  39. package/docs/{architecture.md → reference/architecture.md} +31 -5
  40. package/docs/{atelier-runtime.md → reference/atelier-runtime.md} +1 -1
  41. package/docs/{connected-composition.md → reference/connected-composition.md} +7 -10
  42. package/docs/{coordination.md → reference/coordination.md} +1 -1
  43. package/docs/{decisions.md → reference/decisions.md} +3 -3
  44. package/docs/{design.md → reference/design.md} +1 -1
  45. package/docs/reference/edge-policy.md +151 -0
  46. package/docs/reference/graph-view-structured.md +119 -0
  47. package/docs/reference/graph-view.md +139 -0
  48. package/docs/reference/graph-workspace.md +37 -0
  49. package/docs/{intake-and-guides.md → reference/intake-and-guides.md} +3 -4
  50. package/docs/{knowledge-graph.md → reference/knowledge-graph.md} +120 -6
  51. package/docs/{local-services.md → reference/local-services.md} +43 -4
  52. package/docs/{obsidian-contract.md → reference/obsidian-contract.md} +29 -4
  53. package/docs/{obsidian-plugin.md → reference/obsidian-plugin.md} +218 -68
  54. package/docs/{practical-judgment.md → reference/phronesis.md} +12 -3
  55. package/docs/reference/portability.md +92 -0
  56. package/docs/{portable-presentation.md → reference/portable-presentation.md} +2 -2
  57. package/docs/{presentation-consumer-boundaries.md → reference/presentation-consumer-boundaries.md} +5 -0
  58. package/docs/{project-options.md → reference/project-options.md} +17 -0
  59. package/docs/{repo-boundary-guard.md → reference/repo-boundary-guard.md} +50 -4
  60. package/docs/{review-portability.md → reference/review-portability.md} +4 -0
  61. package/docs/{semantic-operation.md → reference/semantic-operation.md} +4 -3
  62. package/docs/{template-profile.md → reference/template-profile.md} +1 -1
  63. package/docs/{trackables.md → reference/trackables.md} +2 -2
  64. package/docs/start/independent-start.md +89 -0
  65. package/docs/{install.md → start/install.md} +9 -9
  66. package/docs/{knowledge-setup.md → start/knowledge-setup.md} +5 -5
  67. package/docs/{research-starter-quickstart.md → start/research-starter-quickstart.md} +6 -6
  68. package/fixtures/atelier-architecture/catalog/invalid/document.json +2 -2
  69. package/fixtures/atelier-architecture/catalog/valid/document.json +2 -2
  70. package/fixtures/graph-view/synthetic-authorized-input.v1.json +437 -0
  71. package/fixtures/graph-view/synthetic-view.v1.json +1 -0
  72. package/fixtures/knowledge-health/workshop/README.md +1 -1
  73. package/fixtures/templates/local-library.v1.json +3 -1
  74. package/package.json +22 -8
  75. package/plugins/obsidian/main.js +57 -8
  76. package/plugins/obsidian/manifest.json +1 -1
  77. package/skills/claude/atelier-guided-upgrade/SKILL.md +1 -1
  78. package/skills/claude/atelier-knowledge-setup/SKILL.md +2 -2
  79. package/skills/claude/atelier-local-service/SKILL.md +1 -1
  80. package/skills/claude/atelier-practical-judgment/SKILL.md +7 -2
  81. package/skills/claude/atelier-skill-steward/SKILL.md +1 -1
  82. package/skills/claude/mnstry-readiness/SKILL.md +2 -2
  83. package/skills/codex/atelier-guided-upgrade/SKILL.md +1 -1
  84. package/skills/codex/atelier-knowledge-setup/SKILL.md +2 -2
  85. package/skills/codex/atelier-local-service/SKILL.md +1 -1
  86. package/skills/codex/atelier-practical-judgment/SKILL.md +7 -2
  87. package/skills/codex/atelier-skill-steward/SKILL.md +1 -1
  88. package/skills/codex/mnstry-readiness/SKILL.md +2 -2
  89. package/src/architecture/catalog.mjs +2 -2
  90. package/src/attestation/sign.mjs +1 -1
  91. package/src/boundary/content-rules.mjs +534 -68
  92. package/src/boundary/policy.mjs +187 -60
  93. package/src/cli/run.mjs +26 -9
  94. package/src/commands/distribution.mjs +1 -1
  95. package/src/commands/extension.mjs +27 -0
  96. package/src/commands/ingest.mjs +1 -1
  97. package/src/commands/learn.mjs +1 -1
  98. package/src/commands/obsidian.mjs +49 -21
  99. package/src/commands/review.mjs +3 -0
  100. package/src/edge-policy/contract.mjs +69 -0
  101. package/src/edge-policy/policy.mjs +142 -0
  102. package/src/edge-policy/source-citation-adapter.mjs +43 -0
  103. package/src/extension-packs/loader.mjs +4 -3
  104. package/src/extensions/admission.mjs +83 -0
  105. package/src/extensions/client.d.mts +23 -0
  106. package/src/extensions/client.mjs +99 -0
  107. package/src/extensions/contracts.d.mts +29 -0
  108. package/src/extensions/contracts.mjs +163 -0
  109. package/src/extensions/convex.d.mts +24 -0
  110. package/src/extensions/convex.mjs +189 -0
  111. package/src/extensions/delegation.mjs +54 -0
  112. package/src/extensions/drizzle.d.mts +11 -0
  113. package/src/extensions/drizzle.mjs +33 -0
  114. package/src/extensions/postgres.d.mts +7 -0
  115. package/src/extensions/postgres.mjs +66 -0
  116. package/src/extensions/prisma.d.mts +13 -0
  117. package/src/extensions/prisma.mjs +63 -0
  118. package/src/extensions/project.mjs +432 -0
  119. package/src/extensions/scaffold-convex.mjs +130 -0
  120. package/src/extensions/scaffold-sql.mjs +144 -0
  121. package/src/extensions/server.d.mts +20 -0
  122. package/src/extensions/server.mjs +102 -0
  123. package/src/extensions/sql-scope.mjs +22 -0
  124. package/src/graph/graph.mjs +13 -5
  125. package/src/graph/knowledge-graph.mjs +20 -2
  126. package/src/graph/repo-contribution.mjs +405 -0
  127. package/src/graph/repo-contribution.types.mjs +32 -0
  128. package/src/graph-access/edge-decision-adapter.mjs +41 -0
  129. package/src/graph-access/edge-record-adapter.mjs +73 -0
  130. package/src/graph-access/index.mjs +253 -0
  131. package/src/graph-access/multi-contribution-record-adapter.mjs +101 -0
  132. package/src/graph-access/multi-record-projection-adapter.mjs +74 -0
  133. package/src/graph-access/source-adapter.mjs +81 -0
  134. package/src/graph-refresh/compare.mjs +110 -0
  135. package/src/graph-refresh/controller.mjs +324 -0
  136. package/src/graph-refresh/index.mjs +3 -0
  137. package/src/graph-refresh/local.mjs +78 -0
  138. package/src/graph-view/babylon/babylon-scene-port.mjs +64 -0
  139. package/src/graph-view/babylon/index.mjs +2 -0
  140. package/src/graph-view/babylon/navigator.mjs +312 -0
  141. package/src/graph-view/babylon/provenance.mjs +36 -0
  142. package/src/graph-view/babylon/scene-controller.mjs +131 -0
  143. package/src/graph-view/babylon/semantic-picking.mjs +35 -0
  144. package/src/graph-view/babylon/web-host.mjs +188 -0
  145. package/src/graph-view/cli.mjs +3 -0
  146. package/src/graph-view/command.mjs +34 -0
  147. package/src/graph-view/contracts.mjs +79 -0
  148. package/src/graph-view/errors.mjs +5 -0
  149. package/src/graph-view/index.mjs +4 -0
  150. package/src/graph-view/intents.mjs +30 -0
  151. package/src/graph-view/query.mjs +213 -0
  152. package/src/graph-view/refs.mjs +2 -0
  153. package/src/graph-view/structured/browser.mjs +262 -0
  154. package/src/graph-view/structured/styles.mjs +8 -0
  155. package/src/graph-workspace/authoring.mjs +185 -0
  156. package/src/graph-workspace/browser/constellation.mjs +369 -0
  157. package/src/graph-workspace/browser/dom.mjs +23 -0
  158. package/src/graph-workspace/browser/index.mjs +6 -0
  159. package/src/graph-workspace/browser/review.mjs +55 -0
  160. package/src/graph-workspace/browser/styles.mjs +11 -0
  161. package/src/graph-workspace/browser/workbench.mjs +351 -0
  162. package/src/graph-workspace/browser/workspace.mjs +431 -0
  163. package/src/graph-workspace/disclosure.mjs +39 -0
  164. package/src/graph-workspace/geometry.mjs +78 -0
  165. package/src/graph-workspace/index.mjs +11 -0
  166. package/src/graph-workspace/labels.mjs +50 -0
  167. package/src/graph-workspace/model.mjs +109 -0
  168. package/src/graph-workspace/recipes.mjs +88 -0
  169. package/src/graph-workspace/scale.mjs +95 -0
  170. package/src/graph-workspace/theme.mjs +156 -0
  171. package/src/ingestion/optional/graphify/graphify_supplier.py +5 -0
  172. package/src/ingestion/optional/graphify/native-closure-pins.json +275 -0
  173. package/src/ingestion/optional/graphify/native-pins.json +306 -0
  174. package/src/knowledge/ontology-guide.mjs +152 -0
  175. package/src/knowledge-health/proof/installed-check.mjs +2 -2
  176. package/src/knowledge-health/rule-identity.mjs +14 -9
  177. package/src/knowledge-health/workshop.mjs +22 -2
  178. package/src/personal-workspace/index.mjs +1 -1
  179. package/src/personal-workspace/selection.mjs +34 -3
  180. package/src/project/config.mjs +1 -1
  181. package/src/project/path-match.mjs +2 -1
  182. package/src/project/providers/github.mjs +24 -0
  183. package/src/project/repo-identity.mjs +3 -22
  184. package/src/projection/obsidian/contracts.mjs +1 -1
  185. package/src/projection/obsidian/materialize/path-registry.mjs +1 -1
  186. package/src/projection/obsidian/materialize/prepare-view.mjs +88 -6
  187. package/src/projection/obsidian/materialize/redaction.mjs +1 -1
  188. package/src/projection/obsidian/personal-workspace.mjs +84 -5
  189. package/src/projection/obsidian/plugin-bridge/channel.mjs +1 -1
  190. package/src/projection/obsidian/proposals/adapter.mjs +46 -23
  191. package/src/projection/obsidian/proposals/observation.mjs +40 -2
  192. package/src/projection/obsidian/publication/publisher.mjs +96 -8
  193. package/src/projection/project.mjs +21 -16
  194. package/src/readiness/readiness.mjs +20 -11
  195. package/src/readiness-protocols/bundled-pack.mjs +14 -766
  196. package/src/readiness-protocols/bundled-selection.mjs +32 -0
  197. package/src/readiness-protocols/evidence.mjs +2 -1
  198. package/src/readiness-protocols/packs/mnstry.mjs +766 -0
  199. package/src/readiness-protocols/runtime.mjs +43 -6
  200. package/src/runtime/obsidian/engine.mjs +387 -76
  201. package/src/runtime/obsidian/lifecycle.mjs +20 -15
  202. package/src/runtime/obsidian/login-item.mjs +6 -3
  203. package/src/runtime/obsidian/machine-settings.mjs +52 -8
  204. package/src/runtime/obsidian/observation.mjs +36 -4
  205. package/src/runtime/obsidian/opening.mjs +4 -3
  206. package/src/runtime/obsidian/personal-selection.mjs +187 -0
  207. package/src/runtime/obsidian/pipeline.mjs +115 -6
  208. package/src/runtime/obsidian/private-lock.mjs +50 -6
  209. package/src/runtime/obsidian/selection-continuity.mjs +124 -0
  210. package/src/runtime/obsidian/service-main.mjs +68 -16
  211. package/src/runtime/obsidian/service.mjs +80 -6
  212. package/src/runtime/obsidian/unheld-evidence.mjs +1 -1
  213. package/src/runtime/obsidian/view-permission.mjs +335 -0
  214. package/src/ui/control-system.mjs +201 -0
  215. package/src/ui/html-primitives.mjs +3 -0
  216. package/src/upgrade/closure-diagnostics.mjs +135 -0
  217. package/src/upgrade/closure.mjs +649 -0
  218. package/src/upgrade/upgrade.mjs +42 -11
  219. package/templates/distribution-workspace/README.md +2 -2
  220. package/templates/external-project-workspace/atelier.project.json +3 -0
  221. package/templates/external-project-workspace/pack-lifecycle.json +3 -1
  222. package/templates/knowledge-workspace/README.md +2 -2
  223. package/docs/integration-and-review-plan.md +0 -409
  224. package/docs/integration-contract-decisions.md +0 -44
  225. package/docs/knowledge-health/devday/facilitator.md +0 -16
  226. package/docs/knowledge-health/devday/public-default.md +0 -107
  227. package/docs/knowledge-health/devday/quickstart.md +0 -18
  228. package/docs/knowledge-health/devday/support.md +0 -22
  229. package/docs/knowledge-health/exercise.md +0 -17
  230. package/docs/knowledge-health/support.md +0 -34
  231. package/docs/presentation-review-disposition.md +0 -47
  232. package/docs/progressive-ingestion-acceptance.md +0 -89
  233. package/docs/progressive-ingestion.md +0 -235
  234. /package/docs/{exact-upgrades.md → guides/exact-upgrades.md} +0 -0
  235. /package/docs/{guided-upgrades.md → guides/guided-upgrades.md} +0 -0
  236. /package/docs/{local-review.md → guides/local-review.md} +0 -0
  237. /package/docs/{skill-steward.md → guides/skill-steward.md} +0 -0
  238. /package/docs/{template-upgrades.md → guides/template-upgrades.md} +0 -0
  239. /package/docs/{assurance-controls.md → maintainers/assurance-controls.md} +0 -0
  240. /package/docs/{attestation.md → reference/attestation.md} +0 -0
  241. /package/docs/{coauthor-session.md → reference/coauthor-session.md} +0 -0
  242. /package/docs/{continuity.md → reference/continuity.md} +0 -0
  243. /package/docs/{contract-stability.md → reference/contract-stability.md} +0 -0
  244. /package/docs/{decision-practices.md → reference/decision-practices.md} +0 -0
  245. /package/docs/{evidence-navigation.md → reference/evidence-navigation.md} +0 -0
  246. /package/docs/{extension-pack-lifecycle.md → reference/extension-pack-lifecycle.md} +0 -0
  247. /package/docs/{interaction-and-reflection.md → reference/interaction-and-reflection.md} +0 -0
  248. /package/docs/{local-state.md → reference/local-state.md} +0 -0
  249. /package/docs/{ontology.md → reference/ontology.md} +0 -0
  250. /package/docs/{optional-ingestion.md → reference/optional-ingestion.md} +0 -0
  251. /package/docs/{project-command-inventory.md → reference/project-command-inventory.md} +0 -0
  252. /package/docs/{semantic-ingestion.md → reference/semantic-ingestion.md} +0 -0
  253. /package/docs/{vault-service.md → reference/vault-service.md} +0 -0
@@ -17,7 +17,7 @@ import { BUILT_IN_OPERATIONS, UNAVAILABLE_APPLY_OPERATION, createObsidianRegistr
17
17
  import { LIFECYCLE_PRIMITIVES, readServiceStatusDocument, requestServiceTick, runtimeRelease, serviceStatus, startService, stopService } from '../runtime/obsidian/lifecycle.mjs'
18
18
  import {
19
19
  DECISIONS, ONLY_YOU_AUDIENCES, authorizeAutomaticApply, defaultMachineSettings, ensureWorkspaceIdentity, installApplyPolicy, localPointerPath, protectedRoots,
20
- readInstalledApplyPolicy, readMachineSettings, revokeApplyPolicy, withDecision, writeMachineSettings,
20
+ readInstalledApplyPolicy, readLocalPointer, readMachineSettings, revokeApplyPolicy, withDecision, writeMachineSettings,
21
21
  } from '../runtime/obsidian/machine-settings.mjs'
22
22
  import {
23
23
  currentLoginItemPlan, installLoginItem, loginItemDataRoot, loginItemLabel, loginItemStarter, loginItemStatus, planLoginItem, projectNameOf, readLoginItemRecord, realNodePath,
@@ -29,7 +29,7 @@ import { pluginPresenceOf, turnPluginOnNext, withPluginFilesPending, withPluginR
29
29
  import { planViewAdd, viewFromRequest, writeViewPlan } from '../runtime/obsidian/project-views.mjs'
30
30
  import { askGoAhead, createQuestioner } from '../runtime/obsidian/questions.mjs'
31
31
  import { readServiceSettings } from '../runtime/obsidian/service-record.mjs'
32
- import { resolveServiceWorkspace } from '../runtime/obsidian/service.mjs'
32
+ import { resolveServiceWorkspace, serviceLocationCheck } from '../runtime/obsidian/service.mjs'
33
33
  import { STARTUP_PLATFORMS, buildStartupAdapter, startupSearchPath } from '../runtime/obsidian/startup-adapters.mjs'
34
34
  import { OBSIDIAN_SETTINGS_FILE, obsidianSandboxedBuild, obsidianUserDataDir, readObsidianSettings } from '../projection/obsidian/publication/vault-list.mjs'
35
35
  import { checkVaultParent, projectDisplayName, vaultFolderName } from '../runtime/obsidian/vault-location.mjs'
@@ -69,7 +69,7 @@ const PRODUCTION_ADAPTER = 'obsidian-cli'
69
69
  const MAX_POLICY_BYTES = 64 * 1024
70
70
  const AUDIENCE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
71
71
 
72
- export const USAGE = `Usage: atelier obsidian <operation> [--project atelier.project.json] [--data-root DIR] [--json] [--no-input]
72
+ export const USAGE = `Usage: atelier obsidian <operation> [--project atelier.project.json | --personal-home DIR] [--data-root DIR] [--json] [--no-input]
73
73
 
74
74
  status Enablement, machine settings, service and per-view freshness. Read-only.
75
75
  settings What this machine remembers for this workspace, and how to change it. Read-only.
@@ -111,6 +111,11 @@ export const USAGE = `Usage: atelier obsidian <operation> [--project atelier.pro
111
111
  service publishes the view at once (with --adapter, one of an earlier release
112
112
  is replaced first).
113
113
 
114
+ --personal-home DIR, given instead of --project, follows the generation last confirmed in that private home of a personal
115
+ workspace (the personal-workspace module's selection); its views come from the overlay. Nothing here confirms one: while
116
+ none is confirmed and current nothing is published, and status, open and service start refuse with the reason. service
117
+ status and stop, unit --remove and uninstall find the service from the home alone. Who may see its vaults stays this
118
+ machine's audience setting: owner only when undecided; an explicit clear stays empty.
114
119
  Reaching the installed app needs --adapter=${PRODUCTION_ADAPTER} once for a workspace; it is remembered after that.
115
120
  Obsidian's own settings file (obsidian.json) is written only while no Obsidian runs: open adds the view's vault to its
116
121
  list, turns its command line on (cli: true), and creates the file for an Obsidian that never started; every write is
@@ -139,7 +144,7 @@ Exit codes: 0 done; 1 internal error; 2 refusal or usage; 3 ran, and the answer
139
144
  // spelling of `project`); a test holds it to that.
140
145
  export const FLAGS = Object.freeze({
141
146
  json: 'flag', 'allow-stale': 'flag', 'restart-obsidian': 'flag', print: 'flag', install: 'flag', remove: 'flag', help: 'flag', 'no-input': 'flag', 'allow-synced-location': 'flag', all: 'flag', default: 'flag', 'allow-empty': 'flag', yes: 'flag',
142
- project: 'value', 'project-config': 'value', 'data-root': 'value', scope: 'value', 'consent-actor': 'value', actor: 'value', adapter: 'value', 'wait-ms': 'value', repo: 'value', tag: 'value',
147
+ project: 'value', 'project-config': 'value', 'personal-home': 'value', 'data-root': 'value', scope: 'value', 'consent-actor': 'value', actor: 'value', adapter: 'value', 'wait-ms': 'value', repo: 'value', tag: 'value',
143
148
  expand: 'value', folder: 'values',
144
149
  })
145
150
 
@@ -276,6 +281,7 @@ const NEXT = Object.freeze({
276
281
  'login-item-remove-failed': 'see `atelier obsidian status`; `atelier obsidian service unit --remove` tries again',
277
282
  'login-item-file-unsafe': 'the login item\'s file is a link or not a regular file and is left alone; remove it by hand, then try again',
278
283
  'invalid-login-item': 'the login item record in private state does not validate; inspect it, remove it, then install the login item again',
284
+ 'selection-history-shortened': 'records of the selection history were removed after this process read them; put them back, or run the command in a process of its own, which reads the history as it now stands',
279
285
  })
280
286
 
281
287
  // The login item of `status`, in words.
@@ -372,10 +378,16 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
372
378
 
373
379
  const dataRoot = flags['data-root'] === undefined ? options.dataRoot : path.resolve(cwd, flags['data-root'])
374
380
  const configFlag = flags.project ?? flags['project-config']
375
- const loadProject = options.loadProject ?? (() => resolveProjectConfig({ argv: configFlag === undefined ? [] : [`--project=${path.resolve(cwd, configFlag)}`], cwd, env, writeLocalState: false }))
381
+ // A personal home is followed instead of a project: the generation confirmed there is loaded (composed in this
382
+ // thread, as for a command run once), and the service's workspace is located from the home alone.
383
+ const personalHome = flags['personal-home'] === undefined ? null : path.resolve(cwd, flags['personal-home'])
384
+ if (personalHome !== null && configFlag !== undefined) refuse('usage', '--personal-home is given instead of --project, not with it')
385
+ const selection = personalHome === null ? null : await import('../runtime/obsidian/personal-selection.mjs')
386
+ const loadProject = options.loadProject ?? (selection !== null ? () => selection.loadSelectedProject({ personalHome, dataRoot, env, platform }) : () => resolveProjectConfig({ argv: configFlag === undefined ? [] : [`--project=${path.resolve(cwd, configFlag)}`], cwd, env, writeLocalState: false }))
387
+ const locateProject = selection === null ? null : () => selection.locatePersonalHome({ personalHome })
376
388
  const registry = createObsidianRegistry({ reservedOperations: BUILT_IN_OPERATIONS, contributions: contributions ?? await loadContributions(contributionsDirectory === undefined ? {} : { directory: contributionsDirectory }) })
377
389
  const applyAvailable = registry.extensions.applyOperation() !== UNAVAILABLE_APPLY_OPERATION
378
- const lifecycle = { loadProject, dataRoot, env, platform, ...(options.probeTimeoutMs === undefined ? {} : { probeTimeoutMs: options.probeTimeoutMs }) }
390
+ const lifecycle = { loadProject, ...(locateProject === null ? {} : { locateProject }), dataRoot, env, platform, ...(options.probeTimeoutMs === undefined ? {} : { probeTimeoutMs: options.probeTimeoutMs }) }
379
391
 
380
392
  // The adapter of this run: given now, or remembered by this workspace (read only when the real entry has no flag).
381
393
  const chooseAdapter = () => selectAdapter({ flag: flags.adapter, remembered: flags.adapter === undefined && production === true ? rememberedAdapter() : null, production, env })
@@ -458,8 +470,8 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
458
470
  const pluginOf = async (scopeId) => pluginPresenceOf((await readServiceStatusDocument(lifecycle, lifecycleRules)).document, scopeId)
459
471
  const pluginLine = (plugin) => `${plugin.present ? `; plugin present (Obsidian ${plugin.appVersion})` : plugin.reason === 'turned-off-in-this-vault' ? '; plugin turned off in this vault' : `; plugin not present (${plugin.reason})`}${plugin.files === 'waits-for-app' ? '; plugin files wait for the app' : ''}`
460
472
 
461
- // A project bound to a personal workspace takes its views from the overlay, and has no place for these decisions
462
- // yet: the operations that would write them refuse before anything is resolved or written.
473
+ // A personal binding takes its views from the overlay and keeps its audience owner only (or explicitly empty).
474
+ // Operations outside those machine decisions refuse before anything is resolved or written.
463
475
  const refuseOnPersonalBinding = async (project, operation) => (await import('../projection/obsidian/personal-workspace.mjs')).refuseOnPersonalBinding(project, operation)
464
476
  const configured = () => {
465
477
  const project = loadProject()
@@ -470,7 +482,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
470
482
  // Private state of this workspace, created on demand for an operation that writes it.
471
483
  const writable = () => {
472
484
  const { project, enablement } = configured()
473
- ensureWorkspaceIdentity({ project, ...(dataRoot === undefined ? {} : { dataRoot }) })
485
+ ensureWorkspaceIdentity({ project, ...(dataRoot === undefined ? {} : { dataRoot }), validate: serviceLocationCheck({ project, dataRoot, env, platform }) })
474
486
  const workspace = resolveServiceWorkspace({ project, dataRoot, env, platform, create: true })
475
487
  return { project, enablement, workspace, repositoryRoots: protectedRoots(project), now: isoTime(clock) }
476
488
  }
@@ -617,10 +629,10 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
617
629
  const removeUnit = async () => {
618
630
  if (!STARTUP_PLATFORMS.includes(platform)) buildStartupAdapter({ platform })
619
631
  const manager = await managerSeam()
620
- const removed = await removeLoginItem({ loadProject, dataRoot, env, platform, manager, clock })
632
+ const removed = await removeLoginItem({ loadProject: locateProject ?? loadProject, dataRoot, env, platform, manager, clock })
621
633
  const failed = removed.removed !== true && removed.reason !== undefined && removed.reason !== 'workspace-not-prepared'
622
634
  if (failed) return { exit: EXIT.notSuccess, document: { loginItem: removed }, human: [`login item not removed: ${removed.reason}${removed.message ? ` (${removed.message})` : ''}`, `Next: ${NEXT[removed.reason] ?? 'see `atelier obsidian status`'}`] }
623
- const remembered = removed.reason === 'workspace-not-prepared' ? false : rememberLoginItem('off')
635
+ const remembered = removed.reason === 'workspace-not-prepared' ? false : rememberLoginItem('off', { locate: true })
624
636
  let service = null
625
637
  if (removed.removed === true) {
626
638
  let entry = null
@@ -645,9 +657,18 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
645
657
  // The person's answer to "start at login?", remembered for this workspace as the `loginItem` decision of its machine
646
658
  // settings, given on the command line. By whom: the actor named, else a person at a terminal by the account's name,
647
659
  // else nobody known. Answers whether it wrote; an answer already remembered is not written again.
648
- const rememberLoginItem = (choice) => {
649
- if (readable().enablement.reason === 'not-configured') return false
650
- const { workspace, repositoryRoots, now } = writable()
660
+ const rememberLoginItem = (choice, { locate = false } = {}) => {
661
+ let workspace, repositoryRoots, now
662
+ if (locate && locateProject !== null) {
663
+ const project = locateProject()
664
+ workspace = resolveServiceWorkspace({ project, dataRoot, env, platform })
665
+ if (!workspace?.workspaceRoot) return false
666
+ repositoryRoots = protectedRoots(project)
667
+ now = isoTime(clock)
668
+ } else {
669
+ if (readable().enablement.reason === 'not-configured') return false
670
+ ;({ workspace, repositoryRoots, now } = writable())
671
+ }
651
672
  const current = machineOf(workspace) ?? defaultMachineSettings({ workspaceId: workspace.workspaceId, updatedAt: now })
652
673
  if (current.decisions.loginItem?.choice === choice) return false
653
674
  const decidedBy = flags['consent-actor'] ?? (interactive ? accountActor(account()) : null)
@@ -681,7 +702,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
681
702
  }
682
703
  const vaults = [...new Set([...underDataRoot, ...allocated])].sort()
683
704
  const userDataDir = obsidianUserDataDir({ platform, env })
684
- return { vaults, privateState: workspace?.workspaceRoot ?? null, projectFile: project.configPath ?? null, pointer: project.configPath ? localPointerPath(project) : null, obsidianList: userDataDir === null ? null : path.join(userDataDir, OBSIDIAN_SETTINGS_FILE) }
705
+ return { vaults, privateState: workspace?.workspaceRoot ?? null, projectFile: project.configPath ?? null, pointer: typeof project.configDir === 'string' && readLocalPointer(project) !== null ? localPointerPath(project) : null, obsidianList: userDataDir === null ? null : path.join(userDataDir, OBSIDIAN_SETTINGS_FILE) }
685
706
  }
686
707
 
687
708
  // Where a view's vault is, or will be: { path, origin }. A view the next tick will place is shown where it would go,
@@ -720,7 +741,10 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
720
741
  const plugin = withPluginFilesPending(pluginView(running, workspace, scopeId), report.freshness)
721
742
  return enablement.state === 'disabled' ? { ...report, outcome: 'disabled', reason: enablement.reason, next: OPENING_OUTCOMES.disabled.next, plugin } : { ...report, plugin }
722
743
  })
744
+ // The confirmed selection a personal home's project was loaded under; reported, never confirmed here.
745
+ const pinned = selection === null ? null : selection.personalSelectionOf(project)
723
746
  const document = {
747
+ ...(pinned === null ? {} : { selection: { generationId: pinned.generationId, sequence: pinned.sequence } }),
724
748
  enablement: { state: enablement.state, reason: enablement.reason, defaultScopeId: enablement.defaultScopeId }, workspace: { workspaceId, prepared: workspace !== null },
725
749
  machine: shownMachine(machineOf(workspace), workspace), apply: applyShown,
726
750
  service: { state: service.state, reason: service.reason ?? null, address: service.address ?? null, runtimeId: service.record?.runtimeId ?? null, pid: service.record?.pid ?? null, lastTick: running?.lastTick ?? null, lastError: running?.lastError ?? null, app },
@@ -729,6 +753,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
729
753
  return {
730
754
  exit: EXIT.ok, document,
731
755
  human: [
756
+ ...(pinned === null ? [] : [`personal workspace: generation ${pinned.generationId}, confirmed as selection ${pinned.sequence}`]),
732
757
  `obsidian: ${enablement.state} (${enablement.reason}); mode ${document.machine.maintenanceMode}; audiences ${document.machine.audienceAllow.join(', ') || 'none'}`,
733
758
  `remembered: ${decisionSummary(document.machine.decisions)}`,
734
759
  `service: ${service.state} (${service.reason ?? 'no reason'})${app ? `; app ${app.outcome} (${app.reason})` : ''}`,
@@ -885,7 +910,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
885
910
  // `me` stands for "only you". Named beside other audiences, it adds its own to theirs, and the list is the person's own choice.
886
911
  const audienceAllow = [...new Set(named.flatMap((item) => (item === 'me' ? ONLY_YOU_AUDIENCES : [item])))]
887
912
  const choice = named.length === 1 && named[0] === 'me' ? 'only-you' : 'custom'
888
- await refuseOnPersonalBinding(loadProject(), `audience ${sub}`)
913
+ await refuseOnPersonalBinding(loadProject(), choice === 'only-you' ? 'audience set me' : `audience ${sub}`)
889
914
  const { workspace, repositoryRoots, now } = writable()
890
915
  const current = machineOf(workspace) ?? defaultMachineSettings({ workspaceId: workspace.workspaceId, updatedAt: now })
891
916
  // A vault that is only yours shows the notes that carry no classification too; any other list withholds them.
@@ -1073,17 +1098,19 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
1073
1098
  // Maintenance of this workspace ends here: the login item goes first, so its manager does not start the service
1074
1099
  // again, then the proven service stops. Everything a person made or may want back stays, and is named.
1075
1100
  async uninstall() {
1076
- const project = loadProject()
1077
- let workspace = null
1078
- try { const found = resolveServiceWorkspace({ project, dataRoot, env, platform }); workspace = found?.workspaceRoot ? found : null } catch (error) { if (!isTyped(error)) throw error }
1101
+ const project = (locateProject ?? loadProject)()
1102
+ // Only an established absence (no pointer, or no private state yet) is unprepared. A location or identity refusal
1103
+ // stops here, before any login-item or service action, so uninstall never reports success over it.
1104
+ const found = resolveServiceWorkspace({ project, dataRoot, env, platform })
1105
+ const workspace = found?.workspaceRoot ? found : null
1079
1106
  const record = workspace === null ? null : readLoginItemRecord(workspace)
1080
1107
  // What is kept is read before anything is stopped or removed, so nothing it finds can stop the answer after that.
1081
1108
  const kept = keptLocations(project, workspace)
1082
- const item = record === null ? { removed: false, reason: 'not-installed' } : await removeLoginItem({ loadProject, dataRoot, env, platform, manager: await managerSeam(), clock })
1109
+ const item = record === null ? { removed: false, reason: 'not-installed' } : await removeLoginItem({ loadProject: locateProject ?? loadProject, dataRoot, env, platform, manager: await managerSeam(), clock })
1083
1110
  const service = workspace === null ? { state: 'stopped', stopped: false, refused: false, reason: 'workspace-not-prepared' } : await stopService(lifecycle, lifecycleRules)
1084
1111
  const itemGone = item.removed === true || item.reason === 'not-installed' || item.reason === 'workspace-not-prepared'
1085
1112
  const serviceGone = service.stopped === true || service.state === 'stopped'
1086
- const remembered = workspace !== null && itemGone ? rememberLoginItem('off') : false
1113
+ const remembered = workspace !== null && itemGone ? rememberLoginItem('off', { locate: true }) : false
1087
1114
  return {
1088
1115
  exit: itemGone && serviceGone ? EXIT.ok : EXIT.notSuccess,
1089
1116
  document: { loginItem: item, service, kept, rememberedNow: { loginItem: remembered ? 'off' : null } },
@@ -1095,6 +1122,7 @@ export async function runObsidianCommandForOracleTests(options = {}, rules = {})
1095
1122
  ...kept.vaults.map((vault) => ` vault ${vault}`),
1096
1123
  ...(kept.privateState === null ? [] : [` private state ${kept.privateState}`]),
1097
1124
  ` project file ${kept.projectFile ?? '(none)'}; its Obsidian settings stay`,
1125
+ ...(kept.pointer === null ? [] : [` workspace pointer ${kept.pointer}`]),
1098
1126
  ...(kept.obsidianList === null ? [] : [` Obsidian's list ${kept.obsidianList}; the vaults stay in it`]),
1099
1127
  ],
1100
1128
  }
@@ -7,6 +7,7 @@ import {
7
7
  createProtocolRegistry,
8
8
  } from '../extension-packs/loader.mjs'
9
9
  import { runProtocol } from '../readiness-protocols/runtime.mjs'
10
+ import { bundledReadinessEnabled, requireSelectedReadinessProtocol } from '../readiness-protocols/bundled-selection.mjs'
10
11
  import { recordReviewEvidence } from '../readiness-protocols/evidence.mjs'
11
12
  import { createReviewStore } from '../collaboration/review-store.mjs'
12
13
  import {
@@ -73,7 +74,9 @@ let result
73
74
  if (args._[0] === 'run') {
74
75
  const registry = createProtocolRegistry({
75
76
  packs: loadExtensionPacks(project).packs,
77
+ includeBundled: bundledReadinessEnabled(project),
76
78
  })
79
+ requireSelectedReadinessProtocol(project, registry, args._[1])
77
80
  const answers = args.answers ? readJson(path.resolve(args.answers)) : {}
78
81
  const { run } = runProtocol(project, args._[1], {
79
82
  answers,
@@ -0,0 +1,69 @@
1
+ import crypto from 'node:crypto'
2
+ import { canonicalize } from '../attestation/jcs.mjs'
3
+
4
+ // Experimental adapter shapes, not an ontology or identity authority.
5
+ export const EDGE_RECORD_SCHEMA = 'atelier-edge-record@experimental-v1'
6
+ export const EDGE_DECISION_SCHEMA = 'atelier-edge-decision@experimental-v1'
7
+ export const EDGE_FAMILIES = Object.freeze(['hyperlink', 'source-declaration', 'reviewed-assertion'])
8
+ export const EDGE_ACTIONS = Object.freeze(['propose', 'approve', 'accept', 'reject', 'suspend', 'withdraw', 'supersede'])
9
+ const digestPattern = /^sha256:[a-f0-9]{64}$/
10
+ const text = (v, max = 256) => typeof v === 'string' && v.length > 0 && v.trim() === v && v.length <= max && !/[\u0000-\u001f\u007f]/.test(v)
11
+ const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
12
+ const closed = (v, keys) => object(v) && Object.keys(v).length === keys.length && keys.every((k) => Object.hasOwn(v, k))
13
+ const relativePath = (v) => text(v, 2048) && !v.includes('\\') && !v.startsWith('/') && !/^[A-Za-z]:/.test(v) && v.split('/').every((p) => p && p !== '.' && p !== '..')
14
+ export const validNodeRef = (v) => closed(v, ['repoId', 'localId']) && text(v.repoId) && text(v.localId)
15
+ export const bytesDigest = (v) => `sha256:${crypto.createHash('sha256').update(v).digest('hex')}`
16
+ export const edgeDigest = (v) => bytesDigest(canonicalize(v))
17
+ export function immutable(value) {
18
+ if (value && typeof value === 'object') {
19
+ for (const child of Object.values(value)) immutable(child)
20
+ Object.freeze(value)
21
+ }
22
+ return value
23
+ }
24
+
25
+ export function validSourceRef(v) {
26
+ return closed(v, ['repoId', 'path', 'revision', 'fileDigest', 'locator', 'citedDigest']) &&
27
+ text(v.repoId) && relativePath(v.path) && text(v.revision) && digestPattern.test(v.fileDigest) && digestPattern.test(v.citedDigest) &&
28
+ closed(v.locator, ['byteStart', 'byteEnd']) && Number.isSafeInteger(v.locator.byteStart) && v.locator.byteStart >= 0 &&
29
+ Number.isSafeInteger(v.locator.byteEnd) && v.locator.byteEnd > v.locator.byteStart
30
+ }
31
+
32
+ export function validateEdgeRecord(v) {
33
+ try {
34
+ if (!closed(v, ['schema', 'family', 'subject', 'predicate', 'object', 'home', 'origin', 'supports', 'policyDigest', 'proposalRef', 'sourceChange', 'qualifications'])) return false
35
+ if (v.schema !== EDGE_RECORD_SCHEMA || !EDGE_FAMILIES.includes(v.family) || !validNodeRef(v.subject) || !validNodeRef(v.object) || !text(v.predicate)) return false
36
+ if (!closed(v.home, ['repoId', 'path', 'readership']) || !text(v.home.repoId) || !relativePath(v.home.path) || !text(v.home.readership)) return false
37
+ if (!validSourceRef(v.origin) || !Array.isArray(v.supports) || v.supports.length > 256 ||
38
+ v.supports.some((s) => !closed(s, ['id', 'source']) || !text(s.id) || !validSourceRef(s.source)) || new Set(v.supports.map((s) => s.id)).size !== v.supports.length) return false
39
+ if (!digestPattern.test(v.policyDigest) || (v.proposalRef !== null && !text(v.proposalRef, 2048)) ||
40
+ !Array.isArray(v.qualifications) || v.qualifications.length > 64 || v.qualifications.some((q) => !text(q, 2048))) return false
41
+ if (v.family === 'hyperlink' && (v.predicate !== 'links-to' || v.sourceChange !== null)) return false
42
+ if (v.sourceChange !== null && (!closed(v.sourceChange, ['repoId', 'path', 'baseRevision', 'diffDigest', 'reviewRef']) ||
43
+ !text(v.sourceChange.repoId) || !relativePath(v.sourceChange.path) || !text(v.sourceChange.baseRevision) || !digestPattern.test(v.sourceChange.diffDigest) || !text(v.sourceChange.reviewRef, 2048))) return false
44
+ edgeDigest(v)
45
+ return true
46
+ } catch { return false }
47
+ }
48
+
49
+ export function validateEdgeDecision(v) {
50
+ try {
51
+ if (!closed(v, ['schema', 'recordDigest', 'sequence', 'previousDigest', 'action', 'actorId', 'at', 'policyDigest', 'reason', 'roles', 'signature'])) return false
52
+ if (v.schema !== EDGE_DECISION_SCHEMA || !digestPattern.test(v.recordDigest) || !Number.isSafeInteger(v.sequence) || v.sequence < 1 ||
53
+ (v.previousDigest !== null && !digestPattern.test(v.previousDigest)) || !EDGE_ACTIONS.includes(v.action) || !text(v.actorId) ||
54
+ !text(v.at) || !Number.isFinite(Date.parse(v.at)) || new Date(v.at).toISOString() !== v.at || !digestPattern.test(v.policyDigest) || !text(v.reason, 2048)) return false
55
+ if (!Array.isArray(v.roles) || v.roles.length > 3 || v.roles.some((r) => !closed(r, ['repoId', 'role']) || !text(r.repoId) || !['home-owner', 'endpoint-steward'].includes(r.role)) ||
56
+ new Set(v.roles.map((r) => canonicalize(r))).size !== v.roles.length) return false
57
+ if (v.action !== 'approve' && v.roles.length !== 0) return false
58
+ if (v.action === 'approve' && v.roles.length === 0) return false
59
+ if (!closed(v.signature, ['algorithm', 'keyId', 'value']) || !['ed25519', 'es256'].includes(v.signature.algorithm) || !text(v.signature.keyId) || !text(v.signature.value, 512)) return false
60
+ edgeDigest(v)
61
+ return true
62
+ } catch { return false }
63
+ }
64
+
65
+ export function edgeIdentity(record) {
66
+ if (!validateEdgeRecord(record)) throw new TypeError('invalid edge record')
67
+ const recordDigest = edgeDigest(record)
68
+ return Object.freeze({ edgeId: `edge:${recordDigest.slice(7)}`, recordDigest })
69
+ }
@@ -0,0 +1,142 @@
1
+ import { verifyDocument } from '../attestation/sign.mjs'
2
+ import { bytesDigest, edgeDigest, edgeIdentity, immutable, validateEdgeDecision, validateEdgeRecord } from './contract.mjs'
3
+
4
+ const roleKey = (role) => `${role.role}\0${role.repoId}`
5
+ const same = (a, b) => edgeDigest(a) === edgeDigest(b)
6
+ const fail = (state, reason) => ({ state, eligible: false, reason })
7
+ const decisions = new WeakSet()
8
+ const seal = (value) => { const result = immutable(value); decisions.add(result); return result }
9
+ function demand(value, reason) { if (!value) throw new Error(reason) }
10
+
11
+ function validPolicy(p) {
12
+ return p && Object.keys(p).sort().join(',') === 'allowProposerReview,allowSharedSteward,version' &&
13
+ typeof p.version === 'string' && p.version.length > 0 && typeof p.allowProposerReview === 'boolean' && typeof p.allowSharedSteward === 'boolean'
14
+ }
15
+
16
+ // Content-bound occurrence identity only; this does not establish access,
17
+ // source currency or eligibility. Keep caller-owned records untouched.
18
+ export function edgeEvidenceOccurrences(record) {
19
+ const { edgeId } = edgeIdentity(record)
20
+ const occurrence = (id, source) => ({ id: `evidence:${edgeDigest({ edgeId, occurrenceId: id, source }).slice(7)}`, occurrenceId: id, source: structuredClone(source) })
21
+ return immutable([occurrence('origin', record.origin), ...record.supports.map((s) => occurrence(`support:${s.id}`, s.source))])
22
+ }
23
+
24
+ // Host functions are trusted integration ports. No record-supplied identity,
25
+ // eligible flag, audience or local asserted review substitutes for these ports.
26
+ // The host must hold one coherent current snapshot across an evaluation.
27
+ export async function evaluateEdge(input, host) {
28
+ let record, events
29
+ try {
30
+ record = structuredClone(input.record)
31
+ events = structuredClone(input.events)
32
+ if (!validateEdgeRecord(record) || !Array.isArray(events) || events.length > 1024 || events.some((e) => !validateEdgeDecision(e))) return immutable(fail('suspended', 'invalid-input'))
33
+ immutable(record); immutable(events)
34
+ const identity = edgeIdentity(record)
35
+ const policy = immutable(structuredClone(host.policy))
36
+ demand(validPolicy(policy), 'policy-unavailable')
37
+ const policyDigest = edgeDigest(policy)
38
+ demand(typeof host.bindingId === 'string' && host.bindingId.length > 0, 'snapshot-binding-unavailable')
39
+ const base = { ...identity, family: record.family, policyDigest, verification: { checkedAt: host.now, bindingId: host.bindingId }, reviewRefs: [], triple: { subject: record.subject, predicate: record.predicate, object: record.object },
40
+ supportRefs: edgeEvidenceOccurrences(record), qualifications: record.qualifications,
41
+ sourceChange: record.sourceChange, history: events }
42
+ const result = (value) => seal({ ...base, ...value })
43
+ if (record.policyDigest !== policyDigest) return result(fail('suspended', 'policy-changed'))
44
+ const now = Date.parse(host.now)
45
+ demand(Number.isFinite(now), 'clock-unavailable')
46
+ const head = await host.readRecordHead(identity)
47
+ if (!head || head.recordDigest !== identity.recordDigest || head.eventCount !== events.length || head.eventDigest !== (events.length ? edgeDigest(events.at(-1)) : null)) return result(fail('suspended', 'history-head-mismatch'))
48
+ const origin = await host.verifyOrigin({ record, ...identity, policyDigest })
49
+ if (!origin || origin.recordDigest !== identity.recordDigest || origin.bindingId !== host.bindingId || origin.matches !== true) return result(fail('suspended', 'origin-mismatch'))
50
+ // Safe placement considers the home's entire intended readership, not only
51
+ // this reader. The host checks endpoints, all evidence and record metadata.
52
+ const placement = await host.checkSourceHome({ record, ...identity, policyDigest })
53
+ if (!placement || placement.recordDigest !== identity.recordDigest || placement.policyDigest !== policyDigest || placement.bindingId !== host.bindingId || placement.allowed !== true) return result(fail('suspended', 'unsafe-source-home'))
54
+ const endpoints = await Promise.all([record.subject, record.object].map((ref) => host.resolveEndpoint(ref)))
55
+ if (endpoints.some((e, i) => !e || !same(e.ref, [record.subject, record.object][i]) || e.current !== true || typeof e.stewardId !== 'string' || !e.stewardId)) return result(fail('suspended', 'endpoint-unavailable'))
56
+ const home = await host.resolveHomeOwner(record.home.repoId)
57
+ if (!home || typeof home.actorId !== 'string' || !home.actorId) return result(fail('suspended', 'home-owner-unavailable'))
58
+ for (const { source } of base.supportRefs) {
59
+ const observed = await host.readSource(source)
60
+ if (!observed || observed.revision !== source.revision || !(observed.bytes instanceof Uint8Array)) return result(fail('stale', 'source-changed'))
61
+ const bytes = Buffer.from(observed.bytes)
62
+ if (bytesDigest(bytes) !== source.fileDigest || source.locator.byteEnd > bytes.length ||
63
+ bytesDigest(bytes.subarray(source.locator.byteStart, source.locator.byteEnd)) !== source.citedDigest) return result(fail('stale', 'citation-changed'))
64
+ }
65
+ const semantic = record.family !== 'hyperlink'
66
+ const crossRepo = record.subject.repoId !== record.object.repoId
67
+ const governed = semantic && (crossRepo || record.family === 'reviewed-assertion' || events.length > 0)
68
+ if (!semantic) return result(events.length ? fail('suspended', 'navigation-has-decisions') : { state: 'active', eligible: true, assurance: 'navigation' })
69
+ if (!governed) return result({ state: 'active', eligible: true, assurance: 'source-declaration' })
70
+ if (!record.proposalRef || !record.sourceChange || record.sourceChange.repoId !== record.home.repoId || record.sourceChange.path !== record.home.path) return result(fail('suspended', 'source-owner-handoff-missing'))
71
+ // Review roles in this adapter are repository-scoped. Conflicting resource
72
+ // stewards inside one repository cannot be collapsed into one approval.
73
+ if (!crossRepo && endpoints[0].stewardId !== endpoints[1].stewardId) return result(fail('suspended', 'endpoint-steward-conflict'))
74
+ const required = [{ repoId: record.home.repoId, role: 'home-owner', actorId: home.actorId },
75
+ ...[...new Map(endpoints.map((e) => [e.ref.repoId, { repoId: e.ref.repoId, role: 'endpoint-steward', actorId: e.stewardId }])).values()]]
76
+ if (crossRepo && endpoints[0].stewardId === endpoints[1].stewardId && !policy.allowSharedSteward) return result(fail('suspended', 'shared-steward-not-delegated'))
77
+ let previous = null, state = 'draft', proposer = null, lastTime = -Infinity
78
+ const approvals = new Map()
79
+ for (const [index, event] of events.entries()) {
80
+ if (event.sequence !== index + 1 || event.previousDigest !== previous || event.recordDigest !== identity.recordDigest || event.policyDigest !== policyDigest || Date.parse(event.at) < lastTime || Date.parse(event.at) > now) return result(fail('suspended', 'decision-chain-invalid'))
81
+ if (['withdrawn', 'rejected', 'superseded', 'suspended'].includes(state)) return result(fail('suspended', 'terminal-history-continued'))
82
+ const binding = await host.resolveActorKey({ actorId: event.actorId, keyId: event.signature.keyId, at: host.now })
83
+ if (!binding || binding.actorId !== event.actorId || binding.revoked !== false || !Number.isFinite(Date.parse(binding.notBefore)) || !Number.isFinite(Date.parse(binding.expiresAt)) ||
84
+ Date.parse(binding.notBefore) > Date.parse(event.at) || Date.parse(binding.expiresAt) <= now || !verifyDocument(event, { publicKey: binding.publicKey }).valid) return result(fail('suspended', 'actor-key-unavailable'))
85
+ const grant = await host.authorizeDecision({ event, record, required, policyDigest })
86
+ if (!grant || grant.eventDigest !== edgeDigest(event) || grant.policyDigest !== policyDigest || grant.bindingId !== host.bindingId || grant.allowed !== true || grant.revoked !== false ||
87
+ !Number.isFinite(Date.parse(grant.notBefore)) || Date.parse(grant.notBefore) > Date.parse(event.at) || !Number.isFinite(Date.parse(grant.expiresAt)) || Date.parse(grant.expiresAt) <= now) return result(fail('suspended', 'decision-grant-unavailable'))
88
+ if (event.action === 'propose') {
89
+ if (state !== 'draft') return result(fail('suspended', 'invalid-transition'))
90
+ proposer = event.actorId; state = 'awaiting-endpoint-review'
91
+ } else if (event.action === 'approve') {
92
+ if (state !== 'awaiting-endpoint-review' || (!policy.allowProposerReview && event.actorId === proposer)) return result(fail('suspended', 'invalid-reviewer'))
93
+ for (const role of event.roles) {
94
+ const match = required.find((r) => roleKey(r) === roleKey(role) && r.actorId === event.actorId)
95
+ if (!match) return result(fail('suspended', 'invalid-reviewer'))
96
+ approvals.set(roleKey(role), edgeDigest(event))
97
+ }
98
+ } else if (event.action === 'accept') {
99
+ if (state !== 'awaiting-endpoint-review' || event.actorId !== home.actorId || required.some((r) => !approvals.has(roleKey(r)))) return result(fail('suspended', 'missing-reviewer'))
100
+ state = 'active'
101
+ } else {
102
+ if (state === 'draft' || ![home.actorId, proposer, ...endpoints.map((e) => e.stewardId)].includes(event.actorId)) return result(fail('suspended', 'invalid-transition'))
103
+ state = { reject: 'rejected', suspend: 'suspended', withdraw: 'withdrawn', supersede: 'superseded' }[event.action]
104
+ }
105
+ previous = edgeDigest(event); lastTime = Date.parse(event.at)
106
+ }
107
+ return result({ state, eligible: state === 'active', assurance: state === 'active' ? 'reviewed-assertion' : 'pending', reviewRefs: [...new Set(approvals.values())].sort() })
108
+ } catch {
109
+ // Port errors must not disclose hidden paths, actor details or source bytes.
110
+ return immutable(fail('suspended', 'verification-unavailable'))
111
+ }
112
+ }
113
+
114
+ // Input results must already be authorized for one recipient and evaluated in
115
+ // the same host snapshot. Preserve each complete assertion and its supports.
116
+ export function groupEligibleEdges(results) {
117
+ const groups = new Map()
118
+ let binding = null
119
+ for (const result of results) {
120
+ if (!decisions.has(result)) throw new Error('unverified edge decision')
121
+ if (!result.eligible || result.state !== 'active') continue
122
+ const current = edgeDigest({ bindingId: result.verification.bindingId, policyDigest: result.policyDigest })
123
+ if (binding !== null && binding !== current) throw new Error('mixed edge decision bindings')
124
+ binding = current
125
+ const key = edgeDigest({ family: result.family, triple: result.triple, qualifications: result.qualifications })
126
+ const group = groups.get(key) ?? { groupId: `edge-group:${key.slice(7)}`, family: result.family, triple: result.triple, assertions: [] }
127
+ if (group.assertions.some((a) => a.edgeId === result.edgeId && edgeDigest(a) !== edgeDigest(result))) throw new Error('conflicting edge decisions')
128
+ if (!group.assertions.some((a) => a.edgeId === result.edgeId)) group.assertions.push(structuredClone(result))
129
+ groups.set(key, group)
130
+ }
131
+ return immutable([...groups.values()].map((g) => ({ ...g, assertions: g.assertions.sort((a, b) => a.edgeId.localeCompare(b.edgeId, 'en')) })).sort((a, b) => a.groupId.localeCompare(b.groupId, 'en')))
132
+ }
133
+
134
+ // Only evaluated decisions may enter this seam; a deserialized eligible flag is
135
+ // never authority. The access host revalidates bindingId/current grants at use.
136
+ export function displayEligibleEdge(result) {
137
+ if (!decisions.has(result) || !result.eligible || result.state !== 'active') return null
138
+ return immutable({ edgeId: result.edgeId, source: result.triple.subject, target: result.triple.object,
139
+ predicate: result.triple.predicate, family: result.family, acceptanceState: result.state, assurance: result.assurance,
140
+ supportRefs: result.supportRefs.map((s) => s.id), sourceRef: result.supportRefs[0].id, reviewRefs: result.reviewRefs,
141
+ qualifications: result.qualifications, recordDigest: result.recordDigest, policyDigest: result.policyDigest, verification: result.verification })
142
+ }
@@ -0,0 +1,43 @@
1
+ import { canonicalize } from '../attestation/jcs.mjs'
2
+ import { validateRepositoryContribution } from '../graph/repo-contribution.mjs'
3
+ import { validSourceRef } from './contract.mjs'
4
+
5
+ const need = (value) => { if (!value) throw new TypeError('Invalid source citation input') }
6
+
7
+ /**
8
+ * Convert an exact observed occurrence to the policy's raw-byte citation shape.
9
+ * This pure structural binding does not authenticate source bytes, source-home
10
+ * custody, freshness or a declared triple. The policy host must still implement
11
+ * readSource, verifyOrigin and checkSourceHome against its current snapshot.
12
+ * Original provenance, including UTF16 offsets and any pointer/target range,
13
+ * stays unchanged in the contribution. Only its outer raw-byte range is cited.
14
+ * @param {import('../graph/repo-contribution.types.mjs').RepositoryContribution} contribution
15
+ * @param {import('../graph/repo-contribution.types.mjs').SourceRef} provenance
16
+ */
17
+ export function sourceCitationFromProvenance(contribution, provenance) {
18
+ try {
19
+ need(validateRepositoryContribution(contribution).ok)
20
+ const { snapshot, repository } = contribution
21
+ need(provenance?.repoId === repository.repoId && provenance.revision === snapshot.digest)
22
+ const file = snapshot.files.find((pin) => pin.path === provenance.path)
23
+ need(file && provenance.digest === file.rawDigest)
24
+ const { range, rangeDigest } = provenance.locator ?? {}
25
+ need(range && Number.isSafeInteger(range.byteStart) && range.byteStart >= 0 &&
26
+ Number.isSafeInteger(range.byteEnd) && range.byteEnd > range.byteStart && range.byteEnd <= file.byteLength)
27
+ // A matching file pin alone cannot invent a source occurrence. Accept a
28
+ // detached copy too, while retaining every field of the recorded provenance.
29
+ const observed = canonicalize(provenance)
30
+ need([...contribution.ordinaryLinks, ...contribution.declaredRelations]
31
+ .some((occurrence) => canonicalize(occurrence.provenance) === observed))
32
+ const citation = {
33
+ repoId: provenance.repoId, path: provenance.path, revision: provenance.revision,
34
+ fileDigest: provenance.digest,
35
+ locator: { byteStart: range.byteStart, byteEnd: range.byteEnd },
36
+ citedDigest: rangeDigest,
37
+ }
38
+ need(validSourceRef(citation))
39
+ return citation
40
+ } catch {
41
+ throw new TypeError('Invalid source citation input')
42
+ }
43
+ }
@@ -468,7 +468,8 @@ export function loadExtensionPacks(project, { report = false, verifyLock = true
468
468
  // Explicit registry object, no global state. Bundled resolution keeps its
469
469
  // existing id | slug | namespaced affordance; pack protocols resolve by full
470
470
  // namespaced id only, so a pack can never shadow or squat a bundled slug.
471
- export function createProtocolRegistry({ packs = [] } = {}) {
471
+ export function createProtocolRegistry({ packs = [], includeBundled = true } = {}) {
472
+ if (typeof includeBundled !== 'boolean') throw new TypeError('includeBundled must be a boolean')
472
473
  const packProtocols = new Map()
473
474
  for (const pack of packs) {
474
475
  for (const record of pack.protocols ?? []) packProtocols.set(record.id, record)
@@ -476,14 +477,14 @@ export function createProtocolRegistry({ packs = [] } = {}) {
476
477
  return {
477
478
  packs,
478
479
  protocolById(id) {
479
- const bundled = getBundledReadinessProtocol(id)
480
+ const bundled = includeBundled ? getBundledReadinessProtocol(id) : null
480
481
  if (bundled) return bundled
481
482
  const record = packProtocols.get(id)
482
483
  return record ? record.protocol : null
483
484
  },
484
485
  listProtocols() {
485
486
  return [
486
- ...bundledReadinessProtocols,
487
+ ...(includeBundled ? bundledReadinessProtocols : []),
487
488
  ...[...packProtocols.values()].map((record) => record.protocol),
488
489
  ]
489
490
  },
@@ -0,0 +1,83 @@
1
+ import { canonicalJson, exact, ExtensionError } from './contracts.mjs'
2
+
3
+ function joinBytes(chunks, size) {
4
+ const bytes = new Uint8Array(size)
5
+ let offset = 0
6
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength }
7
+ return bytes
8
+ }
9
+ async function cancelResponseBody(body) {
10
+ if (!body) return
11
+ let timer
12
+ try {
13
+ await Promise.race([
14
+ Promise.resolve().then(() => body.cancel()).catch(() => {}),
15
+ new Promise(resolve => { timer = setTimeout(resolve, 100) }),
16
+ ])
17
+ } finally { clearTimeout(timer) }
18
+ }
19
+ export async function readInvocationBody(request) {
20
+ const body = request.body?.getReader()
21
+ if (!body) throw new ExtensionError('invalid-input')
22
+ try {
23
+ let size = 0
24
+ const chunks = []
25
+ for (;;) {
26
+ const { value, done } = await body.read()
27
+ if (done) break
28
+ size += value.byteLength
29
+ if (size > 65536) throw new ExtensionError('invalid-input')
30
+ chunks.push(value)
31
+ }
32
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(joinBytes(chunks, size)))
33
+ } finally { await body.cancel().catch(() => {}) }
34
+ }
35
+ export function createRootAdmissionConsumer(options) {
36
+ return createAdmissionConsumer(options, options.allowLocalTesting && ['test', 'development'].includes(process.env.NODE_ENV))
37
+ }
38
+ export function createAdmissionConsumer({ origin, fetch: hostFetch = globalThis.fetch }, localTestingAuthorized = false) {
39
+ const target = new URL(origin)
40
+ const local = localTestingAuthorized &&
41
+ target.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(target.hostname)
42
+ if ((target.protocol !== 'https:' && !local) || target.username || target.password || target.pathname !== '/' || target.search || target.hash) throw new ExtensionError('invalid-input')
43
+ const endpoint = new URL('/api/root-runtime/extensions/admissions/consume', target)
44
+ return async (_claims, delegation) => {
45
+ // Convex supports AbortController and action timers, but not every runtime
46
+ // implements AbortSignal.timeout. The deadline includes the response body.
47
+ const controller = new AbortController()
48
+ let timer, response, reader
49
+ const expired = new Promise((_, reject) => {
50
+ timer = setTimeout(() => { controller.abort(); reject(new ExtensionError('unavailable', 503)) }, 5000)
51
+ })
52
+ const consume = async () => {
53
+ response = await hostFetch(endpoint, { method: 'POST', redirect: 'error', signal: controller.signal,
54
+ headers: { 'content-type': 'application/json' }, body: canonicalJson({ delegation }) })
55
+ if (response.status === 401 || response.status === 403) return false
56
+ if (response.status != 200 || response.headers.get('content-type')?.split(';')[0] !== 'application/json') throw new ExtensionError('unavailable', 503)
57
+ reader = response.body?.getReader()
58
+ if (!reader) throw new ExtensionError('unavailable', 503)
59
+ try {
60
+ let size = 0
61
+ const chunks = []
62
+ for (;;) {
63
+ const { value, done } = await reader.read()
64
+ if (done) break
65
+ size += value.byteLength
66
+ if (size > 1024) throw new ExtensionError('unavailable', 503)
67
+ chunks.push(value)
68
+ }
69
+ const body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(joinBytes(chunks, size)))
70
+ exact(body, ['admitted'])
71
+ if (body.admitted !== true) throw new ExtensionError('unavailable', 503)
72
+ return true
73
+ } catch { throw new ExtensionError('unavailable', 503) }
74
+ }
75
+ try { return await Promise.race([consume(), expired]) } finally {
76
+ clearTimeout(timer)
77
+ // Also close refusal/error responses, whose body reader was never acquired.
78
+ // Abort first; a broken/custom stream cancellation cannot extend transport.
79
+ controller.abort()
80
+ await cancelResponseBody(reader ?? response?.body)
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,23 @@
1
+ import type { ExtensionReleaseBundle, ExtensionReleaseRegistration, ExtensionManifest, InstallationBinding, Invocation, Json, OperationReceipt } from './contracts.mjs'
2
+ export interface HostTransport {
3
+ getInstallation(id: string): Promise<InstallationBinding>;
4
+ invoke(request: Invocation): Promise<OperationReceipt>;
5
+ lookupOperation(id: string, key: string, action: string): Promise<OperationReceipt | null>;
6
+ recoverOperation(binding: InstallationBinding, key: string, action: string): Promise<OperationReceipt>;
7
+ recordResult(operationId: string): Promise<void>;
8
+ }
9
+ export interface AdminHostTransport {
10
+ registerRelease?(bundle: ExtensionReleaseBundle): Promise<ExtensionReleaseRegistration>;
11
+ configure(manifest: ExtensionManifest, connectionRef: string, environment: 'development' | 'staging' | 'production'): Promise<InstallationBinding>;
12
+ transition(binding: InstallationBinding, transition: 'verify' | 'enable' | 'disable' | 'remove'): Promise<InstallationBinding>;
13
+ upgrade(binding: InstallationBinding, manifest: ExtensionManifest): Promise<InstallationBinding>;
14
+ }
15
+ export function createHttpHostTransport(hostFetch: typeof fetch): HostTransport & AdminHostTransport
16
+ export function createExtensionClient(transport: HostTransport): {
17
+ getInstallation(id: string): Promise<InstallationBinding>;
18
+ invoke(binding: InstallationBinding, actionId: string, input: Json, idempotencyKey: string): Promise<OperationReceipt>;
19
+ lookupOperation(id: string, key: string, action: string): Promise<OperationReceipt | null>;
20
+ recoverOperation(binding: InstallationBinding, key: string, action: string): Promise<OperationReceipt>;
21
+ recordResult(operationId: string): Promise<void>;
22
+ }
23
+ export function createExtensionAdminClient(transport: AdminHostTransport): Omit<AdminHostTransport, 'configure' | 'registerRelease'> & { registerRelease(bundle: ExtensionReleaseBundle): Promise<ExtensionReleaseRegistration>; configure(manifest: ExtensionManifest, connectionRef: string, environment?: 'development' | 'staging' | 'production'): Promise<InstallationBinding> }